Compare commits

12 Commits

Author SHA1 Message Date
znetsixe
e991ea64ef Merge origin/basin-docs-update: per-mode SVG + stopLevel hysteresis + shifted ramp
Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:

  control/levelBased.js
    - stopLevel Schmitt-trigger + dead-band keep-alive
    - Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
    - Linear vs log up-curve (curveType + logCurveFactor)

  measurement/flowAggregator.js
    - Predicted-volume overflow clamp + spill flow stream
    - Cumulative overflowVolume + underflowVolume
    - Hard floor at 0 + dry-run-on-transition handling

  basin/thresholdValidator.js
    - computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
    - startLevel ≤ inflowLevel invariant added

  measurement/calibration.js + commands/
    - Manual q_out path (set.outflow / q_out alias)

  safety/safetyController.js
    - Accepts both legacy + new high-volume threshold names

UI:
  pumpingStation.html — restored the side-panel + SVG mode-preview block,
  added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
  logCurveFactor/enableShiftedRamp.
  src/editor/* — basin-docs' 7-file modular editor (replaces single
  src/editor.js, which is deleted).
  pumpingStation.js — admin endpoint serves editor/:file.

Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.

Human-review items (see commit context):
  - rampFoot = inflowLevel (matches basin-docs test); basin-docs source
    used rampFoot = startLevel. Domain owner: confirm intent.
  - Naming kept dual (overfillLevel + highVolumeSafetyLevel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:19:55 +02:00
znetsixe
ed22f01932 P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
  01-Basic.json         14 nodes, inject + dashboard, no parent
  02-Integration.json   32 nodes, 2 tabs, measurement + MGC + 2 pumps,
                        link-out/link-in channels per node-red-flow-layout.md
  03-Dashboard.json     63 nodes, 3 tabs (process + UI + setup),
                        FlowFuse charts + sliders, trend-split pattern
  README.md             load instructions
  tools/build-examples.js  regenerator

All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.

wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.

package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
znetsixe
d2384b1a2d P10.7a: fix test script (was running pumpingStation.js, now node --test test/)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:40:46 +02:00
znetsixe
52d3889fbc P2 wave 2: convert pumpingStation orchestrator to BaseDomain + BaseNodeAdapter
specificClass.js: 860 → 245 lines.
  PumpingStation extends BaseDomain. configure() wires basin / flow /
  measurement / safety / control modules. tick() is the orchestration
  trio: flowAggregator.tick() → safety.evaluate() → control.dispatch()
  → state snapshot → notifyOutputChanged().

  Public surface preserved for tests: machines / stations /
  machineGroups remain plain id-keyed dicts (registry is still source
  of truth via ChildRouter; see OPEN_QUESTIONS.md 2026-05-10), legacy
  delegates _controlLevelBased / _calc{Volume,Level}From* / percControl
  getter+setter all retained. Calibration + setManualInflow forward to
  the calibration module.

nodeClass.js: 263 → 45 lines.
  Extends BaseNodeAdapter. static DomainClass = PumpingStation, static
  commands = require('./commands'), static tickInterval = 1000 (predicted
  volume integrator needs delta-time), static statusInterval = 1000.
  buildDomainConfig maps the Node-RED uiConfig fields onto the domain
  slice (basin / hydraulics / control / safety).

102 / 102 basic tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:05 +02:00
znetsixe
7afcd6e54a P2 wave 1: extract concerns from pumpingStation specificClass
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.

  src/basin/         BasinGeometry + thresholdValidator (pure)
  src/measurement/   flowAggregator + measurementRouter + calibration
  src/control/       levelBased + flowBased(stub) + manual + index dispatcher
  src/safety/        safetyController split into dryRun + overfill rules
  src/commands/      registry array + handlers (canonical names from start)
  src/editor.js      260 lines of SVG basin-diagram redraw, was inline in .html
  examples/standalone-demo.js  was if(require.main===module) at bottom of specificClass.js
  CONTRACT.md        canonical inputs + outputs + emitted events

Modified:
  src/specificClass.js  removed the 170-line standalone demo block
  pumpingStation.html   oneditprepare/oneditsave delegate to editor.{init,save}
  pumpingStation.js     added admin endpoint serving src/editor.js

102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:18:49 +02:00
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
59 changed files with 9112 additions and 1711 deletions

57
CONTRACT.md Normal file
View File

@@ -0,0 +1,57 @@
# pumpingStation — Contract
Hand-maintained for Phase 2; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `set.mode` | `changemode` | `string` — one of `manual`, `levelbased`, `flowbased`, `none` | Switches the control strategy. |
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils` at the supplied `msg.positionVsParent`. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
Aliases log a one-time deprecation warning the first time they fire.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
(only changed fields are emitted).
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
to the upstream parent.
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
the corresponding series receives a new value. Parents subscribe via the
generic `child.measurements.emitter.on(eventName, ...)` handshake.
pumpingStation publishes:
- `volume.predicted.atequipment` — basin volume integrator output (m³).
- `level.predicted.atequipment` — basin level (m), recomputed from volume.
- `flow.predicted.in` (childed `manual-qin`) — manual inflow injections.
- `volume.measured.atequipment`, `level.measured.<position>`,
`pressure.measured.<position>`, `temperature.measured.atequipment`,
`flow.predicted.<in|out>` (childed by upstream child id) — when a
matching child measurement arrives.
The exact set is data-driven by which children register and what they
publish; downstream consumers should subscribe by event name, not assume
a fixed catalogue.
## Children registered by this node
pumpingStation acts as a parent for `measurement`, `machine`, `machinegroup`,
and `pumpingstation` software types. Position labels accepted from
children are `upstream`, `downstream`, `atequipment` (and the synonyms
`in` / `out` for predicted-flow children). Child-registration plumbing is
documented in `MODULE_SPLIT.md`; this node does not receive children
through Port 0 input — registration arrives on Port 2 from the child via
the standard `childRegistrationUtils` handshake.

View File

@@ -5,5 +5,6 @@ Wet-well basin model and pump orchestration node for EVOLV.
The detailed documentation lives in [`wiki/`](wiki/):
- [`wiki/functional-description.md`](wiki/functional-description.md) defines the shared basin model, pipe reference semantics, safety points, net-flow selection, and child registration behaviour.
- [`wiki/modes/`](wiki/modes/) documents control-mode-specific behaviour such as the level-linear `startLevel` demand ramp.
- [`wiki/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.

340
examples/01-Basic.json Normal file
View File

@@ -0,0 +1,340 @@
[
{
"id": "ps_basic_tab",
"type": "tab",
"label": "PumpingStation - Basic",
"disabled": false,
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
},
{
"id": "ps_basic_title",
"type": "comment",
"z": "ps_basic_tab",
"name": "PumpingStation - Basic\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nA 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\noverflow at 3.2 m). controlMode = levelbased, manual demand allowed\nonly when set.mode = manual.\n\nHOW TO USE:\n 1. Deploy the flow.\n 2. Click \"set.mode = manual\" so set.demand is honoured.\n 3. Click \"set.inflow = 60 m3/h\" to push wastewater into the basin.\n 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n 5. Click \"calibrate volume 25 m3\" to jump straight to half-full.\n\nAliases (changemode, q_in, Qd, …) still work but log a deprecation\nwarning - fresh flows use the canonical names.",
"info": "",
"x": 600,
"y": 40,
"wires": []
},
{
"id": "ps_basic_inj_mode",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.mode = manual",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "manual",
"vt": "str"
}
],
"topic": "set.mode",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 200,
"y": 160,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_mode_lvl",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.mode = levelbased",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "levelbased",
"vt": "str"
}
],
"topic": "set.mode",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 200,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_inflow",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.inflow = 60 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "60",
"vt": "num"
}
],
"topic": "set.inflow",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 200,
"y": 260,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_demand",
"type": "inject",
"z": "ps_basic_tab",
"name": "set.demand = 40 %",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "40",
"vt": "num"
}
],
"topic": "set.demand",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 200,
"y": 300,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_calvol",
"type": "inject",
"z": "ps_basic_tab",
"name": "calibrate volume 25 m3",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "25",
"vt": "num"
}
],
"topic": "cmd.calibrate.volume",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 360,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_inj_callvl",
"type": "inject",
"z": "ps_basic_tab",
"name": "calibrate level 1.5 m",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "1.5",
"vt": "num"
}
],
"topic": "cmd.calibrate.level",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 400,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_node",
"type": "pumpingStation",
"z": "ps_basic_tab",
"name": "Pumping Station",
"simulator": false,
"basinVolume": 50,
"basinHeight": 3.5,
"inflowLevel": 3,
"outflowLevel": 0.2,
"overflowLevel": 3.2,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
"pipelineLength": 80,
"maxDischargeHead": 24,
"staticHead": 12,
"maxInflowRate": 200,
"temperatureReferenceDegC": 15,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"enableDryRunProtection": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 2,
"overfillThresholdPercent": 98,
"minHeightBasedOn": "outlet",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"refHeight": "NAP",
"basinBottomRef": 1,
"uuid": "example-ps-001",
"supplier": "WBD-RD",
"category": "station",
"assetType": "pumpingstation",
"model": "demo-50m3",
"unit": "m3/h",
"enableLog": true,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"controlMode": "levelbased",
"startLevel": 1.2,
"minLevel": 0.4,
"maxLevel": 2.8,
"flowSetpoint": null,
"flowDeadband": null,
"x": 1320,
"y": 300,
"wires": [
[
"ps_basic_format"
],
[
"ps_basic_dbg_influx"
],
[
"ps_basic_dbg_parent"
]
]
},
{
"id": "ps_basic_format",
"type": "function",
"z": "ps_basic_tab",
"name": "Merge deltas + format",
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1560,
"y": 280,
"wires": [
[
"ps_basic_dbg_process"
]
]
},
{
"id": "ps_basic_dbg_process",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 240,
"wires": []
},
{
"id": "ps_basic_dbg_influx",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 1: InfluxDB",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 320,
"wires": []
},
{
"id": "ps_basic_dbg_parent",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 2: Parent reg",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 380,
"wires": []
},
{
"id": "grp_ps_basic",
"type": "group",
"z": "ps_basic_tab",
"name": "Pumping Station (PC)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#0c99d9",
"fill-opacity": "0.10"
},
"nodes": [
"ps_basic_node",
"ps_basic_format"
],
"x": 1290,
"y": 230,
"w": 500,
"h": 140
}
]

View File

@@ -0,0 +1,686 @@
[
{
"id": "ps_int_proc",
"type": "tab",
"label": "Process Plant",
"disabled": false,
"info": "Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics."
},
{
"id": "ps_int_setup",
"type": "tab",
"label": "Setup",
"disabled": false,
"info": "Deploy-time once-true injects that initialise control modes on the EVOLV nodes."
},
{
"id": "ps_int_title",
"type": "comment",
"z": "ps_int_proc",
"name": "PumpingStation - Integration\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nL0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\nPumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\nCross-tab channels: setup:* drive once-true initialisation from the Setup tab.",
"info": "",
"x": 600,
"y": 40,
"wires": []
},
{
"id": "lin_setup_mode",
"type": "link in",
"z": "ps_int_proc",
"name": "setup:to-ps-mode",
"links": [],
"x": 120,
"y": 500,
"wires": [
[
"ps_int_station"
]
]
},
{
"id": "lin_setup_inflow",
"type": "link in",
"z": "ps_int_proc",
"name": "setup:to-ps-inflow",
"links": [],
"x": 120,
"y": 560,
"wires": [
[
"ps_int_station"
]
]
},
{
"id": "lin_setup_mgcmode",
"type": "link in",
"z": "ps_int_proc",
"name": "setup:to-mgc-mode",
"links": [],
"x": 120,
"y": 360,
"wires": [
[
"ps_int_mgc"
]
]
},
{
"id": "meas_level",
"type": "measurement",
"z": "ps_int_proc",
"name": "Basin level sensor",
"mode": "analog",
"channels": "[]",
"scaling": false,
"i_min": 0,
"i_max": 0,
"i_offset": 0,
"o_min": 0,
"o_max": 1,
"simulator": true,
"smooth_method": "mean",
"count": 5,
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"uuid": "example-level-001",
"supplier": "vega",
"category": "sensor",
"assetType": "level",
"model": "VEGAPULS-31",
"unit": "m",
"assetTagNumber": "LT-001",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 600,
"y": 700,
"wires": [
[
"ps_int_dbg_level"
],
[],
[
"ps_int_station"
]
]
},
{
"id": "ps_int_inj_level",
"type": "inject",
"z": "ps_int_proc",
"name": "sim level 1.6 m",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "1.6",
"vt": "num"
}
],
"topic": "measurement",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 120,
"y": 700,
"wires": [
[
"meas_level"
]
]
},
{
"id": "pump_a",
"type": "rotatingMachine",
"z": "ps_int_proc",
"name": "Pump A",
"speed": "1",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "example-pump-a",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 840,
"y": 320,
"wires": [
[
"ps_int_dbg_pa"
],
[],
[
"ps_int_mgc"
]
]
},
{
"id": "pump_b",
"type": "rotatingMachine",
"z": "ps_int_proc",
"name": "Pump B",
"speed": "1",
"startup": "2",
"warmup": "1",
"shutdown": "2",
"cooldown": "1",
"movementMode": "staticspeed",
"machineCurve": "",
"uuid": "example-pump-b",
"supplier": "hidrostal",
"category": "pump",
"assetType": "pump-centrifugal",
"model": "hidrostal-H05K-S03R",
"unit": "m3/h",
"curvePressureUnit": "mbar",
"curveFlowUnit": "m3/h",
"curvePowerUnit": "kW",
"curveControlUnit": "%",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"x": 840,
"y": 400,
"wires": [
[
"ps_int_dbg_pb"
],
[],
[
"ps_int_mgc"
]
]
},
{
"id": "ps_int_mgc",
"type": "machineGroupControl",
"z": "ps_int_proc",
"name": "Pump Group",
"enableLog": true,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"x": 1080,
"y": 360,
"wires": [
[
"ps_int_dbg_mgc"
],
[],
[
"ps_int_station"
]
]
},
{
"id": "ps_int_station",
"type": "pumpingStation",
"z": "ps_int_proc",
"name": "Pumping Station",
"simulator": false,
"basinVolume": 50,
"basinHeight": 3.5,
"inflowLevel": 3,
"outflowLevel": 0.2,
"overflowLevel": 3.2,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
"pipelineLength": 80,
"maxDischargeHead": 24,
"staticHead": 12,
"maxInflowRate": 200,
"temperatureReferenceDegC": 15,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"enableDryRunProtection": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 2,
"overfillThresholdPercent": 98,
"minHeightBasedOn": "outlet",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"refHeight": "NAP",
"basinBottomRef": 1,
"uuid": "example-ps-001",
"supplier": "WBD-RD",
"category": "station",
"assetType": "pumpingstation",
"model": "demo-50m3",
"unit": "m3/h",
"enableLog": true,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"controlMode": "levelbased",
"startLevel": 1.2,
"minLevel": 0.4,
"maxLevel": 2.8,
"flowSetpoint": null,
"flowDeadband": null,
"x": 1320,
"y": 520,
"wires": [
[
"ps_int_format"
],
[
"ps_int_dbg_influx"
],
[]
]
},
{
"id": "ps_int_format",
"type": "function",
"z": "ps_int_proc",
"name": "Merge deltas + format",
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\nfunction pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\nconst vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n childCount: cache.childCount != null ? cache.childCount : 'n/a'\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1560,
"y": 520,
"wires": [
[
"ps_int_dbg_process"
]
]
},
{
"id": "ps_int_dbg_process",
"type": "debug",
"z": "ps_int_proc",
"name": "PS Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 480,
"wires": []
},
{
"id": "ps_int_dbg_influx",
"type": "debug",
"z": "ps_int_proc",
"name": "PS Port 1: InfluxDB",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 540,
"wires": []
},
{
"id": "ps_int_dbg_mgc",
"type": "debug",
"z": "ps_int_proc",
"name": "MGC Port 0",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 360,
"wires": []
},
{
"id": "ps_int_dbg_pa",
"type": "debug",
"z": "ps_int_proc",
"name": "Pump A Port 0",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 320,
"wires": []
},
{
"id": "ps_int_dbg_pb",
"type": "debug",
"z": "ps_int_proc",
"name": "Pump B Port 0",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 400,
"wires": []
},
{
"id": "ps_int_dbg_level",
"type": "debug",
"z": "ps_int_proc",
"name": "Level Port 0",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 700,
"wires": []
},
{
"id": "grp_pumpa",
"type": "group",
"z": "ps_int_proc",
"name": "Pump A (EM)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"pump_a",
"ps_int_dbg_pa"
],
"x": 815,
"y": 275,
"w": 1210,
"h": 110
},
{
"id": "grp_pumpb",
"type": "group",
"z": "ps_int_proc",
"name": "Pump B (EM)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#86bbdd",
"fill-opacity": "0.10"
},
"nodes": [
"pump_b",
"ps_int_dbg_pb"
],
"x": 815,
"y": 355,
"w": 1210,
"h": 110
},
{
"id": "grp_mgc",
"type": "group",
"z": "ps_int_proc",
"name": "Pump Group MGC (UN)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#50a8d9",
"fill-opacity": "0.10"
},
"nodes": [
"ps_int_mgc",
"ps_int_dbg_mgc",
"lin_setup_mgcmode"
],
"x": 95,
"y": 315,
"w": 1930,
"h": 110
},
{
"id": "grp_station",
"type": "group",
"z": "ps_int_proc",
"name": "Pumping Station (PC)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#0c99d9",
"fill-opacity": "0.10"
},
"nodes": [
"ps_int_station",
"ps_int_format",
"ps_int_dbg_process",
"ps_int_dbg_influx",
"lin_setup_mode",
"lin_setup_inflow"
],
"x": 95,
"y": 435,
"w": 1930,
"h": 190
},
{
"id": "grp_level",
"type": "group",
"z": "ps_int_proc",
"name": "Level Sensor (CM)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#a9daee",
"fill-opacity": "0.10"
},
"nodes": [
"meas_level",
"ps_int_inj_level",
"ps_int_dbg_level"
],
"x": 95,
"y": 655,
"w": 1930,
"h": 110
},
{
"id": "setup_title",
"type": "comment",
"z": "ps_int_setup",
"name": "Deploy-time setup\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nFires once after each deploy: pushes the canonical set.mode / set.inflow /\nset.demand topics across cross-tab channels into the Process Plant tab.",
"info": "",
"x": 600,
"y": 40,
"wires": []
},
{
"id": "setup_inj_mode",
"type": "inject",
"z": "ps_int_setup",
"name": "set.mode = levelbased",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "levelbased",
"vt": "str"
}
],
"topic": "set.mode",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "0.5",
"x": 120,
"y": 160,
"wires": [
[
"lout_setup_mode"
]
]
},
{
"id": "setup_inj_mgcmode",
"type": "inject",
"z": "ps_int_setup",
"name": "MGC set.mode = auto",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "auto",
"vt": "str"
}
],
"topic": "set.mode",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "0.5",
"x": 120,
"y": 220,
"wires": [
[
"lout_setup_mgcmode"
]
]
},
{
"id": "setup_inj_inflow",
"type": "inject",
"z": "ps_int_setup",
"name": "seed inflow 60 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "60",
"vt": "num"
}
],
"topic": "set.inflow",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "1.0",
"x": 120,
"y": 280,
"wires": [
[
"lout_setup_inflow"
]
]
},
{
"id": "lout_setup_mode",
"type": "link out",
"z": "ps_int_setup",
"name": "setup:to-ps-mode",
"mode": "link",
"links": [
"lin_setup_mode"
],
"x": 1800,
"y": 160,
"wires": []
},
{
"id": "lout_setup_mgcmode",
"type": "link out",
"z": "ps_int_setup",
"name": "setup:to-mgc-mode",
"mode": "link",
"links": [
"lin_setup_mgcmode"
],
"x": 1800,
"y": 220,
"wires": []
},
{
"id": "lout_setup_inflow",
"type": "link out",
"z": "ps_int_setup",
"name": "setup:to-ps-inflow",
"mode": "link",
"links": [
"lin_setup_inflow"
],
"x": 1800,
"y": 280,
"wires": []
},
{
"id": "grp_setup",
"type": "group",
"z": "ps_int_setup",
"name": "Deploy-time setup",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#dddddd",
"fill-opacity": "0.10"
},
"nodes": [
"setup_inj_mode",
"setup_inj_mgcmode",
"setup_inj_inflow",
"lout_setup_mode",
"lout_setup_mgcmode",
"lout_setup_inflow"
],
"x": 95,
"y": 115,
"w": 1930,
"h": 230
}
]

1325
examples/03-Dashboard.json Normal file

File diff suppressed because it is too large Load Diff

99
examples/README.md Normal file
View File

@@ -0,0 +1,99 @@
# pumpingStation - Example Flows
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
canonical topic API (`set.mode`, `set.inflow`, `set.demand`,
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
(`changemode`, `q_in`, `Qd`, `calibratePredictedVolume`,
`calibratePredictedLevel`, `registerChild`) still work but log a
one-time deprecation warning; these fresh flows use the canonical names only.
## Files
| File | Tier | Tabs | Purpose |
|---|---|---|---|
| `01-Basic.json` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
| `02-Integration.json` | 2 | Process Plant + Setup | Adds a `measurement` level child and a `machineGroupControl` parent with two `rotatingMachine` pumps. Demonstrates the Phase-2 parent/child handshake. |
| `03-Dashboard.json` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
## Prerequisites
- Node-RED with the EVOLV package installed (so the `pumpingStation`,
`measurement`, `machineGroupControl`, and `rotatingMachine` node
types are registered).
- For `03-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
## How to load
```bash
# Drop a file into a running Node-RED instance using its Admin API.
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/pumpingStation/examples/01-Basic.json \
http://localhost:1880/flows
```
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
import into their own tabs and can be deployed immediately.
## 01-Basic - what to try
1. Deploy.
2. Inject `set.mode = manual`.
3. Inject `set.inflow = 60 m3/h` - the basin starts filling. Watch the
formatted Port 0 payload in the debug sidebar.
4. Inject `set.demand = 40 %` - in manual mode this would feed any
registered children; here there are no pump children so it is logged
and shown on Port 0.
5. Inject `cmd.calibrate.volume = 25 m3` to jump the predicted-volume
integrator to half-full.
## 02-Integration - what to try
1. Deploy. The Setup tab fires `set.mode = levelbased` to the station
and `set.mode = auto` to the MGC.
2. The two pumps register with the MGC via Port 2; the MGC and the level
sensor register with the station via Port 2. Watch the registration
debug taps to confirm.
3. The level inject pushes a 1.6 m measurement so the station sees a
non-zero starting level. Setup also seeds `set.inflow = 60 m3/h`.
4. The station's `controlMode = levelbased` then drives the MGC, which
dispatches to Pump A / Pump B.
## 03-Dashboard - what to try
1. Deploy.
2. Open the dashboard at `http://localhost:1880/dashboard/page/pumping-station`.
3. Use the **Control mode** dropdown to switch between `manual`,
`levelbased`, `flowbased`, `none`.
4. In manual mode, drag the **Manual demand** slider - the demand cascades
to the MGC and on to the pumps.
5. The three charts (flow, level, volume %) plot live data; the four text
widgets show state, percControl, direction, and time-to-empty.
## Layout conventions
These flows follow the EVOLV layout rule set in
`.claude/rules/node-red-flow-layout.md`:
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
(`ui-*` widgets) / Setup (once-true injects).
- Cross-tab wiring via **named link out / link in channels**:
`setup:to-ps-mode`, `setup:to-ps-inflow`, `setup:to-mgc-mode`,
`cmd:ps-mode`, `cmd:ps-demand`, `evt:flow`, `evt:level`,
`evt:volpct`, `evt:state`, `evt:perc`, `evt:dir`, `evt:tempty`.
- **Lane positions** L0-L7 = `[120, 360, 600, 840, 1080, 1320, 1560, 1800]`,
driven by each node's S88 level (Process Cell on L5, Unit on L4,
Equipment on L3, Control Module on L2).
- **Group boxes** wrap each parent + its direct children, coloured by the
parent's S88 level.
## Regenerating
These flows are generated from `tools/build-examples.js`. Edit the
generator, never the JSON, then:
```bash
node nodes/pumpingStation/tools/build-examples.js
```
The script writes `01-Basic.json`, `02-Integration.json`, and
`03-Dashboard.json` into this directory.

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

@@ -0,0 +1,57 @@
/**
* Standalone PumpingStation demo — run with `node examples/standalone-demo.js`.
* Builds a station + one pump, calibrates predicted volume, ticks once.
* Useful for sanity-checking the orchestrator without Node-RED.
*/
const PumpingStation = require('../src/specificClass');
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
function createPumpingStationConfig(name) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
flowThreshold: 1e-4,
},
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller' },
basin: { volume: 43.75, height: 10, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 3.2 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0 },
safety: { enableDryRunProtection: false, enableOverfillProtection: false },
};
}
function createMachineConfig(name, position) {
return {
general: { name, logging: { enabled: false, logLevel: 'debug' } },
functionality: { softwareType: 'machine', positionVsParent: position },
asset: { supplier: 'Hydrostal', type: 'pump', category: 'centrifugal', model: 'hidrostal-H05K-S03R' },
};
}
function createMachineStateConfig() {
return {
general: { logging: { enabled: true, logLevel: 'debug' } },
movement: { speed: 1 },
time: { starting: 2, warmingup: 3, stopping: 2, coolingdown: 3 },
};
}
(async function demo() {
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
const pump1 = new RotatingMachine(createMachineConfig('Pump1', 'downstream'), createMachineStateConfig());
station.childRegistrationUtils.registerChild(pump1, 'machine');
setInterval(() => station.tick(), 1000);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log('Initial state:', station.state);
station.setManualInflow(300, Date.now(), 'l/s');
station.calibratePredictedVolume(3.4);
console.log('Station state:', station.state);
console.log('Station output:', station.getOutput());
})().catch((err) => {
console.error('Demo failed:', err);
});

View File

@@ -4,7 +4,10 @@
"description": "Control module",
"main": "pumpingStation.js",
"scripts": {
"test": "node pumpingStation.js"
"test": "node --test test/",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
},
"repository": {
"type": "git",

View File

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

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

View File

@@ -49,6 +49,7 @@ module.exports = {
| `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` |

View File

@@ -54,6 +54,10 @@ function evalExpectation(ex, records) {
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})` };

View File

@@ -2,30 +2,30 @@
//
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
// startLevel and maxLevel) at roughly the point where demand matches
// 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: 1200,
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 },
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 },
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableOverfillProtection: true,
overfillThresholdPercent: 98,
enableHighVolumeSafety: true,
highVolumeSafetyThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
@@ -44,7 +44,7 @@ module.exports = {
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
},
};
ps.calibratePredictedLevel(2.0); // start at the bottom of the RAMP zone
ps.calibratePredictedLevel(2.0); // start at the mode start level, below the rising ramp
},
inputs: (t, ps) => {
@@ -55,6 +55,7 @@ module.exports = {
{ 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

@@ -1,31 +1,31 @@
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
// level rises toward overflow then recedes.
// Storm surge — inflow triples briefly, pumps should increase demand as
// the level enters the rising ramp.
//
// Expectation: during the surge (t=300..600), demand reaches 100% and
// level may transiently climb above maxLevel. Overflow safety should
// fire if the surge overwhelms pump capacity; dry-run should not fire.
// 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. Overfill safety may engage.',
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 },
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 },
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableOverfillProtection: true,
overfillThresholdPercent: 95,
enableHighVolumeSafety: true,
highVolumeSafetyThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
@@ -55,6 +55,6 @@ module.exports = {
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
// Level may exceed maxLevel transiently but must stay under basinHeight
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
{ name: 'demand saturates at 100% during surge', type: 'max_demand_bounded', value: 100 },
{ name: 'demand remains bounded during surge', type: 'max_demand_bounded', value: 100 },
],
};

View File

@@ -12,18 +12,18 @@ module.exports = {
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 },
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 },
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 50,
enableOverfillProtection: false,
overfillThresholdPercent: 98,
enableHighVolumeSafety: false,
highVolumeSafetyThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},

View File

@@ -0,0 +1,99 @@
// Basin geometry for a wet-well pumping station.
//
// Models the basin as a rectangular prism (constant cross-section), so
// volume = level × surfaceArea. Owns the level↔volume conversions and the
// derived threshold volumes used by control + safety. Pure domain — no
// Node-RED, no logger, no side effects beyond construction.
class BasinGeometry {
/**
* @param {object} basinConfig - { volume, height, inflowLevel, outflowLevel, overflowLevel }
* @param {object} hydraulicsConfig - { minHeightBasedOn: 'inlet' | 'outlet' }
*/
constructor(basinConfig, hydraulicsConfig) {
const volEmptyBasin = basinConfig.volume;
const heightBasin = basinConfig.height;
const inflowLevel = basinConfig.inflowLevel;
const outflowLevel = basinConfig.outflowLevel;
const overflowLevel = basinConfig.overflowLevel;
const inletPipeDiameter = basinConfig.inletPipeDiameter;
const outletPipeDiameter = basinConfig.outletPipeDiameter;
const minHeightBasedOn = hydraulicsConfig?.minHeightBasedOn;
const surfaceArea = volEmptyBasin / heightBasin;
// maxVol ≡ volEmptyBasin under the constant cross-section assumption;
// kept as a separate field for naming symmetry with the trigger volumes.
const maxVol = heightBasin * surfaceArea;
const maxVolAtOverflow = overflowLevel * surfaceArea;
const minVolAtOutflow = outflowLevel * surfaceArea;
const minVolAtInflow = inflowLevel * surfaceArea;
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
this._volEmptyBasin = volEmptyBasin;
this._heightBasin = heightBasin;
this._inflowLevel = inflowLevel;
this._outflowLevel = outflowLevel;
this._overflowLevel = overflowLevel;
this._inletPipeDiameter = inletPipeDiameter;
this._outletPipeDiameter = outletPipeDiameter;
this._surfaceArea = surfaceArea;
this._maxVol = maxVol;
this._maxVolAtOverflow = maxVolAtOverflow;
this._minVolAtInflow = minVolAtInflow;
this._minVolAtOutflow = minVolAtOutflow;
this._minVol = minVol;
this._minHeightBasedOn = minHeightBasedOn;
}
get volEmptyBasin() { return this._volEmptyBasin; }
get heightBasin() { return this._heightBasin; }
get inflowLevel() { return this._inflowLevel; }
get outflowLevel() { return this._outflowLevel; }
get overflowLevel() { return this._overflowLevel; }
get inletPipeDiameter() { return this._inletPipeDiameter; }
get outletPipeDiameter() { return this._outletPipeDiameter; }
get surfaceArea() { return this._surfaceArea; }
get maxVol() { return this._maxVol; }
get maxVolAtOverflow() { return this._maxVolAtOverflow; }
get minVolAtInflow() { return this._minVolAtInflow; }
get minVolAtOutflow() { return this._minVolAtOutflow; }
get minVol() { return this._minVol; }
get minHeightBasedOn() { return this._minHeightBasedOn; }
/** Convert level (m from floor) → volume (m3). Negative levels clamp to 0. */
volumeFromLevel(level) {
return Math.max(level, 0) * this._surfaceArea;
}
/** Convert volume (m3) → level (m from floor). Negative volumes clamp to 0. */
levelFromVolume(volume) {
return Math.max(volume, 0) / this._surfaceArea;
}
/**
* Plain-object snapshot mirroring the legacy `this.basin` shape so
* getOutput / status code can keep using the same field names without
* caring whether it's holding a class instance or a plain object.
*/
snapshot() {
return {
volEmptyBasin: this._volEmptyBasin,
heightBasin: this._heightBasin,
inflowLevel: this._inflowLevel,
outflowLevel: this._outflowLevel,
overflowLevel: this._overflowLevel,
inletPipeDiameter: this._inletPipeDiameter,
outletPipeDiameter: this._outletPipeDiameter,
surfaceArea: this._surfaceArea,
maxVol: this._maxVol,
maxVolAtOverflow: this._maxVolAtOverflow,
minVolAtInflow: this._minVolAtInflow,
minVolAtOutflow: this._minVolAtOutflow,
minVol: this._minVol,
minHeightBasedOn: this._minHeightBasedOn,
};
}
}
module.exports = BasinGeometry;

View File

@@ -0,0 +1,94 @@
// Threshold-ordering validator for the pumpingStation basin + control +
// safety config. Pure: returns the issues array, never logs or throws.
// The caller decides what to do (warn, surface to status badge, fail tests).
//
// Invariants enforced (level-space, bottom → top):
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
//
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
// The validator recomputes them so a config that places minLevel below the
// effective dry-run trigger (a no-op control band) is caught here.
/**
* Derived safety thresholds + reference levels. Exposed so the editor /
* status badge / FlowAggregator can read the same values without
* recomputing them.
*/
function computeSafetyPoints(basin, safety = {}) {
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const rawHighPct = safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent;
// When neither high-volume nor overfill pct is supplied, use 100 % so
// the validator's `maxLevel <= overfillLevel` check is a no-op (the
// basin can't physically exceed overflow anyway). Tests pin this.
const highPct = Number(rawHighPct);
const effectiveHighPct = Number.isFinite(highPct) ? highPct : 100;
const minVol = Number(basin?.minVol) || 0;
const maxVolAtOverflow = Number(basin?.maxVolAtOverflow) || 0;
const dryRunSafetyVol = minVol * (1 + dryRunPct / 100);
const highVolumeSafetyVol = maxVolAtOverflow * (effectiveHighPct / 100);
const refLowLevel = basin?.minHeightBasedOn === 'inlet'
? Number(basin?.inflowLevel)
: Number(basin?.outflowLevel);
const dryRunLevel = Number.isFinite(refLowLevel)
? refLowLevel * (1 + dryRunPct / 100)
: Number.NaN;
const overflowLevel = Number(basin?.overflowLevel) || 0;
const highVolumeSafetyLevel = overflowLevel * (effectiveHighPct / 100);
return {
dryRunSafetyVol,
dryRunLevel,
highVolumeSafetyVol,
highVolumeSafetyLevel,
// Back-compat alias — pre-basin-docs name.
overfillLevel: highVolumeSafetyLevel,
overfillVol: highVolumeSafetyVol,
};
}
/**
* @param {object} basin - BasinGeometry instance OR plain {inflowLevel, outflowLevel, overflowLevel, heightBasin, minHeightBasedOn}
* @param {object} levelbased - config.control.levelbased ({ minLevel, startLevel, maxLevel })
* @param {object} safety - config.safety ({ dryRunThresholdPercent, highVolumeSafetyThresholdPercent | overfillThresholdPercent })
* @returns {Array<{aName, a, op, bName, b, msg}>}
*/
function validateThresholdOrdering(basin, levelbased, safety) {
const lvl = levelbased || {};
const points = computeSafetyPoints(basin, safety);
const { dryRunLevel, overfillLevel } = points;
// basin-docs added `startLevel <= inflowLevel` and `inflowLevel <
// maxLevel`; HEAD had only the `startLevel < maxLevel` and
// `maxLevel <= overfillLevel` checks. We keep the `overfillLevel`
// name (rather than basin-docs's `highVolumeSafetyLevel`) for
// back-compat with consumers reading issue.bName.
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
];
const issues = [];
for (const [aName, a, op, bName, b] of checks) {
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
const ok = op === '<' ? a < b : a <= b;
if (!ok) {
issues.push({
aName,
a,
op,
bName,
b,
msg: `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`,
});
}
}
return issues;
}
module.exports = { validateThresholdOrdering, computeSafetyPoints };

106
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,106 @@
'use strict';
// Handler functions for pumpingStation commands. Each handler receives:
// source: the domain (specificClass) instance — has the public methods
// (changeMode, calibratePredicted*, setManualInflow, ...).
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Handlers are pure functions: they don't keep state. Validation that goes
// beyond the registry's typeof-check ladder lives here.
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
exports.setMode = (source, msg) => {
source.changeMode(msg.payload);
};
exports.registerChild = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};
exports.calibrateVolume = (source, msg, ctx) => {
const log = _logger(source, ctx);
const v = parseFloat(msg.payload);
if (!Number.isFinite(v)) {
log?.warn?.(`cmd.calibrate.volume: non-numeric payload '${msg.payload}'`);
return;
}
source.calibratePredictedVolume(v);
};
exports.calibrateLevel = (source, msg, ctx) => {
const log = _logger(source, ctx);
const v = parseFloat(msg.payload);
if (!Number.isFinite(v)) {
log?.warn?.(`cmd.calibrate.level: non-numeric payload '${msg.payload}'`);
return;
}
source.calibratePredictedLevel(v);
};
exports.setInflow = (source, msg) => {
// Payload is either a number (legacy q_in shape) or
// { value, unit, timestamp } (richer object form).
const p = msg.payload;
let value;
let unit;
let timestamp;
if (p !== null && typeof p === 'object') {
value = Number(p.value);
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
}
source.setManualInflow(value, timestamp, unit);
};
exports.setOutflow = (source, msg) => {
// Manual q_out — basin-docs dashboard injects a drain rate without
// wiring a real pump. Same payload shape as q_in.
const p = msg.payload;
let value;
let unit;
let timestamp;
if (p !== null && typeof p === 'object') {
value = Number(p.value);
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
}
source.setManualOutflow(value, timestamp, unit);
};
exports.setDemand = (source, msg, ctx) => {
const log = _logger(source, ctx);
const demand = Number(msg.payload);
if (!Number.isFinite(demand)) {
log?.warn?.(`set.demand: invalid Qd value '${msg.payload}'`);
return;
}
if (source.mode !== 'manual') {
log?.debug?.(
`set.demand ignored in '${source.mode}' mode; switch to manual to use the demand slider`
);
return;
}
// forwardDemandToChildren returns a promise — surface failures via logger.
Promise.resolve(source.forwardDemandToChildren(demand)).catch((err) => {
log?.error?.(`set.demand: failed to forward demand: ${err && err.message}`);
});
};

56
src/commands/index.js Normal file
View File

@@ -0,0 +1,56 @@
'use strict';
// pumpingStation command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.mode',
aliases: ['changemode'],
payloadSchema: { type: 'string' },
handler: handlers.setMode,
},
{
topic: 'child.register',
aliases: ['registerChild'],
// payload is the Node-RED id (string) of the child node.
payloadSchema: { type: 'string' },
handler: handlers.registerChild,
},
{
topic: 'cmd.calibrate.volume',
aliases: ['calibratePredictedVolume'],
// any: payload may be a number or numeric string.
payloadSchema: { type: 'any' },
handler: handlers.calibrateVolume,
},
{
topic: 'cmd.calibrate.level',
aliases: ['calibratePredictedLevel'],
payloadSchema: { type: 'any' },
handler: handlers.calibrateLevel,
},
{
topic: 'set.inflow',
aliases: ['q_in'],
// any: number, numeric string, or { value, unit, timestamp } object.
payloadSchema: { type: 'any' },
handler: handlers.setInflow,
},
{
topic: 'set.outflow',
aliases: ['q_out'],
payloadSchema: { type: 'any' },
handler: handlers.setOutflow,
},
{
topic: 'set.demand',
aliases: ['Qd'],
payloadSchema: { type: 'any' },
handler: handlers.setDemand,
},
];

11
src/control/flowBased.js Normal file
View File

@@ -0,0 +1,11 @@
// Placeholder — flow-based control mode is not yet implemented.
// The dispatcher routes here when config.control.mode === 'flowbased',
// at which point a real implementation should land in this file.
async function run(ctx) {
ctx?.logger?.debug?.('flow-based mode not yet implemented');
}
module.exports = {
name: 'flowbased',
run,
};

20
src/control/index.js Normal file
View File

@@ -0,0 +1,20 @@
const levelBased = require('./levelBased');
const flowBased = require('./flowBased');
const manual = require('./manual');
const strategies = {
[levelBased.name]: levelBased,
[flowBased.name]: flowBased,
[manual.name]: manual,
};
function dispatch(mode, ctx, controlState, direction) {
const s = strategies[mode];
if (!s) {
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
return Promise.resolve();
}
return s.run(ctx, controlState, direction);
}
module.exports = { strategies, dispatch, manual };

225
src/control/levelBased.js Normal file
View File

@@ -0,0 +1,225 @@
// Level-based control strategy.
//
// Ported from basin-docs `_controlLevelBased` into the refactored
// strategy module. Concerns kept here:
// 1. minLevel hard-stop (unconditional MGC shutdown).
// 2. stopLevel Schmitt-trigger hysteresis — pumps stay engaged
// through the dead band [stopLevel, startLevel] emitting a small
// keep-alive demand so MGC keeps a single pump draining the basin.
// 3. Up-curve mapping — level mapped to demand 0..100 % across
// [inflowLevel, maxLevel] using linear or log shape.
// 4. Shifted-ramp hysteresis — when the up-curve crosses
// shiftArmPercent the strategy ARMS; on the next filling→draining
// flip it captures the up-curve value as `hold`; while draining
// the output stays at `hold` until level falls to shiftLevel, then
// ramps `hold → 0 %` over [shiftLevel, startLevel]. Disarms when
// level reaches startLevel.
//
// Hysteresis flags live on the host (specificClass instance) — the
// strategy reads/writes via ctx.host so the same flags survive across
// ticks regardless of how often the context view is rebuilt.
// Apply the configured curve shape to a normalized x in [0, 1].
// Linear by default; log when curveType is 'log'.
function _curveShape(x, levelbased) {
const { curveType = 'linear', logCurveFactor = 9 } = levelbased || {};
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor) : 9;
return Math.log1p(factor * clamped) / Math.log1p(factor);
}
return clamped;
}
// Map level to demand % across [rampFoot, rampTop]. Returns 0 below the
// foot, 100 above the top. Curve type controlled by levelbased.curveType.
function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
if (!Number.isFinite(level) || !Number.isFinite(rampFoot) || !Number.isFinite(rampTop)) return 0;
if (rampTop <= rampFoot) return level >= rampTop ? 100 : 0;
if (level <= rampFoot) return 0;
if (level >= rampTop) return 100;
const x = (level - rampFoot) / (rampTop - rampFoot);
return 100 * _curveShape(x, levelbased);
}
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
await Promise.all(
Object.values(machineGroups).map((group) =>
group.handleInput('parent', percentControl).catch((err) => {
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
})
)
);
}
async function _applyMachineLevelControl(machines, percentControl, logger) {
const filtered = Object.values(machines).filter((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
return (pos === 'downstream' || pos === 'atequipment');
});
if (!filtered.length) return;
const perMachine = percentControl / filtered.length;
for (const machine of filtered) {
try {
await machine.handleInput('parent', 'execSequence', 'startup');
await machine.handleInput('parent', 'execMovement', perMachine);
} catch (err) {
logger?.error?.(`Failed to start machine "${machine.config?.general?.name}": ${err.message}`);
}
}
}
function _pickVariant(measurements, type, variants, position, unit) {
for (const variant of variants) {
const val = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
if (!Number.isFinite(val)) continue;
return val;
}
return null;
}
async function run(ctx, controlState, direction) {
const { measurements, config, logger, machineGroups, basin, levelVariants, host } = ctx;
const cfg = config.control.levelbased || {};
const { startLevel, minLevel, maxLevel } = cfg;
const levelUnit = measurements.getUnit('level');
const variants = levelVariants || ['measured', 'predicted'];
const level = _pickVariant(measurements, 'level', variants, 'atequipment', levelUnit);
if (level == null) {
logger?.warn?.('No valid level found');
return;
}
// 1. minLevel hard-stop — unconditional MGC shutdown.
if (level < minLevel) {
controlState.percControl = 0;
if (host) {
host._shiftHoldValue = null;
host._shiftArmed = false;
host._stopHystRunning = false;
host._lastDirection = direction;
}
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
return;
}
// 2. stopLevel hysteresis (Schmitt trigger).
// Requires an explicit positive stopLevel — configManager merges null
// defaults to 0 otherwise, which would activate the hysteresis on every
// config that omitted it.
const stopLvl = Number(cfg.stopLevel);
const stopThresholdActive = cfg.stopLevel != null && Number.isFinite(stopLvl)
&& stopLvl > 0 && stopLvl < maxLevel;
if (stopThresholdActive && level <= stopLvl) {
controlState.percControl = 0;
if (host) {
host._stopHystRunning = false;
host._lastDirection = direction;
}
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
return;
}
if (host) {
if (stopThresholdActive) {
if (!host._stopHystRunning && level >= startLevel) host._stopHystRunning = true;
} else {
host._stopHystRunning = level >= startLevel;
}
}
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
// 4. Shifted-ramp arming.
if (host) {
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
if (!host._shiftArmed && upPct >= armPct) {
host._shiftArmed = true;
logger?.debug?.(`Shift armed: upPct=${upPct} >= ${armPct}`);
}
} else {
host._shiftArmed = false;
}
if (level <= startLevel) {
host._shiftArmed = false;
host._shiftHoldValue = null;
}
// Capture hold on filling→draining transition while armed.
if (cfg.enableShiftedRamp && host._shiftArmed) {
if (host._lastDirection !== 'draining' && direction === 'draining') {
host._shiftHoldValue = upPct;
logger?.debug?.(`Shift hold captured: ${upPct} % at level=${level}`);
} else if (direction === 'filling') {
// Returning to filling clears any captured hold; the next drain
// transition will recapture from the up curve.
host._shiftHoldValue = null;
}
}
if (direction === 'filling' || direction === 'draining') {
host._lastDirection = direction;
}
}
// Compute output.
const shiftArmed = !!host?._shiftArmed;
const shiftHold = host?._shiftHoldValue;
const inDrainingHold = cfg.enableShiftedRamp && shiftArmed
&& direction === 'draining' && shiftHold != null;
let percControl;
if (!inDrainingHold) {
if (level < rampFoot) {
// While engaged via stopLevel hysteresis AND inside the dead band
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
// single pump running.
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
? Number(cfg.deadZoneKeepAlivePercent) : 1;
percControl = Math.max(0, keepAlive);
} else {
percControl = 0;
}
} else {
percControl = Math.max(0, upPct);
}
} else {
const hold = shiftHold;
const shift = cfg.shiftLevel;
if (!Number.isFinite(shift) || shift <= startLevel) {
// Bad config — fall back to up curve.
percControl = Math.max(0, upPct);
} else if (level >= shift) {
percControl = hold;
} else if (level > startLevel) {
// Ramp [shift, hold] → [start, 0] using the same curve shape.
const x = (level - startLevel) / (shift - startLevel);
percControl = Math.max(0, hold * _curveShape(x, cfg));
} else {
percControl = 0;
}
}
controlState.percControl = percControl;
logger?.debug?.(
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
);
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
}
module.exports = {
name: 'levelbased',
run,
_scaleLevelToFlowPercent,
_curveShape,
_applyMachineGroupLevelControl,
_applyMachineLevelControl,
};

36
src/control/manual.js Normal file
View File

@@ -0,0 +1,36 @@
async function run() {
// No-op: manual mode is event-driven via set.demand → forwardDemand,
// not tick-driven.
}
async function forwardDemand(ctx, demand) {
const { machineGroups, machines, logger } = ctx;
logger?.info?.(`Manual demand forwarded: ${demand}`);
if (machineGroups && Object.keys(machineGroups).length > 0) {
await Promise.all(
Object.values(machineGroups).map((group) =>
group.handleInput('parent', demand).catch((err) => {
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
})
)
);
}
if (machines && Object.keys(machines).length > 0) {
const perMachine = demand / Object.keys(machines).length;
for (const machine of Object.values(machines)) {
try {
await machine.handleInput('parent', 'execMovement', perMachine);
} catch (err) {
logger?.error?.(`Failed to forward demand to machine: ${err.message}`);
}
}
}
}
module.exports = {
name: 'manual',
run,
forwardDemand,
};

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

@@ -0,0 +1,91 @@
// Calibration helpers for the pumping-station predicted volume / level
// streams. Pure functions over a context bag holding the live
// MeasurementContainer + basin geometry. After every calibration the
// integrator state is reset so the next tick starts from the new anchor.
function _resetFlowState(ctx, timestamp) {
if (ctx.flowAggregator?.resetState) {
ctx.flowAggregator.resetState(timestamp);
return;
}
ctx._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
}
function _clearSeries(measurements, type) {
const series = measurements.type(type).variant('predicted').position('atequipment');
if (series.exists()) {
const m = series.get();
if (m) {
m.values = [];
m.timestamps = [];
}
}
}
function _levelFromVolume(basin, volume) {
const area = basin.surfaceArea;
return area > 0 ? Math.max(volume, 0) / area : 0;
}
function _volumeFromLevel(basin, level) {
const area = basin.surfaceArea;
return area > 0 ? Math.max(level, 0) * area : 0;
}
function calibratePredictedVolume(ctx, calibratedVol, timestamp = Date.now()) {
if (!ctx?.measurements || !ctx.basin) {
throw new Error('calibratePredictedVolume: ctx.measurements and ctx.basin required');
}
const { measurements, basin } = ctx;
_clearSeries(measurements, 'volume');
_clearSeries(measurements, 'level');
measurements.type('volume').variant('predicted').position('atequipment')
.value(calibratedVol, timestamp, 'm3').unit('m3');
measurements.type('level').variant('predicted').position('atequipment')
.value(_levelFromVolume(basin, calibratedVol), timestamp, 'm');
_resetFlowState(ctx, timestamp);
}
function calibratePredictedLevel(ctx, level, timestamp = Date.now(), unit = 'm') {
if (!ctx?.measurements || !ctx.basin) {
throw new Error('calibratePredictedLevel: ctx.measurements and ctx.basin required');
}
const { measurements, basin } = ctx;
_clearSeries(measurements, 'volume');
_clearSeries(measurements, 'level');
measurements.type('level').variant('predicted').position('atequipment')
.value(level, timestamp, unit);
measurements.type('volume').variant('predicted').position('atequipment')
.value(_volumeFromLevel(basin, level), timestamp, 'm3');
_resetFlowState(ctx, timestamp);
}
function setManualInflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
if (!ctx?.measurements) throw new Error('setManualInflow: ctx.measurements required');
const num = Number(value);
ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin')
.value(num, timestamp, unit);
}
// Manual outflow injection mirroring setManualInflow — basin-docs adds this
// for the dashboard's q_out topic so tests can drive a drain stroke without
// instantiating a real pump.
function setManualOutflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
if (!ctx?.measurements) throw new Error('setManualOutflow: ctx.measurements required');
const num = Number(value);
ctx.measurements.type('flow').variant('predicted').position('out').child('manual-qout')
.value(num, timestamp, unit);
}
module.exports = {
calibratePredictedVolume,
calibratePredictedLevel,
setManualInflow,
setManualOutflow,
};

View File

@@ -0,0 +1,265 @@
// FlowAggregator — owns the predicted-volume integrator + net-flow selection
// + remaining-time projection for the pumping-station basin.
//
// Pure domain. Takes a context bag with the live MeasurementContainer, the
// basin geometry, and the merged config; mutates measurements in place and
// keeps a tiny piece of integrator state internally.
//
// Ports from basin-docs:
// - Predicted-volume integrator clamped to [dryRunSafetyVol, maxVolAtOverflow]
// with hard physical floor at 0 (predicted volume can never go negative).
// - Synthetic spill flow at position 'overflow' so net-flow balance
// reads ~0 while pinned at overflow.
// - Cumulative overflowVolume + underflowVolume streams for compliance /
// diagnostic reporting via InfluxDB.
const { interpolation } = require('generalFunctions');
const DEFAULT_FLOW_THRESHOLD = 1e-4;
const DEFAULT_FLOW_VARIANTS = ['measured', 'predicted'];
const DEFAULT_LEVEL_VARIANTS = ['measured', 'predicted'];
const DEFAULT_FLOW_POSITIONS = {
inflow: ['in', 'upstream'],
outflow: ['out', 'downstream'],
};
class FlowAggregator {
constructor(ctx = {}) {
if (!ctx.measurements) throw new Error('FlowAggregator: ctx.measurements is required');
if (!ctx.basin) throw new Error('FlowAggregator: ctx.basin is required');
this.measurements = ctx.measurements;
this.basin = ctx.basin;
this.config = ctx.config || {};
this.logger = ctx.logger || null;
this._interp = ctx.interpolation || new interpolation();
this.flowVariants = ctx.flowVariants || DEFAULT_FLOW_VARIANTS;
this.levelVariants = ctx.levelVariants || DEFAULT_LEVEL_VARIANTS;
this.flowPositions = ctx.flowPositions || DEFAULT_FLOW_POSITIONS;
const cfgThresh = Number(this.config?.general?.flowThreshold);
this.flowThreshold = Number.isFinite(ctx.flowThreshold)
? ctx.flowThreshold
: (Number.isFinite(cfgThresh) ? cfgThresh : DEFAULT_FLOW_THRESHOLD);
// Optional callback so the host can supply derived safety thresholds
// without us re-importing the validator. Returns { dryRunSafetyVol, ... }.
this._computeSafetyPoints = ctx.computeSafetyPoints || (() => ({ dryRunSafetyVol: 0 }));
this._predictedFlowState = null;
this._lastNetFlow = { value: 0, source: null, direction: 'steady' };
this._lastRemaining = { seconds: null, source: null };
this._lastLevelRateNetFlow = null;
}
resetState(timestamp = Date.now()) {
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
}
update() {
const flowUnit = 'm3/s';
const now = Date.now();
// Synthetic spill flow lives at its OWN position ('overflow') —
// not as a child of 'out'. That keeps it out of the operational
// outflow sum here so no self-subtraction is needed.
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
const tPrev = this._predictedFlowState.lastTimestamp ?? now;
const dt = Math.max((now - tPrev) / 1000, 0);
const dV = dt > 0 ? (inflow - outflowReal) * dt : 0;
const currentVol = this.measurements
.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? this.basin.minVol ?? 0;
const writeTs = tPrev + dt * 1000;
// Bounds.
// Upper (hard physical): maxVolAtOverflow — past this the basin
// spills; predicted level pins at overflowLevel and the excess
// becomes cumulative overflowVolume + synthetic spill flow.
// Lower (operational): dryRunSafetyVol — clamps ON TRANSITION
// from above so the integrator can't drop into the unphysical
// band. A basin seeded BELOW it is left alone (startup from empty).
// Lower (hard physical): 0 — basin cannot hold negative water.
// Any negative excess is tracked as underflowVolume (diagnostic).
const safety = this._computeSafetyPoints();
const upperClamp = this.basin.maxVolAtOverflow;
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
const proposedVolume = currentVol + dV;
let nextVolume = proposedVolume;
let overflowIncrement = 0;
let underflowIncrement = 0;
if (proposedVolume > upperClamp) {
overflowIncrement = proposedVolume - upperClamp;
nextVolume = upperClamp;
} else if (proposedVolume < lowerClamp && currentVol >= lowerClamp) {
nextVolume = lowerClamp;
}
if (nextVolume < 0) {
underflowIncrement = -nextVolume;
nextVolume = 0;
}
// Synthetic spill flow at position 'overflow'.
let spillRate = 0;
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
spillRate = inflow - outflowReal;
}
this.measurements
.type('flow').variant('predicted').position('overflow')
.value(spillRate, writeTs, 'm3/s').unit('m3/s');
if (overflowIncrement > 0) {
const prev = this.measurements
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
this.measurements
.type('overflowVolume').variant('predicted').position('atequipment')
.value(prev + overflowIncrement, writeTs, 'm3').unit('m3');
}
if (underflowIncrement > 0) {
const prev = this.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
this.measurements
.type('underflowVolume').variant('predicted').position('atequipment')
.value(prev + underflowIncrement, writeTs, 'm3').unit('m3');
}
this.measurements.type('volume').variant('predicted').position('atequipment')
.value(nextVolume, writeTs, 'm3').unit('m3');
const surfaceArea = this.basin.surfaceArea;
const nextLevel = surfaceArea > 0 ? Math.max(nextVolume, 0) / surfaceArea : 0;
this.measurements.type('level').variant('predicted').position('atequipment')
.value(nextLevel, writeTs, 'm').unit('m');
const percent = this._interp.interpolate_lin_single_point(
nextVolume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
);
this.measurements.type('volumePercent').variant('predicted').position('atequipment')
.value(percent, writeTs, '%');
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTs };
}
selectBestNetFlow() {
const type = 'flow';
const unit = this.measurements.getUnit(type) || 'm3/s';
for (const variant of this.flowVariants) {
const bucket = this.measurements.measurements?.[type]?.[variant];
if (!bucket || Object.keys(bucket).length === 0) continue;
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
const outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
// Fold synthetic spill (position 'overflow') into the outflow side
// so net-flow balance reads ~0 while pinned at the overflow level.
const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0;
const outflow = outflowReal + spill;
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
const net = inflow - outflow;
this.measurements.type('netFlowRate').variant(variant).position('atequipment')
.value(net, Date.now(), unit);
const result = { value: net, source: variant, direction: this.deriveDirection(net) };
this._lastNetFlow = result;
return result;
}
for (const variant of this.levelVariants) {
const rate = this._levelRate(variant);
if (!Number.isFinite(rate)) continue;
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
const pinnedAtOverflow = Number.isFinite(lvl)
&& Number.isFinite(this.basin.overflowLevel)
&& lvl >= this.basin.overflowLevel - 1e-9;
const rateNearZero = Math.abs(rate) < 1e-9;
let netFlow = rate * this.basin.surfaceArea;
// Pinned at overflow — dL/dt collapses to 0 but flow IS still
// moving (in → spill). Hold the last known non-zero net-flow.
if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) {
netFlow = this._lastLevelRateNetFlow;
} else if (!rateNearZero) {
this._lastLevelRateNetFlow = netFlow;
}
const result = { value: netFlow, source: `level:${variant}`, direction: this.deriveDirection(netFlow) };
this._lastNetFlow = result;
return result;
}
if (this.logger) this.logger.warn('No usable measurements to compute net flow; assuming steady.');
const result = { value: 0, source: null, direction: 'steady' };
this._lastNetFlow = result;
return result;
}
computeRemainingTime(netFlow) {
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) {
this._lastRemaining = { seconds: null, source: null };
return this._lastRemaining;
}
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) {
this._lastRemaining = { seconds: null, source: null };
return this._lastRemaining;
}
for (const variant of this.levelVariants) {
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
if (!Number.isFinite(lvl)) continue;
const remainingHeight = netFlow.value > 0
? Math.max(overflowLevel - lvl, 0)
: Math.max(lvl - outflowLevel, 0);
const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value);
if (!Number.isFinite(seconds)) continue;
this._lastRemaining = { seconds, source: `${netFlow.source}/${variant}` };
return this._lastRemaining;
}
this._lastRemaining = { seconds: null, source: netFlow.source };
return this._lastRemaining;
}
deriveDirection(netFlow) {
if (netFlow > this.flowThreshold) return 'filling';
if (netFlow < -this.flowThreshold) return 'draining';
return 'steady';
}
tick() {
this.update();
const netFlow = this.selectBestNetFlow();
const remaining = this.computeRemainingTime(netFlow);
return { netFlow, remaining };
}
snapshot() {
return {
direction: this._lastNetFlow.direction,
netFlow: this._lastNetFlow.value,
flowSource: this._lastNetFlow.source,
secondsRemaining: this._lastRemaining.seconds,
};
}
_levelRate(variant) {
const m = this.measurements.type('level').variant(variant).position('atequipment').get();
if (!m || !m.values || m.values.length < 2) return null;
const current = m.getLaggedSample?.(0);
const previous = m.getLaggedSample?.(1);
if (!current || !previous || previous.timestamp == null) return null;
const dt = (current.timestamp - previous.timestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) return null;
return (current.value - previous.value) / dt;
}
}
module.exports = FlowAggregator;

View File

@@ -0,0 +1,82 @@
// MeasurementRouter — dispatches incoming measurement updates by type and
// derives downstream measurements (volume from level, predicted level from
// pressure). Pure domain over a context bag; no Node-RED dependency.
const { coolprop, interpolation } = require('generalFunctions');
const G = 9.80665;
const ASSUMED_TEMPERATURE_C = 15;
const ATMOSPHERIC_PRESSURE_PA = 101325;
class MeasurementRouter {
constructor(ctx = {}) {
if (!ctx.measurements) throw new Error('MeasurementRouter: ctx.measurements is required');
if (!ctx.basin) throw new Error('MeasurementRouter: ctx.basin is required');
this.measurements = ctx.measurements;
this.basin = ctx.basin;
this.logger = ctx.logger || null;
this._interp = ctx.interpolation || new interpolation();
}
route(measurementType, value, position, eventData = {}) {
switch (measurementType) {
case 'level':
this.onLevelMeasurement(position, value, eventData);
return true;
case 'pressure':
this.onPressureMeasurement(position, value, eventData);
return true;
default:
return false;
}
}
onLevelMeasurement(position, value, context = {}) {
this.measurements.type('level').variant('measured').position(position)
.value(value).unit(context.unit);
const series = this.measurements.type('level').variant('measured').position(position);
const levelMeters = series.getCurrentValue('m');
if (levelMeters == null) return;
const surfaceArea = this.basin.surfaceArea;
const volume = surfaceArea > 0 ? Math.max(levelMeters, 0) * surfaceArea : 0;
const percent = this._interp.interpolate_lin_single_point(
volume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
);
this.measurements.type('volume').variant('measured').position('atequipment')
.value(volume, context.timestamp, 'm3');
this.measurements.type('volumePercent').variant('measured').position('atequipment')
.value(percent, context.timestamp, '%');
}
onPressureMeasurement(position, value, context = {}) {
let kelvin = this.measurements
.type('temperature').variant('measured').position('atequipment')
.getCurrentValue('K') ?? null;
if (kelvin === null) {
if (this.logger) {
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
}
this.measurements.type('temperature').variant('assumed').position('atequipment')
.value(ASSUMED_TEMPERATURE_C, Date.now(), 'C');
kelvin = this.measurements.type('temperature').variant('assumed').position('atequipment')
.getCurrentValue('K');
}
if (kelvin == null) return;
const density = coolprop.PropsSI('D', 'T', kelvin, 'P', ATMOSPHERIC_PRESSURE_PA, 'Water');
const pressurePa = this.measurements.type('pressure').variant('measured').position(position)
.getCurrentValue('Pa');
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
const level = pressurePa / (density * G);
this.measurements.type('level').variant('predicted').position(position)
.value(level, context.timestamp, 'm');
}
}
module.exports = MeasurementRouter;

View File

@@ -1,261 +1,78 @@
const { BaseNodeAdapter, configManager } = require('generalFunctions');
const PumpingStation = require('./specificClass');
const commands = require('./commands');
const { outputUtils, configManager } = require('generalFunctions');
const Specific = require("./specificClass");
class nodeClass extends BaseNodeAdapter {
static DomainClass = PumpingStation;
static commands = commands;
// Tick-driven: predicted-volume integrator needs delta-time per second.
static tickInterval = 1000;
static statusInterval = 1000;
class nodeClass {
/**
* Create a node.
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
* @param {object} nodeInstance - The Node-RED node instance.
* @param {string} nameOfNode - The name of the node, used for
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
// Load default & UI config
this._loadConfig(uiConfig,this.node);
// Instantiate core class
this._setupSpecificClass();
// Wire up event and lifecycle handlers
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* Load and merge default config with user-defined settings.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig,node) {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
// Build config: base sections + pumpingStation-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
buildDomainConfig(uiConfig) {
return {
basin: {
volume: uiConfig.basinVolume,
height: uiConfig.basinHeight,
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,
maxLevel:uiConfig.maxLevel
}
stopLevel: uiConfig.stopLevel,
maxLevel: uiConfig.maxLevel,
// Editor names the field levelCurveType; runtime uses curveType.
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
}
});
// Utility for formatting outputs
this._output = new outputUtils();
}
/**
* Instantiate the core logic and store as source.
*/
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source; // Store the source in the node instance for easy access
}
/**
* Bind Node-RED status updates.
*/
_bindEvents() {
}
// init registration msg
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{ topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null},
]);
}, 100);
}
_updateNodeStatus() {
const ps = this.source;
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 };
};
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');
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(' | ')
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds,
},
output: {
process: uiConfig.processOutputFormat,
dbase: uiConfig.dbaseOutputFormat,
},
};
}
// any time based functions here
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
this._statusInterval = setInterval(() => {
const status = this._updateNodeStatus();
this.node.status(status);
}, 1000);
}, 1000);
}
/**
* Execute a single tick: update measurement, format and send outputs.
*/
_tick() {
//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.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]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
//example
case 'changemode':
this.source.changeMode(msg.payload);
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);
break;
}
case 'calibratePredictedVolume': {
const injectedVol = parseFloat(msg.payload);
this.source.calibratePredictedVolume(injectedVol);
break;
}
case 'calibratePredictedLevel': {
const injectedLevel = parseFloat(msg.payload);
this.source.calibratePredictedLevel(injectedLevel);
break;
}
case 'q_in': {
// payload can be number or { value, unit, timestamp }
const val = Number(msg.payload);
const unit = msg?.unit;
const ts = msg?.timestamp || Date.now();
this.source.setManualInflow(val, ts, unit);
break;
}
case 'Qd': {
// Manual demand: operator sets the target output via a
// dashboard slider. Only accepted when PS is in 'manual'
// mode — mirrors how rotatingMachine gates commands by
// mode (virtualControl vs auto).
const demand = Number(msg.payload);
if (!Number.isFinite(demand)) {
this.source.logger.warn(`Invalid Qd value: ${msg.payload}`);
break;
}
if (this.source.mode === 'manual') {
this.source.forwardDemandToChildren(demand).catch((err) =>
this.source.logger.error(`Failed to forward demand: ${err.message}`)
);
} else {
this.source.logger.debug(
`Qd ignored in ${this.source.mode} mode. Switch to manual to use the demand slider.`
);
}
break;
}
}
done();
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
done();
});
// Test-only entrypoint mirroring the basin-docs config-mapping surface.
// Lets `NodeClass.prototype._loadConfig.call({name:'pumpingStation'}, ui, node)`
// produce the merged config without instantiating a full Node-RED adapter.
// Production wiring goes through BaseNodeAdapter; this is a thin shim.
_loadConfig(uiConfig, node) {
const cfgMgr = new configManager();
const name = this.name || 'pumpingStation';
const domain = nodeClass.prototype.buildDomainConfig.call(this, uiConfig);
this.defaultConfig = cfgMgr.getConfig(name);
this.config = cfgMgr.buildConfig(name, uiConfig, node && node.id, domain);
return this.config;
}
}

View File

@@ -0,0 +1,156 @@
// Safety controller for the pumping-station basin.
//
// Two hard rules, applied independently every tick:
//
// 1. DRY-RUN (volume below minVol while draining): pumps must stop.
// Shuts down all DOWNSTREAM machines + machine groups + child
// stations. Sets blocked=true so the orchestrator skips control
// logic — only a manual override or estop can restart pumps.
//
// 2. OVERFILL (volume above overflow level while filling): pumps must
// keep running. Shuts down UPSTREAM equipment only (stop more water
// coming in) and child stations. Does NOT touch machine groups or
// downstream pumps — they must keep draining. blocked stays false
// so level-based control keeps demanding maximum throughput.
//
// A third path: if no volume reading is available, panic — shut down
// every machine and block control.
function pickVariant(measurements, type, variants, position, unit) {
for (const variant of variants) {
const v = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
if (Number.isFinite(v)) return v;
}
return null;
}
class SafetyController {
/**
* @param {object} ctx
* @param {object} ctx.measurements MeasurementContainer-like instance
* @param {object} ctx.basin BasinGeometry snapshot ({maxVolAtOverflow, minVol, ...})
* @param {object} ctx.config pumpingStation config (uses .safety subtree)
* @param {object} ctx.logger generalFunctions logger
* @param {object} ctx.machines map of childId → rotatingMachine
* @param {object} ctx.stations map of childId → child pumpingStation
* @param {object} ctx.machineGroups map of childId → machineGroupControl
* @param {string[]} [ctx.volVariants] order of volume variants to try
*/
constructor(ctx) {
this.ctx = ctx;
this.volVariants = ctx.volVariants || ['measured', 'predicted'];
}
/**
* Run the dry-run + overfill rules against the current measurement state.
*
* @param {object} flowSnapshot { direction: 'filling'|'draining'|'steady',
* secondsRemaining: number|null }
* @returns {{blocked:boolean, reason:string|null, triggered:string[]}}
*/
evaluate(flowSnapshot) {
const { measurements, basin, config, logger, machines } = this.ctx;
const direction = flowSnapshot?.direction ?? 'steady';
const secondsRemaining = flowSnapshot?.secondsRemaining ?? null;
const volUnit = measurements.getUnit('volume');
const vol = pickVariant(measurements, 'volume', this.volVariants, 'atequipment', volUnit);
if (vol == null) {
Object.values(machines).forEach((m) => m.handleInput('parent', 'execSequence', 'shutdown'));
logger.warn('No volume data available to safe guard system; shutting down all machines.');
return { blocked: true, reason: 'no-volume-data', triggered: ['no-volume-data'] };
}
const triggered = [];
let blocked = false;
let reason = null;
const dry = this._dryRunRule(vol, direction, secondsRemaining);
if (dry.triggered) {
this._shutdownDownstream(vol, secondsRemaining);
blocked = true;
reason = 'dry-run';
triggered.push(...dry.flags);
}
const over = this._overfillRule(vol, direction, secondsRemaining);
if (over.triggered) {
this._shutdownUpstream(vol, secondsRemaining);
// Overfill never sets blocked — control keeps running.
if (reason == null) reason = 'overfill';
triggered.push(...over.flags);
}
return { blocked, reason, triggered };
}
_safetyConfig() {
return this.ctx.config.safety || {};
}
_dryRunRule(vol, direction, secondsRemaining) {
if (direction !== 'draining') return { triggered: false, flags: [] };
const s = this._safetyConfig();
const dryRunEnabled = Boolean(s.enableDryRunProtection);
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
const triggerLowVol = this.ctx.basin.minVol * (1 + ((Number(s.dryRunThresholdPercent) || 0) / 100));
const flags = [];
if (dryRunEnabled && vol < triggerLowVol) flags.push('dry-run-volume');
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
flags.push('time-remaining');
}
return { triggered: flags.length > 0, flags };
}
_overfillRule(vol, direction, secondsRemaining) {
if (direction !== 'filling') return { triggered: false, flags: [] };
const s = this._safetyConfig();
// basin-docs renamed enableOverfillProtection → enableHighVolumeSafety;
// both work as aliases (HEAD already maps in buildDomainConfig).
const enabled = Boolean(s.enableHighVolumeSafety ?? s.enableOverfillProtection);
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
const pct = Number(s.highVolumeSafetyThresholdPercent ?? s.overfillThresholdPercent) || 0;
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * (pct / 100);
const flags = [];
if (enabled && vol > triggerHighVol) flags.push('overfill-volume');
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
flags.push('time-remaining');
}
return { triggered: flags.length > 0, flags };
}
_shutdownDownstream(vol, secondsRemaining) {
const { machines, machineGroups, stations, logger } = this.ctx;
Object.values(machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'shutdown');
}
});
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
Object.values(machineGroups).forEach((g) => g.turnOffAllMachines());
logger.warn(
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
);
}
_shutdownUpstream(vol, secondsRemaining) {
const { machines, stations, logger } = this.ctx;
Object.values(machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
if (pos === 'upstream' && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'shutdown');
}
});
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
// Machine groups intentionally NOT shut down — they must keep draining.
logger.warn(
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
);
}
}
module.exports = SafetyController;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
// Basic unit tests for BasinGeometry.
// Run with: node --test test/basic/BasinGeometry.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const BasinGeometry = require('../../src/basin/BasinGeometry');
function makeBasin(overrides = {}) {
const basin = {
volume: 50,
height: 5,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
...overrides.basin,
};
const hydraulics = {
minHeightBasedOn: 'outlet',
...overrides.hydraulics,
};
return new BasinGeometry(basin, hydraulics);
}
test('constructor produces correct surfaceArea = volume / height', () => {
const g = makeBasin();
assert.equal(g.surfaceArea, 10); // 50 / 5
assert.equal(g.heightBasin, 5);
assert.equal(g.volEmptyBasin, 50);
});
test('maxVolAtOverflow equals overflowLevel × surfaceArea', () => {
const g = makeBasin();
assert.equal(g.maxVolAtOverflow, 4.5 * 10); // 45
assert.equal(g.minVolAtInflow, 3 * 10); // 30
assert.equal(g.minVolAtOutflow, 0.2 * 10); // 2
assert.equal(g.maxVol, 50);
});
test("minVol selects outlet-based when minHeightBasedOn = 'outlet'", () => {
const g = makeBasin();
assert.equal(g.minVol, g.minVolAtOutflow);
assert.equal(g.minHeightBasedOn, 'outlet');
});
test("minVol selects inlet-based when minHeightBasedOn = 'inlet'", () => {
const g = makeBasin({ hydraulics: { minHeightBasedOn: 'inlet' } });
assert.equal(g.minVol, g.minVolAtInflow);
assert.equal(g.minHeightBasedOn, 'inlet');
});
test('volumeFromLevel(0) returns 0; negative level clamps to 0', () => {
const g = makeBasin();
assert.equal(g.volumeFromLevel(0), 0);
assert.equal(g.volumeFromLevel(-1), 0);
assert.equal(g.volumeFromLevel(-1e9), 0);
});
test('volumeFromLevel(positive) is level × surfaceArea', () => {
const g = makeBasin();
assert.equal(g.volumeFromLevel(2.5), 25);
assert.equal(g.volumeFromLevel(5), 50);
});
test('levelFromVolume(maxVol) returns heightBasin', () => {
const g = makeBasin();
assert.equal(g.levelFromVolume(g.maxVol), g.heightBasin);
});
test('levelFromVolume(0) returns 0; negative volume clamps to 0', () => {
const g = makeBasin();
assert.equal(g.levelFromVolume(0), 0);
assert.equal(g.levelFromVolume(-10), 0);
});
test('round-trip: volumeFromLevel(levelFromVolume(v)) ≈ v for v in range', () => {
const g = makeBasin();
for (const v of [0, 0.001, 1, 12.34, 25, 49.999, 50]) {
const back = g.volumeFromLevel(g.levelFromVolume(v));
assert.ok(Math.abs(back - v) < 1e-9, `round-trip failed for v=${v}, got ${back}`);
}
});
test('round-trip: levelFromVolume(volumeFromLevel(L)) ≈ L for L in range', () => {
const g = makeBasin();
for (const L of [0, 0.05, 1, 2.5, 4.5, 5]) {
const back = g.levelFromVolume(g.volumeFromLevel(L));
assert.ok(Math.abs(back - L) < 1e-9, `round-trip failed for L=${L}, got ${back}`);
}
});
test('snapshot() exposes legacy this.basin field names', () => {
const g = makeBasin();
const s = g.snapshot();
const expectedKeys = [
'volEmptyBasin', 'heightBasin', 'inflowLevel', 'outflowLevel',
'overflowLevel', 'surfaceArea', 'maxVol', 'maxVolAtOverflow',
'minVolAtInflow', 'minVolAtOutflow', 'minVol', 'minHeightBasedOn',
];
for (const k of expectedKeys) {
assert.ok(k in s, `snapshot missing key: ${k}`);
}
assert.equal(s.volEmptyBasin, 50);
assert.equal(s.surfaceArea, 10);
assert.equal(s.minHeightBasedOn, 'outlet');
});

View File

@@ -0,0 +1,106 @@
// Basic tests for the calibration helpers.
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const {
calibratePredictedVolume,
calibratePredictedLevel,
setManualInflow,
} = require('../../src/measurement/calibration');
function makeBasin() {
return {
surfaceArea: 10,
minVol: 2,
maxVol: 50,
maxVolAtOverflow: 45,
overflowLevel: 4.5,
outflowLevel: 0.2,
inflowLevel: 3,
};
}
function makeCtx(seedVolume = null) {
const measurements = new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
});
const basin = makeBasin();
if (seedVolume != null) {
measurements.type('volume').variant('predicted').position('atequipment')
.value(seedVolume, Date.now() - 5_000, 'm3').unit('m3');
}
const ctx = { measurements, basin };
return ctx;
}
test('calibratePredictedVolume clears prior series and writes new value', async () => {
const ctx = makeCtx(12);
const before = ctx.measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
assert.ok(Math.abs(before - 12) < 1e-9);
const ts = Date.now();
calibratePredictedVolume(ctx, 30, ts);
const m = ctx.measurements.type('volume').variant('predicted').position('atequipment').get();
assert.equal(m.values.length, 1, 'series should hold exactly the calibration point');
assert.ok(Math.abs(m.getCurrentValue() - 30) < 1e-9);
// Level was derived: 30 / 10 = 3 m.
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
.getCurrentValue('m');
assert.ok(Math.abs(lvl - 3) < 1e-9, `derived level was ${lvl}`);
assert.equal(ctx._predictedFlowState.lastTimestamp, ts);
assert.equal(ctx._predictedFlowState.inflow, 0);
assert.equal(ctx._predictedFlowState.outflow, 0);
});
test('calibratePredictedLevel writes both level and derived volume', async () => {
const ctx = makeCtx(2);
calibratePredictedLevel(ctx, 4.0, Date.now(), 'm');
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
.getCurrentValue('m');
assert.ok(Math.abs(lvl - 4.0) < 1e-9);
const vol = ctx.measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
assert.ok(Math.abs(vol - 40) < 1e-9, `derived volume was ${vol}`);
});
test('setManualInflow writes to flow.predicted.in.manual-qin', async () => {
const ctx = makeCtx();
const ts = Date.now();
setManualInflow(ctx, 0.025, ts, 'm3/s');
const series = ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin');
const val = series.getCurrentValue('m3/s');
assert.ok(Math.abs(val - 0.025) < 1e-9, `manual-qin value was ${val}`);
// It must NOT collide with the default child bucket.
const defaultBucket = ctx.measurements.measurements?.flow?.predicted?.in?.default;
assert.equal(defaultBucket, undefined);
});
test('calibration uses ctx.flowAggregator.resetState when present', async () => {
const ctx = makeCtx(5);
let resetCalled = null;
ctx.flowAggregator = { resetState: (ts) => { resetCalled = ts; } };
const ts = 1234567890;
calibratePredictedVolume(ctx, 20, ts);
assert.equal(resetCalled, ts);
// The plain bag should NOT be touched when the aggregator hook is present.
assert.equal(ctx._predictedFlowState, undefined);
});
test('calibratePredictedVolume rejects bad context', async () => {
assert.throws(() => calibratePredictedVolume({}, 10));
assert.throws(() => calibratePredictedLevel({}, 1.0));
assert.throws(() => setManualInflow({}, 0.01));
});

View File

@@ -0,0 +1,178 @@
// Basic tests for the pumpingStation commands registry.
// Run with: node --test test/basic/commands.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
function makeSource({ mode = 'manual' } = {}) {
const calls = {
changeMode: [],
calibratePredictedVolume: [],
calibratePredictedLevel: [],
setManualInflow: [],
forwardDemandToChildren: [],
registerChild: [],
};
const source = {
mode,
logger: makeLogger(),
changeMode: (m) => calls.changeMode.push(m),
calibratePredictedVolume: (v) => calls.calibratePredictedVolume.push(v),
calibratePredictedLevel: (v) => calls.calibratePredictedLevel.push(v),
setManualInflow: (v, ts, u) => calls.setManualInflow.push({ v, ts, u }),
forwardDemandToChildren: async (d) => { calls.forwardDemandToChildren.push(d); },
childRegistrationUtils: {
registerChild: (childSource, position) =>
calls.registerChild.push({ childSource, position }),
},
};
return { source, calls };
}
function makeCtx({ child = null, logger = makeLogger() } = {}) {
return {
logger,
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
node: {},
send: () => {},
};
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- tests -----------------------------------------------------------------
test('canonical topics dispatch to their handlers', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.mode', payload: 'levelbased' }, source, makeCtx());
assert.deepEqual(calls.changeMode, ['levelbased']);
await reg.dispatch({ topic: 'cmd.calibrate.volume', payload: '12.5' }, source, makeCtx());
assert.deepEqual(calls.calibratePredictedVolume, [12.5]);
await reg.dispatch({ topic: 'cmd.calibrate.level', payload: 1.25 }, source, makeCtx());
assert.deepEqual(calls.calibratePredictedLevel, [1.25]);
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s' }, source, makeCtx());
assert.equal(calls.setManualInflow.length, 1);
assert.equal(calls.setManualInflow[0].v, 0.5);
assert.equal(calls.setManualInflow[0].u, 'm3/s');
await reg.dispatch({ topic: 'set.demand', payload: 100 }, source, makeCtx());
assert.deepEqual(calls.forwardDemandToChildren, [100]);
});
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
const { source, calls } = makeSource();
const child = { id: 'child-1', source: { tag: 'child-domain' } };
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
source,
makeCtx({ child })
);
assert.equal(calls.registerChild.length, 1);
assert.equal(calls.registerChild[0].childSource, child.source);
assert.equal(calls.registerChild[0].position, 'upstream');
});
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
assert.deepEqual(calls.changeMode, ['manual', 'manual']);
const deprecWarns = ctxLogger.calls.warn.filter((m) => m.includes("'changemode' is deprecated"));
assert.equal(deprecWarns.length, 1, 'deprecation warning should log exactly once');
assert.equal(reg.deprecationStats().changemode, 2);
// q_in alias also routes to setInflow.
await reg.dispatch({ topic: 'q_in', payload: 0.25, unit: 'm3/s' }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.setManualInflow.length, 1);
});
test('child.register with unknown child id logs warn and does not throw', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await assert.doesNotReject(() =>
reg.dispatch(
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
source,
makeCtx({ logger: ctxLogger })
)
);
assert.equal(calls.registerChild.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
test('set.inflow accepts number payload and { value, unit, timestamp } object payload', async () => {
const { source, calls } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx());
assert.deepEqual(calls.setManualInflow[0], { v: 0.5, ts: 1000, u: 'm3/s' });
await reg.dispatch(
{ topic: 'set.inflow', payload: { value: 2, unit: 'm3/h', timestamp: 2000 } },
source,
makeCtx()
);
assert.deepEqual(calls.setManualInflow[1], { v: 2, ts: 2000, u: 'm3/h' });
});
test('set.demand in non-manual mode logs debug and does not call forwardDemandToChildren', async () => {
const { source, calls } = makeSource({ mode: 'levelbased' });
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.forwardDemandToChildren.length, 0);
assert.ok(
ctxLogger.calls.debug.some((m) => m.includes('set.demand') && m.includes('levelbased')),
`expected debug about ignoring demand, got: ${JSON.stringify(ctxLogger.calls.debug)}`
);
});
test('set.demand with non-numeric payload logs warn and does not call', async () => {
const { source, calls } = makeSource({ mode: 'manual' });
const ctxLogger = makeLogger();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
assert.equal(calls.forwardDemandToChildren.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('set.demand') && m.includes('oops')),
`expected warn about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});

View File

@@ -0,0 +1,130 @@
// Unit tests for the level-based control strategy.
// Run with: node --test test/basic/control-levelBased.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const levelBased = require('../../src/control/levelBased');
function makeMeasurements(levelMeters) {
// Minimal MeasurementContainer stand-in. The strategy only calls
// getUnit('level') and a chain ending in getCurrentValue(unit).
const chain = {
type() { return chain; },
variant() { return chain; },
position() { return chain; },
getCurrentValue() {
return Number.isFinite(levelMeters) ? levelMeters : null;
},
};
return {
getUnit: () => 'm',
type: () => chain,
};
}
function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
turnOffAllMachines: () => { calls.turnOff += 1; },
_calls: calls,
};
}
function makeCtx(levelMeters, opts = {}) {
const groups = {
a: makeGroup('A'),
b: makeGroup('B'),
c: makeGroup('C'),
};
return {
measurements: makeMeasurements(levelMeters),
config: {
control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } },
},
logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} },
machineGroups: groups,
machines: {},
levelVariants: ['measured', 'predicted'],
};
}
test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => {
const ctx = makeCtx(0.5);
const state = { percControl: 42 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
}
});
// basin-docs behavior: between minLevel and the active ramp foot, demand
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
// only the explicit minLevel hard-stop path skips handleInput.
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
}
});
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
const ctx = makeCtx(2);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
});
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
const ctx = makeCtx(4);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 100);
});
test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => {
const ctx = makeCtx(10);
const state = { percControl: null };
await levelBased.run(ctx, state);
// interpolate_lin_single_point clamps via limit_input(o_min, o_max).
assert.equal(state.percControl, 100);
});
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 50);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
assert.equal(g._calls.turnOff, 0);
}
});
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
const ctx = makeCtx(NaN);
let warned = false;
ctx.logger.warn = () => { warned = true; };
const state = { percControl: 7 };
await levelBased.run(ctx, state);
assert.equal(warned, true);
assert.equal(state.percControl, 7);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
}
});

View File

@@ -0,0 +1,64 @@
// Unit tests for the manual control strategy.
// Run with: node --test test/basic/control-manual.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const manual = require('../../src/control/manual');
function makeGroup(name) {
const calls = { handleInput: [] };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
_calls: calls,
};
}
function makeMachine(name) {
const calls = { handleInput: [] };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
_calls: calls,
};
}
function makeLogger() {
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
}
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
await manual.forwardDemand(ctx, 50);
for (const g of Object.values(groups)) {
assert.equal(g._calls.handleInput.length, 1);
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
}
});
test('forwardDemand with no machineGroups but direct machines splits demand evenly', async () => {
const machines = { m1: makeMachine('M1'), m2: makeMachine('M2'), m3: makeMachine('M3'), m4: makeMachine('M4') };
const ctx = { machineGroups: {}, machines, logger: makeLogger() };
await manual.forwardDemand(ctx, 80);
for (const m of Object.values(machines)) {
assert.equal(m._calls.handleInput.length, 1);
assert.deepEqual(m._calls.handleInput[0], ['parent', 'execMovement', 20]);
}
});
test('run() is a no-op (manual mode is event-driven)', async () => {
const groups = { a: makeGroup('A') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
await manual.run(ctx, { percControl: 0 });
assert.equal(groups.a._calls.handleInput.length, 0);
});
test('manual exports name === "manual"', () => {
assert.equal(manual.name, 'manual');
});

View File

@@ -0,0 +1,141 @@
// Basic tests for FlowAggregator. Pure node:test, no Node-RED runtime.
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const FlowAggregator = require('../../src/measurement/flowAggregator');
function makeBasin() {
// Constant-cross-section basin: 50 m3 / 5 m height ⇒ surfaceArea = 10 m2.
const surfaceArea = 10;
return {
surfaceArea,
minVol: 2,
maxVol: 50,
maxVolAtOverflow: 45, // overflow at 4.5 m
minVolAtOutflow: 2,
minVolAtInflow: 30,
overflowLevel: 4.5,
outflowLevel: 0.2,
inflowLevel: 3,
};
}
function makeMeasurements() {
return new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
});
}
function makeAggregator(overrides = {}) {
const measurements = overrides.measurements || makeMeasurements();
const basin = overrides.basin || makeBasin();
// Seed predicted volume at minVol so update() has a starting point.
measurements.type('volume').variant('predicted').position('atequipment')
.value(basin.minVol).unit('m3');
const fa = new FlowAggregator({ measurements, basin, flowThreshold: 1e-4 });
return { fa, measurements, basin };
}
test('FlowAggregator.update integrates inflow-outflow over delta-t', async () => {
const { fa, measurements } = makeAggregator();
// Net flow = 0.01 m3/s (in) - 0.005 m3/s (out) = 0.005 m3/s.
const t0 = Date.now() - 10_000; // 10 s ago
measurements.type('flow').variant('predicted').position('in').child('src')
.value(0.01, t0, 'm3/s');
measurements.type('flow').variant('predicted').position('out').child('snk')
.value(0.005, t0, 'm3/s');
// Force the integrator to know we are starting 10 s in the past.
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// Expect minVol(2) + 0.005 * ~10 ≈ 2.05 m3. Allow slack for clock jitter.
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
});
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('flow').variant('measured').position('in').child('m')
.value(0.02, Date.now(), 'm3/s');
measurements.type('flow').variant('measured').position('out').child('m')
.value(0.01, Date.now(), 'm3/s');
measurements.type('flow').variant('predicted').position('in').child('p')
.value(0.5, Date.now(), 'm3/s');
measurements.type('flow').variant('predicted').position('out').child('p')
.value(0.0, Date.now(), 'm3/s');
const r = fa.selectBestNetFlow();
assert.equal(r.source, 'measured');
assert.ok(Math.abs(r.value - 0.01) < 1e-9);
assert.equal(r.direction, 'filling');
});
test('FlowAggregator.selectBestNetFlow falls back to level rate when no flow', async () => {
const { fa, measurements, basin } = makeAggregator();
// Seed two level samples 2 s apart, rising 0.1 m → rate 0.05 m/s
// → net flow = 0.05 * 10 m2 = 0.5 m3/s (filling).
const t0 = Date.now() - 2_000;
const t1 = Date.now();
measurements.type('level').variant('measured').position('atequipment').child('default')
.value(1.0, t0, 'm');
measurements.type('level').variant('measured').position('atequipment').child('default')
.value(1.1, t1, 'm');
const r = fa.selectBestNetFlow();
assert.ok(r.source.startsWith('level:'), `source was ${r.source}`);
assert.equal(r.direction, 'filling');
assert.ok(Math.abs(r.value - basin.surfaceArea * 0.05) < 1e-3, `net flow was ${r.value}`);
});
test('FlowAggregator.deriveDirection threshold semantics', async () => {
const { fa } = makeAggregator();
assert.equal(fa.deriveDirection(0), 'steady');
assert.equal(fa.deriveDirection(fa.flowThreshold * 2), 'filling');
assert.equal(fa.deriveDirection(-fa.flowThreshold * 2), 'draining');
assert.equal(fa.deriveDirection(fa.flowThreshold * 0.5), 'steady');
assert.equal(fa.deriveDirection(-fa.flowThreshold * 0.5), 'steady');
});
test('FlowAggregator.computeRemainingTime — filling uses overflow ceiling', async () => {
const { fa, measurements, basin } = makeAggregator();
measurements.type('level').variant('predicted').position('atequipment')
.value(2.0, Date.now(), 'm');
// Net 0.05 m3/s upward; remaining height = 4.5 - 2.0 = 2.5 m.
// seconds = 2.5 * 10 / 0.05 = 500 s.
const r = fa.computeRemainingTime({ value: 0.05, source: 'measured', direction: 'filling' });
assert.ok(Math.abs(r.seconds - 500) < 1e-6, `seconds was ${r.seconds}`);
assert.equal(typeof r.source, 'string');
});
test('FlowAggregator.computeRemainingTime — draining uses outflow floor', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('level').variant('predicted').position('atequipment')
.value(1.0, Date.now(), 'm');
// Net -0.05 m3/s; remaining height = 1.0 - 0.2 = 0.8 m.
// seconds = 0.8 * 10 / 0.05 = 160 s.
const r = fa.computeRemainingTime({ value: -0.05, source: 'measured', direction: 'draining' });
assert.ok(Math.abs(r.seconds - 160) < 1e-6, `seconds was ${r.seconds}`);
});
test('FlowAggregator.snapshot exposes the expected shape', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('flow').variant('measured').position('in').child('m')
.value(0.02, Date.now(), 'm3/s');
fa.tick();
const snap = fa.snapshot();
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'direction'));
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'netFlow'));
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'flowSource'));
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'secondsRemaining'));
});
test('FlowAggregator.computeRemainingTime — below threshold returns null seconds', async () => {
const { fa } = makeAggregator();
const r = fa.computeRemainingTime({ value: 0, source: null, direction: 'steady' });
assert.equal(r.seconds, null);
});

View File

@@ -0,0 +1,106 @@
// Basic tests for MeasurementRouter.
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer, coolprop } = require('generalFunctions');
const MeasurementRouter = require('../../src/measurement/measurementRouter');
// CoolProp is async-init; ensure it's warm before any pressure-conversion
// test runs.
test.before(async () => {
await coolprop.init({ refrigerant: 'Water' });
});
function makeBasin() {
return {
surfaceArea: 10,
minVol: 2,
maxVol: 50,
maxVolAtOverflow: 45,
overflowLevel: 4.5,
outflowLevel: 0.2,
inflowLevel: 3,
};
}
function makeMeasurements() {
return new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
});
}
function fakeLogger() {
const calls = { warn: [], info: [], error: [], debug: [] };
return {
warn: (m) => calls.warn.push(m),
info: (m) => calls.info.push(m),
error: (m) => calls.error.push(m),
debug: (m) => calls.debug.push(m),
_calls: calls,
};
}
test('onLevelMeasurement writes volume + percent', async () => {
const measurements = makeMeasurements();
const basin = makeBasin();
const router = new MeasurementRouter({ measurements, basin });
router.onLevelMeasurement('atequipment', 2.5, { unit: 'm', timestamp: Date.now() });
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
const vol = measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3');
// 2.5 m * 10 m² = 25 m3.
assert.ok(Math.abs(vol - 25) < 1e-9, `volume was ${vol}`);
const pct = measurements.type('volumePercent').variant('measured').position('atequipment').getCurrentValue('%');
// (25 - 2) / (45 - 2) * 100 ≈ 53.488...
assert.ok(pct > 53 && pct < 54, `percent was ${pct}`);
});
test('onPressureMeasurement falls back to assumed temperature and warns', async () => {
const measurements = makeMeasurements();
const basin = makeBasin();
const logger = fakeLogger();
const router = new MeasurementRouter({ measurements, basin, logger });
// No temperature seeded — must fall back to assumed 15C.
measurements.type('pressure').variant('measured').position('atequipment')
.value(20000, Date.now(), 'Pa');
router.onPressureMeasurement('atequipment', 20000, { unit: 'Pa', timestamp: Date.now() });
const warned = logger._calls.warn.some((m) => /assuming 15C|temperature/i.test(m));
assert.ok(warned, 'expected a warn about missing temperature');
const assumedT = measurements.type('temperature').variant('assumed').position('atequipment')
.getCurrentValue('K');
assert.ok(Number.isFinite(assumedT), 'assumed temperature was not stored');
const lvl = measurements.type('level').variant('predicted').position('atequipment')
.getCurrentValue('m');
// 20000 Pa / (~999 kg/m³ * 9.80665) ≈ 2.04 m.
assert.ok(lvl > 1.9 && lvl < 2.2, `derived level was ${lvl}`);
});
test('route() dispatches by measurement type', async () => {
const measurements = makeMeasurements();
const basin = makeBasin();
const router = new MeasurementRouter({ measurements, basin });
const handledLevel = router.route('level', 1.5, 'atequipment', { unit: 'm' });
assert.equal(handledLevel, true);
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
assert.ok(Math.abs(lvl - 1.5) < 1e-9);
// Unknown type returns false (no dispatch).
const handledOther = router.route('flow', 0.1, 'in', {});
assert.equal(handledOther, false);
});
test('constructor rejects missing context fields', async () => {
assert.throws(() => new MeasurementRouter({}));
assert.throws(() => new MeasurementRouter({ measurements: makeMeasurements() }));
});

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,230 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const SafetyController = require('../../src/safety/safetyController');
// --------------------------- fakes ---------------------------
function fakeMeasurements(values) {
// values keyed by `${type}.${variant}.${position}` → number|null
return {
getUnit: (_type) => 'm3',
type(t) {
return {
variant(v) {
return {
position(p) {
return {
getCurrentValue() {
const k = `${t}.${v}.${p}`;
return values[k];
},
};
},
};
},
};
},
};
}
function makeMachine(positionVsParent, operational = true) {
const calls = [];
return {
config: { functionality: { positionVsParent } },
_isOperationalState: () => operational,
handleInput: (...args) => calls.push(args),
calls,
};
}
function makeStation() {
const calls = [];
return {
handleInput: (...args) => calls.push(args),
calls,
};
}
function makeGroup() {
const calls = [];
return {
turnOffAllMachines: () => calls.push(['turnOffAllMachines']),
calls,
};
}
function makeLogger() {
const warns = [];
return {
warn: (msg) => warns.push(msg),
info: () => {},
error: () => {},
debug: () => {},
warns,
};
}
function makeCtx({
vol = 50,
basin = { minVol: 10, maxVolAtOverflow: 90 },
safety = {
enableDryRunProtection: true,
enableOverfillProtection: true,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
machines = {},
stations = {},
machineGroups = {},
} = {}) {
const measurements = fakeMeasurements({
'volume.measured.atequipment': vol,
'volume.predicted.atequipment': vol,
});
const logger = makeLogger();
return {
ctx: { measurements, basin, config: { safety }, logger, machines, stations, machineGroups },
logger,
};
}
// --------------------------- tests ---------------------------
test('normal volume + filling → not blocked, no shutdowns', () => {
const m = makeMachine('downstream');
const { ctx } = makeCtx({ vol: 50, machines: { m } });
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
assert.deepStrictEqual(r, { blocked: false, reason: null, triggered: [] });
assert.strictEqual(m.calls.length, 0);
});
test('dry-run trigger: low volume + draining → blocked, downstream shut down', () => {
const down = makeMachine('downstream');
const at = makeMachine('atequipment');
const up = makeMachine('upstream');
const station = makeStation();
const group = makeGroup();
const { ctx } = makeCtx({
vol: 5, // below 10 * (1 + 10/100) = 11
machines: { down, at, up },
stations: { station },
machineGroups: { group },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'dry-run');
assert.ok(r.triggered.includes('dry-run-volume'));
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(at.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(up.calls.length, 0, 'upstream untouched in dry-run');
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(group.calls[0], ['turnOffAllMachines']);
});
test('dry-run does NOT trigger when filling', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({ vol: 5, machines: { down } });
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
// Filling at vol=5 (below overfill threshold 85.5) → no trigger at all.
assert.strictEqual(r.blocked, false);
assert.strictEqual(r.reason, null);
assert.strictEqual(down.calls.length, 0);
});
test('overfill trigger: high volume + filling → not blocked, only upstream + station shut down', () => {
const down = makeMachine('downstream');
const at = makeMachine('atequipment');
const up = makeMachine('upstream');
const station = makeStation();
const group = makeGroup();
const { ctx } = makeCtx({
vol: 88, // above 90 * 0.95 = 85.5
machines: { down, at, up },
stations: { station },
machineGroups: { group },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, false, 'overfill must NOT block control');
assert.strictEqual(r.reason, 'overfill');
assert.ok(r.triggered.includes('overfill-volume'));
assert.deepStrictEqual(up.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(down.calls.length, 0, 'downstream must keep running');
assert.strictEqual(at.calls.length, 0, 'atequipment must keep running');
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(group.calls.length, 0, 'machine groups must keep draining');
});
test('no volume data → blocked, all machines shut down (panic)', () => {
const a = makeMachine('downstream');
const b = makeMachine('upstream');
const c = makeMachine('atequipment');
// override measurements to return null
const measurements = {
getUnit: () => 'm3',
type: () => ({ variant: () => ({ position: () => ({ getCurrentValue: () => null }) }) }),
};
const ctx = {
measurements,
basin: { minVol: 10, maxVolAtOverflow: 90 },
config: { safety: { enableDryRunProtection: true, enableOverfillProtection: true, dryRunThresholdPercent: 10, overfillThresholdPercent: 95 } },
logger: makeLogger(),
machines: { a, b, c },
stations: {},
machineGroups: {},
};
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'steady', secondsRemaining: null });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'no-volume-data');
assert.deepStrictEqual(a.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(b.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(c.calls[0], ['parent', 'execSequence', 'shutdown']);
});
test('time-based protection: short remainingTime while draining triggers dry-run shutdowns', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({
vol: 50, // well above dry-run vol threshold
safety: {
enableDryRunProtection: false, // volume rule disabled
enableOverfillProtection: false,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 60,
},
machines: { down },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 30 });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'dry-run');
assert.ok(r.triggered.includes('time-remaining'));
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
});
test('disabled rules: enableDryRunProtection=false + draining low → no trigger', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({
vol: 5, // would normally trigger dry-run
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
machines: { down },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, false);
assert.strictEqual(r.reason, null);
assert.strictEqual(down.calls.length, 0);
});

View File

@@ -27,6 +27,8 @@ function makeConfig(overrides = {}) {
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3,
},
hydraulics: {
refHeight: 'NAP',
@@ -36,12 +38,13 @@ function makeConfig(overrides = {}) {
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 2,
highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
@@ -80,6 +83,10 @@ test('Basin geometry — derived values', async (t) => {
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) => {
@@ -131,6 +138,17 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
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 },
@@ -223,20 +241,22 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
assert.equal(turnOffCalls, 1);
});
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
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 () => { throw new Error('should not be called in dead zone'); },
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 42); // unchanged
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
@@ -244,14 +264,144 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
await ps._controlLevelBased();
// lerp(3, [2,4], [0,100]) = 50
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'] = {
@@ -275,6 +425,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
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();
@@ -293,3 +447,155 @@ test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
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,123 @@
// Basic unit tests for thresholdValidator.
// Run with: node --test test/basic/thresholdValidator.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { validateThresholdOrdering } = require('../../src/basin/thresholdValidator');
const BasinGeometry = require('../../src/basin/BasinGeometry');
// A valid baseline: outlet 0.2 < inflow 3 < overflow 4.5 ≤ height 5,
// dryRun = 0.2 * 1.10 = 0.22 ≤ minLevel 1 ≤ start 2 < max 4 ≤ overfill 4.275.
function validBasinAndCfg() {
const basin = new BasinGeometry(
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
{ minHeightBasedOn: 'outlet' }
);
const levelbased = { minLevel: 1, startLevel: 2, maxLevel: 4 };
const safety = { dryRunThresholdPercent: 10, overfillThresholdPercent: 95 };
return { basin, levelbased, safety };
}
test('valid ordering returns empty array', () => {
const { basin, levelbased, safety } = validBasinAndCfg();
const issues = validateThresholdOrdering(basin, levelbased, safety);
assert.deepEqual(issues, []);
});
test('outflowLevel >= inflowLevel triggers issue with correct shape', () => {
const basin = new BasinGeometry(
// outflow 3.5 > inflow 3 — invariant broken.
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 3.5, overflowLevel: 4.5 },
{ minHeightBasedOn: 'outlet' }
);
const issues = validateThresholdOrdering(basin, { minLevel: 1, startLevel: 2, maxLevel: 4 }, { dryRunThresholdPercent: 0, overfillThresholdPercent: 100 });
const hit = issues.find((i) => i.aName === 'outflowLevel' && i.bName === 'inflowLevel');
assert.ok(hit, 'expected an outflowLevel < inflowLevel issue');
assert.equal(hit.op, '<');
assert.equal(hit.a, 3.5);
assert.equal(hit.b, 3);
assert.match(hit.msg, /outflowLevel.*<.*inflowLevel/);
});
test('maxLevel >= overfillLevel triggers issue', () => {
const { basin } = validBasinAndCfg();
// overfillLevel = overflowLevel × overfillPct/100 = 4.5 × 0.80 = 3.6.
// maxLevel 4 > 3.6 → expect a `maxLevel <= overfillLevel` issue.
const issues = validateThresholdOrdering(
basin,
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 80 }
);
const hit = issues.find((i) => i.aName === 'maxLevel' && i.bName === 'overfillLevel');
assert.ok(hit, 'expected a maxLevel <= overfillLevel issue');
assert.equal(hit.op, '<=');
assert.equal(hit.a, 4);
assert.ok(Math.abs(hit.b - 3.6) < 1e-9);
});
test('NaN / undefined values are skipped, not flagged as issues', () => {
const { basin } = validBasinAndCfg();
const issues = validateThresholdOrdering(
basin,
{ minLevel: undefined, startLevel: NaN, maxLevel: 4 },
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
);
// dryRunLevel <= minLevel skipped (minLevel undefined → NaN)
// minLevel <= startLevel skipped (both NaN-ish)
// startLevel < maxLevel skipped (startLevel NaN)
// maxLevel <= overfillLevel still checked → 4 ≤ 4.275 OK.
// Geometry checks also OK.
assert.deepEqual(issues, []);
});
test('multiple violations produce multiple issues in stable order', () => {
// Build a basin with two geometry violations.
const basin = new BasinGeometry(
// outflow 4 > inflow 3 (broken) AND overflow 6 > height 5 (broken)
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 4, overflowLevel: 6 },
{ minHeightBasedOn: 'outlet' }
);
const issues = validateThresholdOrdering(
basin,
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
{ dryRunThresholdPercent: 0, overfillThresholdPercent: 100 }
);
// Expect at least the two geometry issues, in declaration order:
// outflowLevel < inflowLevel comes before overflowLevel <= basinHeight.
const idxOutflow = issues.findIndex((i) => i.aName === 'outflowLevel');
const idxOverflow = issues.findIndex((i) => i.aName === 'overflowLevel' && i.bName === 'basinHeight');
assert.ok(idxOutflow >= 0, 'expected outflowLevel issue');
assert.ok(idxOverflow >= 0, 'expected overflowLevel <= basinHeight issue');
assert.ok(idxOutflow < idxOverflow, 'issues should be in check-declaration order');
});
test('accepts a plain basin object (duck-typed via getters)', () => {
const plainBasin = {
volEmptyBasin: 50,
heightBasin: 5,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
surfaceArea: 10,
maxVol: 50,
maxVolAtOverflow: 45,
minVolAtInflow: 30,
minVolAtOutflow: 2,
minVol: 2,
minHeightBasedOn: 'outlet',
};
const issues = validateThresholdOrdering(
plainBasin,
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
);
assert.deepEqual(issues, []);
});
test('omitted levelbased / safety objects are tolerated', () => {
const { basin } = validBasinAndCfg();
// No control or safety supplied → only geometry checks run; valid basin geometry → []
const issues = validateThresholdOrdering(basin, undefined, undefined);
assert.deepEqual(issues, []);
});

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

949
tools/build-examples.js Normal file
View File

@@ -0,0 +1,949 @@
#!/usr/bin/env node
'use strict';
/**
* build-examples.js — regenerate the three example flows for pumpingStation.
*
* Source of truth for the Tier 1/2/3 example flows under examples/.
* Follows EVOLV/.claude/rules/node-red-flow-layout.md:
* - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
* - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9,
* Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd)
* - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*)
* - ui-chart objects carry every mandatory key (interpolation, yAxisProperty,
* xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any
* causes FlowFuse to render the chart blank with no error.
*
* Only canonical pumpingStation topic names are used (per CONTRACT.md):
* set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level.
*
* Run from repo root or any cwd:
* node nodes/pumpingStation/tools/build-examples.js
*/
const fs = require('fs');
const path = require('path');
const OUT_DIR = path.join(__dirname, '..', 'examples');
/* ------------------------------------------------------------------ */
/* Layout constants */
/* ------------------------------------------------------------------ */
const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800];
const S88 = {
AR: '#0f52a5',
PC: '#0c99d9',
UN: '#50a8d9',
EM: '#86bbdd',
CM: '#a9daee',
neutral: '#dddddd',
};
const CHART_COLORS = [
'#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1',
'#D62728', '#FF9896', '#9467BD', '#C5B0D5',
];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function tab(id, label, info) {
return { id, type: 'tab', label, disabled: false, info: info || '' };
}
function comment(id, z, name, x, y) {
return { id, type: 'comment', z, name, info: '', x, y, wires: [] };
}
function linkOut(id, z, name, x, y, links) {
return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] };
}
function linkIn(id, z, name, x, y, links, downstream) {
return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] };
}
function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) {
const o = opts || {};
return {
id, type: 'inject', z, name,
props: [
{ p: 'topic', vt: 'str' },
{ p: 'payload', v: String(payload), vt: payloadType },
],
topic,
repeat: o.repeat || '',
crontab: '',
once: !!o.once,
onceDelay: o.onceDelay || '',
x, y,
wires: [wires || []],
};
}
function fn(id, z, name, code, x, y, wires, outputs) {
return {
id, type: 'function', z, name,
func: code,
outputs: outputs || 1,
noerr: 0,
initialize: '',
finalize: '',
libs: [],
x, y,
wires: wires || [[]],
};
}
function debugNode(id, z, name, x, y, complete, targetType, active) {
return {
id, type: 'debug', z, name,
active: active !== false,
tosidebar: true,
console: false,
tostatus: false,
complete: complete || 'payload',
targetType: targetType || 'msg',
x, y, wires: [],
};
}
function group(id, z, name, color, nodes, bbox) {
return {
id, type: 'group', z, name,
style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' },
nodes,
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
};
}
function bboxOf(nodeList, ids, pad) {
const p = pad == null ? 20 : pad;
const ns = nodeList.filter((n) => ids.includes(n.id));
const xs = ns.map((n) => n.x || 0);
const ys = ns.map((n) => n.y || 0);
const minX = Math.min(...xs) - p;
const minY = Math.min(...ys) - p - 20;
const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p;
const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p;
return { x: minX, y: minY, w, h };
}
/* Build a fully-specified pumpingStation node. Every config field is set
* explicitly per rule §9 (no schema-default reliance for operational
* parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m,
* overflow at 3.2 m. Level thresholds chosen so levelbased control activates
* mid-tank and saturates near overflow.
*/
function pumpingStationNode(id, z, name, x, y, wires) {
return {
id, type: 'pumpingStation', z, name,
simulator: false,
basinVolume: 50,
basinHeight: 3.5,
inflowLevel: 3.0,
outflowLevel: 0.2,
overflowLevel: 3.2,
defaultFluid: 'wastewater',
inletPipeDiameter: 0.3,
outletPipeDiameter: 0.3,
pipelineLength: 80,
maxDischargeHead: 24,
staticHead: 12,
maxInflowRate: 200,
temperatureReferenceDegC: 15,
timeleftToFullOrEmptyThresholdSeconds: 0,
enableDryRunProtection: true,
enableOverfillProtection: true,
dryRunThresholdPercent: 2,
overfillThresholdPercent: 98,
minHeightBasedOn: 'outlet',
processOutputFormat: 'process',
dbaseOutputFormat: 'influxdb',
refHeight: 'NAP',
basinBottomRef: 1,
uuid: 'example-ps-001',
supplier: 'WBD-RD',
category: 'station',
assetType: 'pumpingstation',
model: 'demo-50m3',
unit: 'm3/h',
enableLog: true,
logLevel: 'info',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: '',
distanceUnit: 'm',
distanceDescription: '',
controlMode: 'levelbased',
startLevel: 1.2,
minLevel: 0.4,
maxLevel: 2.8,
flowSetpoint: null,
flowDeadband: null,
x, y,
wires: wires || [[], [], []],
};
}
function measurementLevelNode(id, z, name, x, y, wires) {
return {
id, type: 'measurement', z, name,
mode: 'analog',
channels: '[]',
scaling: false,
i_min: 0, i_max: 0, i_offset: 0,
o_min: 0, o_max: 1,
simulator: true,
smooth_method: 'mean',
count: 5,
processOutputFormat: 'process',
dbaseOutputFormat: 'influxdb',
uuid: 'example-level-001',
supplier: 'vega',
category: 'sensor',
assetType: 'level',
model: 'VEGAPULS-31',
unit: 'm',
assetTagNumber: 'LT-001',
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: 0,
distanceUnit: 'm',
distanceDescription: '',
x, y,
wires: wires || [[], [], []],
};
}
function machineGroupControlNode(id, z, name, x, y, wires) {
return {
id, type: 'machineGroupControl', z, name,
enableLog: true,
logLevel: 'info',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: '',
distanceUnit: 'm',
x, y,
wires: wires || [[], [], []],
};
}
function rotatingMachineNode(id, z, name, uuid, x, y, wires) {
return {
id, type: 'rotatingMachine', z, name,
speed: '1',
startup: '2', warmup: '1', shutdown: '2', cooldown: '1',
movementMode: 'staticspeed',
machineCurve: '',
uuid,
supplier: 'hidrostal',
category: 'pump',
assetType: 'pump-centrifugal',
model: 'hidrostal-H05K-S03R',
unit: 'm3/h',
curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW',
curveControlUnit: '%',
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
positionIcon: '',
hasDistance: false,
distance: '',
distanceUnit: 'm',
distanceDescription: '',
x, y,
wires: wires || [[], [], []],
};
}
/* FlowFuse ui-chart with every required key (per layout rule §4). */
function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) {
return {
id, type: 'ui-chart', z, group, name, label,
order, width: 12, height: 6,
chartType: 'line',
category: 'topic',
categoryType: 'msg',
xAxisLabel: 'time',
xAxisType: 'time',
xAxisProperty: '',
xAxisPropertyType: 'timestamp',
xAxisFormat: '',
xAxisFormatType: 'auto',
yAxisLabel,
yAxisProperty: 'payload',
yAxisPropertyType: 'msg',
xmin: '', xmax: '', ymin: '', ymax: '',
bins: 10,
action: 'append',
stackSeries: false,
pointShape: 'circle',
pointRadius: 4,
interpolation: 'linear',
showLegend: true,
className: '',
removeOlder: '15',
removeOlderUnit: '60',
removeOlderPoints: '200',
colors: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS,
textColor: ['#666666'],
textColorDefault: true,
gridColor: ['#e5e5e5'],
gridColorDefault: true,
x, y, wires: [],
};
}
function uiText(id, z, group, name, label, order, x, y, format) {
return {
id, type: 'ui-text', z, group, name, label,
order, width: 4, height: 1,
format: format || '{{msg.payload}}',
layout: 'row-spread',
x, y, wires: [],
};
}
function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) {
return {
id, type: 'ui-slider', z, group, name, label,
order, width: 6, height: 1,
passthru: true,
outs: 'end',
topic,
topicType: 'str',
min, max, step,
icon: '',
thumbLabel: 'always',
showValue: true,
className: '',
x, y, wires: [[]],
};
}
function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) {
return {
id, type: 'ui-dropdown', z, group, name, label,
order, width: 6, height: 1,
passthru: true,
multiple: false,
options: options.map((o) => ({ label: o, value: o, type: 'str' })),
payload: '',
topic,
topicType: 'str',
x, y,
wires: [wires || []],
};
}
function uiBase(id) {
return {
id, type: 'ui-base',
name: 'EVOLV Demo',
path: '/dashboard',
appIcon: '',
includeClientData: true,
acceptsClientConfig: ['ui-notification', 'ui-control'],
showPathInSidebar: false,
headerContent: 'page',
navigationStyle: 'default',
titleBarStyle: 'default',
};
}
function uiTheme(id) {
return {
id, type: 'ui-theme',
name: 'EVOLV Theme',
colors: {
surface: '#ffffff', primary: '#0c99d9', bgPage: '#eeeeee',
groupBg: '#ffffff', groupOutline: '#cccccc',
},
sizes: {
density: 'default', pagePadding: '14px', groupGap: '14px',
groupBorderRadius: '6px', widgetGap: '12px',
},
};
}
function uiPage(id, base, theme, name, path, order) {
return {
id, type: 'ui-page', name, ui: base, path,
icon: 'water',
layout: 'grid', theme,
breakpoints: [{ name: 'Default', px: '0', cols: '12' }],
order, className: '',
};
}
function uiGroup(id, page, name, width, height, order) {
return {
id, type: 'ui-group', name, page, width, height, order,
showTitle: true, className: '',
};
}
/* ------------------------------------------------------------------ */
/* Tier 1 — 01-Basic.json */
/* ------------------------------------------------------------------ */
function buildBasic() {
const Z = 'ps_basic_tab';
const nodes = [];
nodes.push(tab(Z, 'PumpingStation - Basic',
'Tier 1: single pumpingStation node driven by inject nodes only. ' +
'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.'));
nodes.push(comment('ps_basic_title', Z,
'PumpingStation - Basic\n' +
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' +
'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' +
'only when set.mode = manual.\n\n' +
'HOW TO USE:\n' +
' 1. Deploy the flow.\n' +
' 2. Click "set.mode = manual" so set.demand is honoured.\n' +
' 3. Click "set.inflow = 60 m3/h" to push wastewater into the basin.\n' +
' 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n' +
' 5. Click "calibrate volume 25 m3" to jump straight to half-full.\n\n' +
'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' +
'warning - fresh flows use the canonical names.', 600, 40));
// Lane 0: link-in placeholders (none for Tier 1 - all inputs are local).
// Lane 2..3: inject nodes (we keep them in lane 1 for proximity).
const injectMode = inject('ps_basic_inj_mode', Z, 'set.mode = manual', 'set.mode', 'manual', 'str', 200, 160, ['ps_basic_node']);
const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode', 'levelbased', 'str', 220, 200, ['ps_basic_node']);
const injectInflow = inject('ps_basic_inj_inflow', Z, 'set.inflow = 60 m3/h', 'set.inflow', '60', 'num', 200, 260, ['ps_basic_node']);
const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 %', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node']);
const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']);
const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']);
nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl);
// Lane 5 (PC): the pumpingStation itself.
const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300,
[['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]);
nodes.push(ps);
// Lane 6: format/merge function for Port 0.
const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format',
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
"const cache = context.get('c') || {};\n" +
"Object.assign(cache, p);\n" +
"context.set('c', cache);\n" +
"function pick(prefix) {\n" +
" for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n" +
" const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n" +
" } return null;\n" +
"}\n" +
"const vol = pick('volume.predicted.atequipment');\n" +
"const lvl = pick('level.predicted.atequipment');\n" +
"const flIn = pick('flow.predicted.in');\n" +
"msg.payload = {\n" +
" state: cache.state || 'unknown',\n" +
" controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
" direction: cache.direction || 'n/a',\n" +
" percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n" +
" volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n" +
" volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n" +
" level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n" +
" inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n" +
" timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n" +
" timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n" +
"};\nreturn msg;",
LANE_X[6], 280, [['ps_basic_dbg_process']]);
nodes.push(formatFn);
// Lane 7: debug taps.
nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true));
nodes.push(debugNode('ps_basic_dbg_influx', Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false));
nodes.push(debugNode('ps_basic_dbg_parent', Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true));
// Wrap the station + its formatter in a Process Cell group box.
const psGroupIds = ['ps_basic_node', 'ps_basic_format'];
nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds,
bboxOf(nodes, psGroupIds, 30)));
return nodes;
}
/* ------------------------------------------------------------------ */
/* Tier 2 — 02-Integration.json */
/* ------------------------------------------------------------------ */
function buildIntegration() {
const TAB_PROC = 'ps_int_proc';
const TAB_SETUP = 'ps_int_setup';
const nodes = [];
nodes.push(tab(TAB_PROC, 'Process Plant',
'Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. ' +
'Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics.'));
nodes.push(tab(TAB_SETUP, 'Setup',
'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.'));
/* ---------- Process Plant tab ---------------------------------- */
nodes.push(comment('ps_int_title', TAB_PROC,
'PumpingStation - Integration\n' +
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' +
'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' +
'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40));
/* Link-ins on L0 receive from the Setup tab. */
const linInMode = linkIn('lin_setup_mode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 500, [], ['ps_int_station']);
const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']);
const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']);
nodes.push(linInMode, linInInflow, linInMgcMode);
/* L2: level measurement (Control Module). */
const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor',
LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]);
nodes.push(levelMeas);
// Simulator measurement injector for the level sensor (push a varying level so PS sees something).
const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']);
nodes.push(levelInj);
/* L3: two rotatingMachine pumps (Equipment Module). */
const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]);
const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]);
nodes.push(pumpA, pumpB);
/* L4: MGC (Unit). */
const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group',
LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]);
nodes.push(mgc);
/* L5: pumpingStation (Process Cell). */
const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station',
LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]);
nodes.push(station);
/* L6: formatter for the station's Port 0. */
const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format',
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
"function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
"const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" +
"msg.payload = {\n" +
" state: cache.state || 'unknown',\n" +
" controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
" direction: cache.direction || 'n/a',\n" +
" percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n" +
" volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n" +
" volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n" +
" level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n" +
" inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
" outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
" childCount: cache.childCount != null ? cache.childCount : 'n/a'\n" +
"};\nreturn msg;",
LANE_X[6], 520, [['ps_int_dbg_process']]);
nodes.push(formatFn);
/* L7: debug taps for the various ports. */
nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process', LANE_X[7], 480, 'payload', 'msg', true));
nodes.push(debugNode('ps_int_dbg_influx', TAB_PROC, 'PS Port 1: InfluxDB', LANE_X[7], 540, 'true', 'full', false));
nodes.push(debugNode('ps_int_dbg_mgc', TAB_PROC, 'MGC Port 0', LANE_X[7], 360, 'payload', 'msg', true));
nodes.push(debugNode('ps_int_dbg_pa', TAB_PROC, 'Pump A Port 0', LANE_X[7], 320, 'payload', 'msg', false));
nodes.push(debugNode('ps_int_dbg_pb', TAB_PROC, 'Pump B Port 0', LANE_X[7], 400, 'payload', 'msg', false));
nodes.push(debugNode('ps_int_dbg_level', TAB_PROC, 'Level Port 0', LANE_X[7], 700, 'payload', 'msg', false));
/* Group boxes. */
const pumpAIds = ['pump_a', 'ps_int_dbg_pa'];
const pumpBIds = ['pump_b', 'ps_int_dbg_pb'];
const mgcIds = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode'];
const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow'];
const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level'];
nodes.push(group('grp_pumpa', TAB_PROC, 'Pump A (EM)', S88.EM, pumpAIds, bboxOf(nodes, pumpAIds, 25)));
nodes.push(group('grp_pumpb', TAB_PROC, 'Pump B (EM)', S88.EM, pumpBIds, bboxOf(nodes, pumpBIds, 25)));
nodes.push(group('grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, mgcIds, bboxOf(nodes, mgcIds, 25)));
nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25)));
nodes.push(group('grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, levelIds, bboxOf(nodes, levelIds, 25)));
/* ---------- Setup tab ----------------------------------------- */
nodes.push(comment('setup_title', TAB_SETUP,
'Deploy-time setup\n' +
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' +
'set.demand topics across cross-tab channels into the Process Plant tab.',
LANE_X[2], 40));
const setMode = inject('setup_inj_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'], { once: true, onceDelay: '0.5' });
const setMgc = inject('setup_inj_mgcmode', TAB_SETUP, 'MGC set.mode = auto', 'set.mode', 'auto', 'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' });
const setInflow = inject('setup_inj_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0' });
nodes.push(setMode, setMgc, setInflow);
const loutMode = linkOut('lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_setup_mode']);
const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode', LANE_X[7], 220, ['lin_setup_mgcmode']);
const loutInflow = linkOut('lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 280, ['lin_setup_inflow']);
nodes.push(loutMode, loutMgcMode, loutInflow);
// Setup tab group.
const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow',
'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow'];
nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
return nodes;
}
/* ------------------------------------------------------------------ */
/* Tier 3 — 03-Dashboard.json */
/* ------------------------------------------------------------------ */
function buildDashboard() {
const TAB_PROC = 'ps_dash_proc';
const TAB_UI = 'ps_dash_ui';
const TAB_SETUP = 'ps_dash_setup';
const nodes = [];
nodes.push(tab(TAB_PROC, 'Process Plant',
'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.'));
nodes.push(tab(TAB_UI, 'Dashboard UI',
'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.'));
nodes.push(tab(TAB_SETUP, 'Setup',
'Once-true injects: initial mode + initial inflow seed.'));
/* ---------- FlowFuse dashboard scaffolding -------------------- */
nodes.push(uiBase('ps_dash_base'));
nodes.push(uiTheme('ps_dash_theme'));
nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1));
nodes.push(uiGroup('ps_dash_grp_ctrl', 'ps_dash_page', 'Controls', 6, 1, 1));
nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status', 6, 1, 2));
nodes.push(uiGroup('ps_dash_grp_trend', 'ps_dash_page', 'Live Trends', 12, 1, 3));
/* ---------- Process Plant tab --------------------------------- */
nodes.push(comment('ps_dash_proc_title', TAB_PROC,
'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' +
'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.',
600, 40));
/* L0 link-ins: setup + dashboard commands. */
const linModeProc = linkIn('lin_proc_mode', TAB_PROC, 'cmd:ps-mode', LANE_X[0], 480, [], ['ps_dash_station']);
const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand', LANE_X[0], 540, [], ['ps_dash_station']);
const linSetupMode = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 420, [], ['ps_dash_station']);
const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']);
nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow);
/* L2 level sensor with simulator. */
const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor',
LANE_X[2], 700, [[], [], ['ps_dash_station']]);
nodes.push(levelMeas);
nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num',
LANE_X[0], 700, ['ps_dash_meas_level']));
/* L3 pumps. */
const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
LANE_X[3], 320, [[], [], ['ps_dash_mgc']]);
const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
LANE_X[3], 400, [[], [], ['ps_dash_mgc']]);
nodes.push(pumpA, pumpB);
/* L4 MGC. */
const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group',
LANE_X[4], 360, [[], [], ['ps_dash_station']]);
nodes.push(mgc);
/* L5 pumpingStation. */
const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station',
LANE_X[5], 520, [['ps_dash_trend_split'], [], []]);
nodes.push(station);
/* L6 trend-split fn: one output per chart + one output for the status text widgets.
* Outputs:
* 0 -> chart_flow ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h})
* 1 -> chart_level ({topic: 'Level', payload: m})
* 2 -> chart_volpct ({topic: 'Volume%', payload: %})
* 3 -> text_status (compact state string)
* 4 -> text_perc (percControl)
* 5 -> text_direction (direction)
* 6 -> text_timetoempty(timeToEmpty)
*/
const trendCode =
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
"function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
"const flowIn = pick('flow.predicted.in');\n" +
"const flowOut = pick('flow.predicted.out');\n" +
"const level = pick('level.predicted.atequipment');\n" +
"const volPct = Number(cache.volumePercent);\n" +
"const ts = Date.now();\n" +
"const flowMsgs = [];\n" +
"if (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\n" +
"if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" +
"const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" +
"const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" +
"const volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" +
"const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" +
"const percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" +
"const dirStr = cache.direction || 'n/a';\n" +
"const tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" +
"return [\n" +
" flowOut1,\n" +
" levelOut,\n" +
" volOut,\n" +
" { payload: stateStr },\n" +
" { payload: percStr },\n" +
" { payload: dirStr },\n" +
" { payload: tEmpty }\n" +
"];";
const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode,
LANE_X[6], 520,
[
['lout_evt_flow'],
['lout_evt_level'],
['lout_evt_volpct'],
['lout_evt_state'],
['lout_evt_perc'],
['lout_evt_dir'],
['lout_evt_tempty'],
], 7);
nodes.push(trendSplit);
/* L7 link-outs into the Dashboard UI tab. */
const loutFlow = linkOut('lout_evt_flow', TAB_PROC, 'evt:flow', LANE_X[7], 420, ['lin_ui_flow']);
const loutLevel = linkOut('lout_evt_level', TAB_PROC, 'evt:level', LANE_X[7], 460, ['lin_ui_level']);
const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']);
const loutState = linkOut('lout_evt_state', TAB_PROC, 'evt:state', LANE_X[7], 540, ['lin_ui_state']);
const loutPerc = linkOut('lout_evt_perc', TAB_PROC, 'evt:perc', LANE_X[7], 580, ['lin_ui_perc']);
const loutDir = linkOut('lout_evt_dir', TAB_PROC, 'evt:dir', LANE_X[7], 620, ['lin_ui_dir']);
const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']);
nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty);
/* Process tab groups. */
const procStationIds = ['ps_dash_station', 'ps_dash_trend_split',
'lin_proc_mode', 'lin_proc_demand', 'lin_proc_setupmode', 'lin_proc_setupinflow',
'lout_evt_flow', 'lout_evt_level', 'lout_evt_volpct', 'lout_evt_state', 'lout_evt_perc', 'lout_evt_dir', 'lout_evt_tempty'];
const procPumpAIds = ['ps_dash_pump_a'];
const procPumpBIds = ['ps_dash_pump_b'];
const procMgcIds = ['ps_dash_mgc'];
const procLevelIds = ['ps_dash_meas_level', 'ps_dash_inj_level'];
nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25)));
nodes.push(group('ps_dash_grp_pa', TAB_PROC, 'Pump A (EM)', S88.EM, procPumpAIds, bboxOf(nodes, procPumpAIds, 25)));
nodes.push(group('ps_dash_grp_pb', TAB_PROC, 'Pump B (EM)', S88.EM, procPumpBIds, bboxOf(nodes, procPumpBIds, 25)));
nodes.push(group('ps_dash_grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, procMgcIds, bboxOf(nodes, procMgcIds, 25)));
nodes.push(group('ps_dash_grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, procLevelIds, bboxOf(nodes, procLevelIds, 25)));
/* ---------- Dashboard UI tab ---------------------------------- */
nodes.push(comment('ps_dash_ui_title', TAB_UI,
'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' +
'Sliders on L2 emit cmd:* back to Process Plant.\n' +
'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.',
600, 40));
/* L0 link-ins from the process side. */
nodes.push(linkIn('lin_ui_flow', TAB_UI, 'evt:flow', LANE_X[0], 220, [], ['ui_chart_flow']));
nodes.push(linkIn('lin_ui_level', TAB_UI, 'evt:level', LANE_X[0], 320, [], ['ui_chart_level']));
nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct']));
nodes.push(linkIn('lin_ui_state', TAB_UI, 'evt:state', LANE_X[0], 520, [], ['ui_text_state']));
nodes.push(linkIn('lin_ui_perc', TAB_UI, 'evt:perc', LANE_X[0], 560, [], ['ui_text_perc']));
nodes.push(linkIn('lin_ui_dir', TAB_UI, 'evt:dir', LANE_X[0], 600, [], ['ui_text_dir']));
nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty']));
/* L4 charts and text widgets. */
nodes.push(uiChart('ui_chart_flow', TAB_UI, 'ps_dash_grp_trend', 'Flow trend', 'Flow (m³/h)', 1, 'm³/h', LANE_X[4], 220));
nodes.push(uiChart('ui_chart_level', TAB_UI, 'ps_dash_grp_trend', 'Level trend', 'Level (m)', 2, 'm', LANE_X[4], 320));
nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %', 'Volume (%)', 3, '%', LANE_X[4], 420));
nodes.push(uiText( 'ui_text_state', TAB_UI, 'ps_dash_grp_status','State', 'Station state',1, LANE_X[4], 520));
nodes.push(uiText( 'ui_text_perc', TAB_UI, 'ps_dash_grp_status','percControl', 'Control %', 2, LANE_X[4], 560));
nodes.push(uiText( 'ui_text_dir', TAB_UI, 'ps_dash_grp_status','direction', 'Direction', 3, LANE_X[4], 600));
nodes.push(uiText( 'ui_text_tempty', TAB_UI, 'ps_dash_grp_status','timeToEmpty', 'Time to empty',4, LANE_X[4], 640));
/* L2 controls: dropdown for mode + slider for demand. */
const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl',
'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode',
['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']);
const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl',
'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5);
nodes.push(modeDropdown, demandSlider);
// Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation).
demandSlider.wires = [['ui_wrap_demand']];
/* L4 wrappers: enforce the canonical topic on the outgoing msg. */
const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode',
"msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
LANE_X[4], 160, [['lout_cmd_mode']]);
const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand',
"msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
LANE_X[4], 220, [['lout_cmd_demand']]);
nodes.push(wrapMode, wrapDemand);
/* L7 link-outs to the process plant. */
nodes.push(linkOut('lout_cmd_mode', TAB_UI, 'cmd:ps-mode', LANE_X[7], 160, ['lin_proc_mode']));
nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand']));
/* UI tab groups (mirror the dashboard groups). */
const uiCtrlIds = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand',
'lout_cmd_mode', 'lout_cmd_demand'];
const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty',
'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty'];
const uiTrendIds = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct',
'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct'];
nodes.push(group('grp_ui_ctrl', TAB_UI, 'Controls (PC)', S88.PC, uiCtrlIds, bboxOf(nodes, uiCtrlIds, 25)));
nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)', S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25)));
nodes.push(group('grp_ui_trend', TAB_UI, 'Live trends (PC)', S88.PC, uiTrendIds, bboxOf(nodes, uiTrendIds, 25)));
/* ---------- Setup tab ----------------------------------------- */
nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' +
'Initialises set.mode = levelbased and seeds an inflow at deploy time.',
LANE_X[2], 40));
nodes.push(inject('ps_dash_setup_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str',
LANE_X[0], 160, ['ps_dash_lout_setup_mode'], { once: true, onceDelay: '0.5' }));
nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num',
LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0' }));
nodes.push(linkOut('ps_dash_lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_proc_setupmode']));
nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow']));
const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow',
'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow'];
nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
return nodes;
}
/* ------------------------------------------------------------------ */
/* README */
/* ------------------------------------------------------------------ */
const README = `# pumpingStation - Example Flows
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`,
\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases
(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`,
\`calibratePredictedLevel\`, \`registerChild\`) still work but log a
one-time deprecation warning; these fresh flows use the canonical names only.
## Files
| File | Tier | Tabs | Purpose |
|---|---|---|---|
| \`01-Basic.json\` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
| \`02-Integration.json\` | 2 | Process Plant + Setup | Adds a \`measurement\` level child and a \`machineGroupControl\` parent with two \`rotatingMachine\` pumps. Demonstrates the Phase-2 parent/child handshake. |
| \`03-Dashboard.json\` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
## Prerequisites
- Node-RED with the EVOLV package installed (so the \`pumpingStation\`,
\`measurement\`, \`machineGroupControl\`, and \`rotatingMachine\` node
types are registered).
- For \`03-Dashboard.json\`: \`@flowfuse/node-red-dashboard\` (Dashboard 2.0).
## How to load
\`\`\`bash
# Drop a file into a running Node-RED instance using its Admin API.
curl -X POST -H 'Content-Type: application/json' \\
--data @nodes/pumpingStation/examples/01-Basic.json \\
http://localhost:1880/flows
\`\`\`
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
import into their own tabs and can be deployed immediately.
## 01-Basic - what to try
1. Deploy.
2. Inject \`set.mode = manual\`.
3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the
formatted Port 0 payload in the debug sidebar.
4. Inject \`set.demand = 40 %\` - in manual mode this would feed any
registered children; here there are no pump children so it is logged
and shown on Port 0.
5. Inject \`cmd.calibrate.volume = 25 m3\` to jump the predicted-volume
integrator to half-full.
## 02-Integration - what to try
1. Deploy. The Setup tab fires \`set.mode = levelbased\` to the station
and \`set.mode = auto\` to the MGC.
2. The two pumps register with the MGC via Port 2; the MGC and the level
sensor register with the station via Port 2. Watch the registration
debug taps to confirm.
3. The level inject pushes a 1.6 m measurement so the station sees a
non-zero starting level. Setup also seeds \`set.inflow = 60 m3/h\`.
4. The station's \`controlMode = levelbased\` then drives the MGC, which
dispatches to Pump A / Pump B.
## 03-Dashboard - what to try
1. Deploy.
2. Open the dashboard at \`http://localhost:1880/dashboard/page/pumping-station\`.
3. Use the **Control mode** dropdown to switch between \`manual\`,
\`levelbased\`, \`flowbased\`, \`none\`.
4. In manual mode, drag the **Manual demand** slider - the demand cascades
to the MGC and on to the pumps.
5. The three charts (flow, level, volume %) plot live data; the four text
widgets show state, percControl, direction, and time-to-empty.
## Layout conventions
These flows follow the EVOLV layout rule set in
\`.claude/rules/node-red-flow-layout.md\`:
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
(\`ui-*\` widgets) / Setup (once-true injects).
- Cross-tab wiring via **named link out / link in channels**:
\`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`,
\`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`,
\`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`.
- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`,
driven by each node's S88 level (Process Cell on L5, Unit on L4,
Equipment on L3, Control Module on L2).
- **Group boxes** wrap each parent + its direct children, coloured by the
parent's S88 level.
## Regenerating
These flows are generated from \`tools/build-examples.js\`. Edit the
generator, never the JSON, then:
\`\`\`bash
node nodes/pumpingStation/tools/build-examples.js
\`\`\`
The script writes \`01-Basic.json\`, \`02-Integration.json\`, and
\`03-Dashboard.json\` into this directory.
`;
/* ------------------------------------------------------------------ */
/* Main */
/* ------------------------------------------------------------------ */
function writeFlow(filename, builder) {
const flow = builder();
const dest = path.join(OUT_DIR, filename);
fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8');
console.log(`wrote ${dest} (${flow.length} nodes)`);
}
function main() {
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
writeFlow('01-Basic.json', buildBasic);
writeFlow('02-Integration.json', buildIntegration);
writeFlow('03-Dashboard.json', buildDashboard);
fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8');
console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`);
}
main();

285
wiki/Home.md Normal file
View File

@@ -0,0 +1,285 @@
# pumpingStation
> **Reflects code as of `d2384b1` · regenerated `<YYYY-MM-DD>` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is
**pumpingStation** is an S88 Process Cell that owns a wet-well basin and orchestrates the pumps that drain it. It tracks measured + predicted volume, evaluates safety interlocks (dry-run, overfill), and dispatches a control strategy that hands a demand setpoint to one or more downstream machine groups or individual pumps.
## 2. Position in the platform
```mermaid
flowchart LR
ps[pumpingStation<br/>Process Cell]:::pc
meas_lvl[measurement<br/>type=level<br/>position=atequipment]:::ctrl
meas_in[measurement<br/>type=flow<br/>position=upstream]:::ctrl
mgc[machineGroupControl<br/>Unit]:::unit
meas_lvl -.data.-> ps
meas_in -.data.-> ps
ps -->|set.demand| mgc
mgc -.evt.flow-predicted.-> ps
mgc -->|child.register| ps
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| Predicts basin volume from net flow | ✅ | Integrator seeded from `basin.minVol`; recomputes level. |
| Accepts measured level / volume / pressure | ✅ | Routed via `measurementRouter` on child registration. |
| Level-based control strategy | ✅ | Linear or log ramp between `minLevel` and `maxLevel`. |
| Flow-based control strategy | ✅ | PID against `flowSetpoint`. |
| Manual demand passthrough | ✅ | `set.demand` only honoured in `manual` mode. |
| Dry-run safety interlock | ✅ | Stops downstream pumps when volume < `minVol` while draining. |
| Overfill safety interlock | ✅ | Stops upstream equipment when volume crosses overfill threshold. |
| Cascaded children (sub-stations) | ⚠️ | Accepted via `pumpingstation` softwareType but not exercised in production. |
## 4. Code map
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands<br/>static tickInterval = 1000ms"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["PumpingStation.configure()<br/>declares ChildRouter rules<br/>tick() → safety → control"]
end
subgraph concerns["src/ concern modules"]
basin["basin/<br/>BasinGeometry + thresholdValidator"]
measurement["measurement/<br/>flowAggregator + router + calibration"]
control["control/<br/>levelbased / flowbased / manual"]
safety["safety/<br/>SafetyController"]
commands["commands/<br/>topic registry + handlers"]
end
nc --> sc
sc --> basin
sc --> measurement
sc --> control
sc --> safety
nc --> commands
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `basin/` | Geometry, volume↔level conversion, threshold ordering | Capacity, level↔volume math, fill %. |
| `measurement/` | Net-flow aggregation, predicted-volume integrator, calibration | Predicted volume / time-to-full. |
| `control/` | Control strategy dispatch (`levelbased`, `flowbased`, `manual`) | Demand calculation, mode behaviour. |
| `safety/` | Dry-run + overfill rules, pump-shutdown side-effects | Safety envelope, alarm reactions. |
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
## 5. Topic contract
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Effect |
|---|---|---|---|
| `set.mode` | `changemode` | `string` | Replaces the named state value with the supplied payload. |
| `child.register` | `registerChild` | `string` | Parent/child plumbing — registers or unregisters a child node. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | `any` | Triggers an action / sequence — not idempotent. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | `any` | Triggers an action / sequence — not idempotent. |
| `set.inflow` | `q_in` | `any` | Replaces the named state value with the supplied payload. |
| `set.demand` | `Qd` | `any` | Replaces the named state value with the supplied payload. |
<!-- END AUTOGEN: topic-contract -->
## 6. Child registration
Mirrors the `ChildRouter` declarations in `specificClass.js → configure()`.
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m["measurement"]:::ctrl
mach["machine<br/>(rotatingMachine)"]:::equip
mgc["machinegroup"]:::unit
sub["pumpingstation<br/>(sub-station)"]:::pc
end
m -->|"&lt;type&gt;.measured.&lt;position&gt;"| route1[_subscribeMeasurement<br/>routes to measurementRouter]
mach -->|flow.predicted.&lt;in or out&gt;| route2[_subscribePredictedFlow<br/>+ flowAggregator]
mgc -->|flow.predicted.&lt;in or out&gt;| route2
sub -->|flow.predicted.&lt;in or out&gt;| route2
route1 --> tick[tick]
route2 --> tick
classDef ctrl fill:#a9daee,color:#000
classDef equip fill:#86bbdd,color:#000
classDef unit fill:#50a8d9,color:#000
classDef pc fill:#0c99d9,color:#fff
```
| softwareType | onRegister side-effect | Subscribed events |
|---|---|---|
| `measurement` | `_subscribeMeasurement(child)` — registers in MeasurementContainer. | `<type>.measured.<position>` for any type (pressure, level, flow, …). |
| `machine` | Stored in `this.machines[id]`. **Skipped when a machineGroup parent is present** to avoid double-counting. | `flow.predicted.<in|out>` per the child's `positionVsParent`. |
| `machinegroup` | Stored in `this.machineGroups[id]`. | `flow.predicted.<in|out>`. |
| `pumpingstation` | Stored in `this.stations[id]`. | `flow.predicted.<in|out>`. |
## 7. Lifecycle — what one tick does
```mermaid
sequenceDiagram
participant child as measurement / pump child
participant ps as pumpingStation
participant fa as flowAggregator
participant sf as safetyController
participant ctl as control strategy
participant out as Port-0 output
child->>ps: data event (measured.level / flow.predicted.out)
ps->>ps: ChildRouter dispatches to handler
Note over ps: every 1000 ms (static tickInterval)
ps->>fa: tick() — net flow, ETA, predicted volume
ps->>sf: evaluate({direction, secondsRemaining})
alt safety blocked
sf-->>ps: blocked=true, reason
Note over ctl: skipped this tick
else safety clear
ps->>ctl: dispatch(mode, ctx, controlState)
ctl-->>ps: percControl updated
end
ps->>ps: notifyOutputChanged()
ps->>out: msg{topic, payload (delta-compressed)}
```
## 8. Data model — `getOutput()`
What lands on Port 0. Built in `getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `direction` | string | — | `"steady"` |
| `flowSource` | null | — | `null` |
| `heightBasin` | number | m | `1` |
| `inflowLevel` | number | m | `2` |
| `maxVol` | number | m3 | `1` |
| `maxVolAtOverflow` | number | m3 | `2.5` |
| `minHeightBasedOn` | string | — | `"outlet"` |
| `minVol` | number | m3 | `0.2` |
| `minVolAtInflow` | number | m3 | `2` |
| `minVolAtOutflow` | number | m3 | `0.2` |
| `outflowLevel` | number | m | `0.2` |
| `overflowLevel` | number | m | `2.5` |
| `percControl` | number | % | `0` |
| `surfaceArea` | number | m2 | `1` |
| `timeleft` | null | s | `null` |
| `volEmptyBasin` | number | m3 | `1` |
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
<!-- END AUTOGEN: data-model -->
The `<nodeId>` segment of the MeasurementContainer key is the Node-RED node id assigned at deploy time; auto-gen substitutes a placeholder stub.
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Basin: volume / height]
f2[Levels: inflow / outflow / overflow]
f3[Control mode]
f4[Level setpoints: min / start / max]
f5[Safety: dry-run % / overfill %]
end
subgraph config["Domain config slice"]
c1[basin.volume<br/>basin.height]
c2[basin.inflowLevel<br/>basin.outflowLevel<br/>basin.overflowLevel]
c3[control.mode]
c4[control.levelbased.minLevel<br/>control.levelbased.startLevel<br/>control.levelbased.maxLevel]
c5[safety.dryRunThresholdPercent<br/>safety.overfillThresholdPercent]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| `basinVolume` | `basin.volume` | `1` | > 0 (m³) | `BasinGeometry` |
| `basinHeight` | `basin.height` | `1` | > 0 (m) | `BasinGeometry` |
| `inflowLevel` | `basin.inflowLevel` | `2` | ≥ 0 (m) | threshold validator, control |
| `outflowLevel` | `basin.outflowLevel` | `0.2` | ≥ 0 (m) | dead-volume floor |
| `overflowLevel` | `basin.overflowLevel` | `2.5` | > 0 (m) | overfill safety |
| `controlMode` | `control.mode` | `levelbased` | enum | `control/dispatch` |
| `minLevel` | `control.levelbased.minLevel` | `1` | ≥ 0 (m) | `levelBased.run` |
| `startLevel` | `control.levelbased.startLevel` | `1` | ≥ minLevel | ramp foot |
| `maxLevel` | `control.levelbased.maxLevel` | `4` | ≤ overflowLevel | ramp top |
| `enableDryRunProtection` | `safety.enableDryRunProtection` | `true` | bool | `SafetyController` |
| `dryRunThresholdPercent` | `safety.dryRunThresholdPercent` | `2` | 0100 % | dry-run trip |
| `enableOverfillProtection` | `safety.enableOverfillProtection` | `true` | bool | overfill safety |
| `overfillThresholdPercent` | `safety.overfillThresholdPercent` | `98` | 0100 % | overfill trip |
## 10. State chart
Two orthogonal state vectors: **control mode** (operator-driven) and **safety state** (data-driven). The diagram shows them together — most transitions are independent.
```mermaid
stateDiagram-v2
state ControlMode {
[*] --> none
none --> levelbased: set.mode
levelbased --> flowbased: set.mode
flowbased --> manual: set.mode
manual --> levelbased: set.mode
levelbased --> none: set.mode
}
state SafetyState {
[*] --> nominal
nominal --> dryRun: vol < minVol AND draining
nominal --> overfill: vol > overfillThreshold AND filling
dryRun --> nominal: vol ≥ minVol
overfill --> nominal: vol ≤ overfillThreshold
}
```
While the safety state is `dryRun`, control dispatch is **skipped** entirely. While `overfill`, control still runs (pumps must keep draining) but upstream equipment is shut down.
## 11. Examples
Example flows live under `examples/` in the repo. The structured tier-1/2/3 flows for this node are still in progress; until they land, the standalone simulator demo is the only runnable artefact.
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/01-Basic.flow.json` | Inject + dashboard, single basin, no parent | ⏳ TBD |
| Integration | `examples/02-Integration.flow.json` | pumpingStation + MGC + 2 pumps + measurement children | ⏳ TBD |
| Dashboard | `examples/03-Dashboard.flow.json` | Live FlowFuse charts (level, net flow, ETA) | ⏳ TBD |
| Headless | `examples/standalone-demo.js` | Node.js-only simulator, no Node-RED | ✅ in repo |
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| Status badge stuck on `❔ 0.0%` | Did any volume / level measurement register? Watch Port 2 + first-child event. | Editor debug tap on Port 2 + `_subscribeMeasurement` log line. |
| `direction` always `steady` | Net flow inside `general.flowThreshold` dead-band (default 0.0001 m³/s). | `flowAggregator.deriveDirection`. |
| `set.demand` ignored | Mode isn't `manual`. Check `set.mode` history. | `handlers.setDemand` debug log. |
| Predicted volume drifts off measured | Calibration needed — fire `cmd.calibrate.volume` with a known reading. | `measurement/calibration.js`. |
| Pumps don't stop on dry-run | `safety.enableDryRunProtection` must be `true` AND the orchestrator must see `direction='draining'`. | `SafetyController.evaluate`. |
| Threshold-ordering warnings on startup | `validateThresholdOrdering` printed `inflowLevel < overflowLevel` style violations. | `basin/thresholdValidator.js`. |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
## 13. When you would NOT use this node
- Use pumpingStation for a **wet-well basin** that needs orchestrated drainage. For a single pump with no basin model, use `rotatingMachine` directly.
- Don't use pumpingStation to schedule a fixed pump rota — its modes are reactive (level / flow / manual). Use an external scheduler if you need a calendar-driven schedule.
- Skip pumpingStation if you don't need predicted volume / time-to-full. A bare `machineGroupControl` is lighter when the upstream basin is modelled elsewhere.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Cascaded `pumpingstation` children accepted but not exercised in production — semantics of nested stations are not test-covered. | TBD |
| 2 | `pressureBased`, `percentageBased`, `powerBased`, and `hybrid` are in the config enum but not implemented as control strategies. | `control/index.js` — only `levelbased` / `flowbased` / `manual` dispatched. |
| 3 | Predicted-volume integrator can drift over long horizons without a measured-level calibration source. | `cmd.calibrate.volume` is operator-triggered, not automatic. |
| 4 | Tier 1/2/3 example flows not yet written — current `examples/` only contains the standalone simulator. | P2.14 (Docker E2E) + P9 wiki cleanup. |

View File

@@ -51,9 +51,10 @@ Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up
| Diagram | Shows |
|---|---|
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
| `modes/basin-mode-level-linear` | Level-linear control mode — `startLevel`, demand ramp, threshold-shift behaviour |
| `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 overfill rule asymmetry — which children stop, which keep running |
| `safety-rules` | Dry-run vs high-volume safety rule asymmetry — which children stop, which keep running |
## Making a brand-new diagram

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 271 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

@@ -79,7 +79,7 @@ The current runtime still uses the level fields directly for its volume math. Pi
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
| **Low Volume Threshold (%)** | `2` | Safety margin above the configured minimum volume: `dryRunSafetyVol = minVol × (1 + pct/100)`. This creates `dryRunLevel`; it is derived, not a separately entered basin height. |
| **Enable Overfill Protection** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
| **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
@@ -152,12 +152,18 @@ Delta-compressed payload (only changed fields per tick). Keys follow the standar
| `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.
@@ -178,9 +184,9 @@ The basin is modelled as a rectangular prism with constant cross-section. Everyt
*Editable source: [`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg) (drag into draw.io; the SVG embeds the editable source). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel ≤ minLevel < inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel < inflowLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
`startLevel` is deliberately not part of this generic basin diagram. It belongs to a control mode. For the current level-linear mode, see [`diagrams/modes/basin-mode-level-linear.drawio.svg`](diagrams/modes/basin-mode-level-linear.drawio.svg).
`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:
@@ -215,6 +221,23 @@ The high-volume safety point exists so the station can still react before the ba
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`):
@@ -245,7 +268,7 @@ flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
The `pumpingStation` supports multiple control modes. Each mode is a **policy that maps basin state to demand (0-100 %)**. `levelbased` uses `minLevel`, `startLevel`, and `maxLevel`; other modes may use different thresholds or compute them dynamically.
The basin model owns the shared physical and safety references: pipe elevations, `dryRunLevel`, `highVolumeSafetyLevel`, and `overflowLevel`. `startLevel` is mode-specific and is documented with the mode diagrams, not the generic basin drawing.
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:
@@ -261,7 +284,7 @@ See [`modes/README.md`](modes/README.md) for the index and page template.
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *high-volume protection tries to preserve distance to actual overflow*.
![Safety rules — dry-run vs overfill](diagrams/safety-rules.drawio.svg)
![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`.
@@ -319,8 +342,10 @@ The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pump
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |
| Pumps keep running during overfill | Intended — overfill safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
| 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

View File

@@ -7,7 +7,7 @@ updated: 2026-04-22
# Level-based mode
The simplest and most widely deployed control strategy. Demand is a direct, *static* piecewise-linear function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
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
@@ -20,9 +20,9 @@ The simplest and most widely deployed control strategy. Demand is a direct, *sta
## Diagram
![Level-linear basin mode — demand vs level transfer function](../diagrams/modes/basin-mode-level-linear.drawio.svg)
![Level-linear basin mode — demand vs level transfer function](../diagrams/modes/level-based/basin-mode-level-linear.drawio.svg)
*Editable source: [`../diagrams/modes/basin-mode-level-linear.drawio.svg`](../diagrams/modes/basin-mode-level-linear.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — it round-trips).*
*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
@@ -30,10 +30,11 @@ The simplest and most widely deployed control strategy. Demand is a direct, *sta
|---|---|---|
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
| `config.control.levelbased.startLevel` | editor, static | where demand-ramp starts |
| `config.control.levelbased.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 are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
The three control thresholds plus curve type are the mode-specific configuration. Nothing here is recomputed at runtime.
## Threshold policy
@@ -42,6 +43,7 @@ The three control thresholds are the **only** mode-specific configuration. Nothi
| `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.
@@ -51,15 +53,15 @@ That this policy is trivial (all static) is **the defining simplicity of this mo
if level < minLevel:
demand = 0
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
elif level < startLevel:
demand = <previous demand> # dead zone — hold last command (hysteresis)
elif level <= maxLevel:
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
elif direction == filling:
demand = curve(level, [inflowLevel, maxLevel], [0 %, 100 %])
elif direction == draining:
demand = curve(level, [startLevel, maxLevel], [0 %, 100 %])
else:
demand = 100 % # saturated; MGC clamps internally if overshoot
demand = previous demand
```
Where `lerp` is linear interpolation. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
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

View File

@@ -67,11 +67,11 @@ demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
demand = min(rawDemand, demandCap)
```
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence.
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 overfill safety trips, it overrides the clip (safety wins).
- **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.