Compare commits

29 Commits

Author SHA1 Message Date
znetsixe
a83a85e958 fix(ps): persist stopLevel/holdLevel as numbers across editor save
Node-RED's auto-form-binding writes <input type="number"> values into the
node object as strings. The editor's setNumberField helper used strict
Number.isFinite(val) which rejects "0.5" and blanked the input on reopen,
so users saw their stopLevel/holdLevel values disappear after clicking Done.

- oneditsave: explicitly parseFloat stopLevel, holdLevel, and
  deadZoneKeepAlivePercent so they land in the node as numbers (matches the
  treatment of startLevel/maxLevel).
- oneditprepare: parseFloat node.holdLevel / node.deadZoneKeepAlivePercent
  before the Number.isFinite check so existing string-typed flows still
  render their saved values.
- index.js setNumberField: defensively coerce stringy numbers so this
  gotcha can't bite a future field.

Verified end-to-end in headless Chromium: type new values, click Done,
reopen — values persist and the stopLevel/holdLevel marker lines render
at the correct x in the level-based mode preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:21:59 +02:00
znetsixe
e041877ae4 fix(ps): keep canonical flow in m³/s, emit output in m³/h
Reverts the canonical half of 8216480 (which set BOTH canonical and output
to m³/h) back to the platform-wide m³/s convention. Canonical m³/s is what
every cross-node consumer assumes — MGC percent→flow demand interpolation,
the volume integrator (flow × dt), and physics-sanity balances. Changing the
canonical basis to m³/h silently scaled those by 3600×.

Output flow / netFlowRate stay m³/h so telemetry and dashboard series remain
on the same axis as the rest of the pump group (verified slice #47). The
m³/s→m³/h conversion now happens at the output boundary only, never on the
internal integrator basis.

No smoothing/hysteresis added for the PS→MGC demand hunting: per design
review that belongs in a dedicated intermediate node (e.g. a PID), not in
the pumpingStation or machineGroupControl control path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:31:39 +02:00
znetsixe
8216480950 change(ps): emit flow in m³/h (canonical + output)
Switch pumpingStation flow unit from m³/s to m³/h for canonical and output
so telemetry/dashboard series land on the same axis as the rest of the
group. NOTE: diverges from the platform-wide m³/s canonical convention —
flagged for review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:27 +02:00
znetsixe
dfaa0c3ae8 feat(pumpingstation): warn when control engages with no machine group registered
A station engaged above startLevel computes a real demand, but if no machine
group is registered (e.g. the Port 2 parent↔group registration was dropped by a
partial redeploy) the demand is silently forwarded nowhere and the pumps never
react — invisible to the operator. levelBased now warns once when engaged with
an empty machineGroups map (throttled via host._warnedNoMachineGroup, re-arms
when a group reappears); manual.forwardDemand warns when neither a group nor a
direct machine is registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:58:34 +02:00
znetsixe
6e727d929b fix(pumpingstation): replay child measurement value on subscribe
A measurement child that already holds a value when the pumpingStation
registers it (e.g. a once:true inject that fired during startup before the
parent subscribed) was never surfaced — the emitter only delivers future
updates. _subscribeMeasurement now seeds from the child's current sample via
getLaggedSample(0), so late subscribers pick up present state. This is what
makes a measured upstream inflow register as inflow on a clean startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:45:44 +02:00
ef07f2a5b2 wip: pre-ship-it state — example dashboard tweaks 2026-05-26 17:31:44 +02:00
znetsixe
2d68a4f504 test: rewire integration test to renamed 02-Dashboard.json
Example flows were renamed to the numbered-tier convention
(02-Dashboard.json). The integration test still loaded the old
basic-dashboard.flow.json and asserted the old 6-output parser shape
+ raw-number payloads. Update both the filename and the assertions
to match the current 14-output fn_status_split (topic labels like
'Level', payload strings like '3.25 m').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:30:02 +02:00
znetsixe
a3536b7b7f fix(level): pass timestamp on level samples for level-rate fallback
MeasurementRouter.onLevelMeasurement was writing level samples via
.value(value).unit(context.unit), which dropped the timestamp. The
level-rate fallback in FlowAggregator derives netFlow from dlevel/dt,
so without a timestamp on each sample it had nothing to differentiate.

Switch to the positional .value(value, timestamp, unit) form so the
fallback works. Add a basic test that drives two level samples 2 s
apart and asserts the aggregator produces direction=filling with a
finite dlevel/dt-derived netFlow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:56 +02:00
znetsixe
f5c6282478 refactor(units): use UnitPolicy.convert instead of hardcoded m3/h<->m3/s scalars
Replace the M3H_TO_M3S constant in control/manual.js and the `* 3600`
inline conversion in the status badge with this.unitPolicy.convert
calls. Expose unitPolicy on the frozen control context so manual
strategies pick it up without reaching into host. Matches the
contract direction in .claude/refactor/CONTRACTS.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:43:35 +02:00
znetsixe
df18e97b8b style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:06:00 +02:00
znetsixe
2e4ad8d3f1 fix(levelBased): drop hold zone, route through MGC.setDemand, add holdLevel + integrator variant pick; slim npm pack
levelBased ramp + engagement:
- Ramp foot is now max(startLevel, holdLevel) — was max(startLevel,
  inflowLevel). inflowLevel is basin geometry, not a control setpoint;
  the implicit hold zone it created was causing pumps to "start at
  inflowLevel" instead of startLevel.
- New optional `holdLevel` config (defaults to startLevel = no hold band).
  When raised, pumps engage at startLevel and hold at 0 % = MGC flow.min
  across [startLevel, holdLevel], then ramp 0..100 % to maxLevel.
- Engagement decided in run() (not in `_applyMachineGroupLevelControl`):
  rising-edge hysteresis arming gates a clean turnOff early-return.
  Once armed, the helper always forwards setDemand(pct, '%') — 0 %
  legitimately means "engaged at min flow", no more soft-turnOff at
  the boundary.
- Disengagement paths (minLevel hard-stop, stopLevel falling-edge,
  pre-arming idle) now all clear the shifted-ramp hysteresis state too.
- Threshold validator drops the startLevel ≤ inflowLevel rule; adds
  startLevel ≤ holdLevel < maxLevel (only checked when holdLevel is
  explicitly set, so default-null doesn't false-flag).

MGC unit math:
- Replace direct group.handleInput(percent) with group.setDemand(pct, '%')
  in _applyMachineGroupLevelControl. The percent → m³/s resolution now
  lives in MGC.setDemand (committed separately in the MGC submodule).

FlowAggregator variant picking:
- New _pickFlowSum() helper mirrors selectBestNetFlow's variant
  precedence (measured first, then predicted) and resolves each side
  independently. Realistic mixed case — real measured upstream sensor +
  predicted pump outflow — now feeds the predicted-volume integrator.
  Was reading only `flow.predicted.*` so a real upstream sensor
  (which writes `flow.measured.*`) never moved the level.

Editor:
- New `holdLevel` and `deadZoneKeepAlivePercent` defaults + side-panel
  input rows in the levelbased mode preview.
- Add the missing `ps-mode-line-holdLevel` SVG marker (was declared in
  the side-panel coupling but the SVG element didn't exist, so the
  dashed line never rendered).
- Relax stopLevel marker gate so it renders for any non-negative typed
  value — start/stop ordering is the ribbon's job, not the marker's
  (was hiding the line whenever startLevel was momentarily smaller).
- Add holdLevel to the marker loop in mode-preview so changes track.
- Add stopLevel + holdLevel + maxLevel to all three bindRedraw lists
  (basin-diagram, mode-preview, bounds.apply) so the SVG, validation
  ribbon, and HTML5 min/max attrs update on every edit.
- Initialise stopLevel + holdLevel + deadZoneKeepAlivePercent inputs
  in oneditprepare so reopening the editor shows the saved values.
- nodeClass passes holdLevel + deadZoneKeepAlivePercent into the
  domain config.

Tests:
- New test/basic/_probe_upstream_emit.test.js: confirms the parent
  surfaces flow.measured.upstream.* on Port 0 after a measurement
  child write — pins the previously-invisible measured variant flow.
- flowAggregator.basic.test.js: two new regression cases — measured
  inflow when predicted side is empty, and the measured-in /
  predicted-out mixed case.
- control-levelBased.basic.test.js: new cases for the holdLevel hold
  band, the [stopLevel, startLevel] keep-alive, the engagement gate,
  and the "0 % at startLevel = setDemand" contract.
- specificClass.test.js: zone tests adjusted to the new ramp foot.
  Shifted-ramp tests pin holdLevel = 3 explicitly so their legacy
  arithmetic (ramp foot at inflowLevel) stays self-consistent.
- shifted-ramp-end-to-end.test.js: same holdLevel pin for the same
  reason.

Packaging:
- Add .gitignore + .npmignore so the published tarball drops the
  wiki/, simulations/, test/, tools/, .claude/ etc. The pack went
  from 1.5 MB (72 files) to ~57 KB (30 files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:36:29 +02:00
znetsixe
d4de3cf5c5 docs(wiki): regenerate Reference-Contracts.md via wiki-gen — formatting
Catches up the committed file with the @evolv/wiki-gen tool's current
canonical output (bare `any` for payload type, no backticks on `any`).
Brings HEAD in line with `wiki-gen --check` so CI doesn't trip on this
file going forward. Content semantics unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:12:34 +02:00
znetsixe
304df7f135 fix(CONTRACT): add set.outflow row — registered topic was missing
Registry's `set.outflow` (alias `q_out`) pushes a measured outflow into
the basin balance. CONTRACT.md documented `set.inflow` but not its
outflow twin; contract-verify required both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:14 +02:00
znetsixe
03440e1e6c docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:27 +02:00
znetsixe
2c7fe1792f ps: setDemand reads unit-normalised payload from commandRegistry
generalFunctions' commandRegistry._normaliseUnits now converts {value, unit}
or unit-tagged payloads to the descriptor's default unit (m3/h for set.demand)
before the handler runs. setDemand just reads Number(payload) — no inline
unit-conversion, no scaling state. Matches the same shift done in MGC for
unit-self-describing demand commands.

Pre-existing test failure: test/integration/basic-dashboard-flow.test.js
references examples/basic-dashboard.flow.json which was renamed to
02-Dashboard.json in commit fe5fa35 (feat(pumpingStation): … dashboard
example). The 3 stale-path failures are unrelated to this commit — they
were broken before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:51:42 +02:00
znetsixe
6e89e4916f wiki: restore GIF placeholders after removing 01-basic-demo.gif
Re-adds the "GIF needed" callouts in Home.md and Reference-Examples.md so
the missing media is tracked instead of a broken image link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:57:06 +02:00
znetsixe
285fd01a5d wiki: drop 52 MB 01-basic-demo.gif from repo
To be re-added once compressed (target ≤ 1 MB via gifsicle -O3 --lossy=80
or converted to MP4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:56:28 +02:00
znetsixe
fe5fa3577b feat(pumpingStation): realistic defaults, ramp-foot visual fix, manual-mode visibility, dashboard example
Editor + schema defaults
- pumpingStation.html: drag-in defaults now reflect a realistic basin
  (volume=50 m³, height=4 m, inflowLevel=1.5, outflowLevel=0.2,
  overflowLevel=3.8, startLevel=1, stopLevel=0.5, minLevel=0.3,
  maxLevel=3.8). Old defaults left every level field null.

Visual bug fix
- src/editor/mode-preview.js: the level-based ramp curve in the editor
  was being drawn with foot=startLevel via buildPath(start, start, max).
  The runtime in control/levelBased.js has always used inflowLevel as
  the ramp foot. Pass buildPath(start, upFoot, max) where upFoot falls
  back to start when inflowLevel is missing, matching the runtime.

Manual mode observability
- src/specificClass.js: store last forwarded demand on this._manualDemand;
  surface as `mode` and `manualDemand` in getOutput(); call
  notifyOutputChanged() on forwardDemandToChildren and on changeMode so
  Port 0/1 emit even with no children registered. Status badge compacted
  to `mode | dir% | net m³/h` + `Qd=X m³/h` in manual mode.

Examples cleanup
- Drop stale 02-Integration.json, 03-Dashboard.json, basic-dashboard.flow.json,
  standalone-demo.js.
- 01-Basic.json: numbered driver groups (1. Control mode … 4. Calibration),
  Debug-outputs group, fixed typos and HOW-TO-USE; Port 1 debug now active.
- New 02-Dashboard.json: FlowFuse Dashboard 2.0 with Controls (7 buttons),
  Status (7 ui-text rows), Trends (4 ui-charts: level / volume / volume% /
  flow in-out-net), Raw output (ui-template dumping every Port 0 field).
  Fan-out function pattern-matches the 4-segment measurement keys by
  prefix instead of hardcoding childId, converts flow m³/s → m³/h, and
  caches last-known values so deltas never blank a row.
- examples/README.md realigned to the two-file set.

Wiki
- Home.md: 5 image placeholders replaced with the provided screenshots
  (01-node-and-editor, 02-basic-flow, 03-wiring-standalone,
  04-wiring-integrated) and the demo GIF (01-basic-demo).
- Reference-Examples.md: shipped-files table reduced to 01-Basic +
  02-Dashboard, Example-01 section uses the screenshot + GIF, Example-02
  rewritten as Dashboard (kept screenshot/GIF callouts open for those
  captures), Example-03/Integration sections + their debug-recipes row
  removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:52:00 +02:00
znetsixe
8507ee4e02 wiki: split per-node Home into Zone A (intuitive) + Reference-* siblings
New standard, pilot pass for pumpingStation. Sets the pattern the other
10 nodes will follow once we sign off on this one.

Zone A (wiki/Home.md, ~180 lines):
- one-sentence opener
- "at a glance" 5-row fact table
- "How it looks in Node-RED" — screenshot placeholder
- "What it models" — embeds the existing basin-model.drawio.svg
- "Try it" — 3-minute demo with curl-load command, click list,
  GIF placeholder
- "Typical wiring" — two placeholder screenshots (standalone +
  integrated), no mermaid (per user direction)
- "The five things you'll send" + sample Port-0 payload table
- "Need more?" footer linking to Reference-* siblings

Zone B (4 sibling pages):
- Reference-Contracts.md  — full topic contract + data model
  (AUTOGEN markers); config schema; child registration filters;
  unit policy
- Reference-Architecture.md — 3-tier code layout; safety FSM
  (stateDiagram-v2); tick lifecycle (sequenceDiagram); output ports
- Reference-Examples.md — 01-Basic / 02-Integration / 03-Dashboard
  walk-through with per-example screenshot + GIF placeholders;
  debug-recipes table
- Reference-Limitations.md — implemented vs schema-only modes;
  basin-shape constraint; net-flow source caveat; alias-removal map

Asset directory placeholders created:
- wiki/_partial-screenshots/pumpingStation/.gitkeep
- wiki/_partial-gifs/pumpingStation/.gitkeep
- wiki/_partial-flows/pumpingStation/.gitkeep

Abandoned per user direction (no longer linked, removed from source):
- wiki/README.md
- wiki/functional-description.md (377 lines retired)
- wiki/modes/*.md (5 files retired)

Diagrams kept in place (wiki/diagrams/*.drawio.svg) — referenced from
Home and Reference-Architecture.

package.json: wiki:contract + wiki:datamodel now target
Reference-Contracts.md instead of Home.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:19:48 +02:00
znetsixe
b825ac1d6d wiki: rewrite Home.md per visual-first 14-section template
- Banner: update hash to 530f84a and date to 2026-05-11
- Section 2: add rotatingMachine to platform diagram; show full child→MGC→PS data flow
- Section 3: add no-data panic capability row; add unimplemented modes row
- Section 7: expand sequence diagram to show all three safety paths (panic / dry-run / overfill)
- Section 9: fix deprecated config keys (enableOverfillProtection → enableHighVolumeSafety,
  overfillThresholdPercent → highVolumeSafetyThresholdPercent); add missing fields
  (levelCurveType, logCurveFactor, enableShiftedRamp, stopLevel, flowSetpoint,
  timeleftToFullOrEmptyThresholdSeconds); call out deprecated aliases in note
- Section 10: add three-state safety FSM with panic branch; add effect table
- Section 11: update examples table — all three tiers now exist in repo
- Section 14: replace stale 'TBD' example-flows entry with deprecated-alias cleanup item

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:05:26 +02:00
znetsixe
530f84ae5b P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:07 +02:00
znetsixe
5f1c9ae2ff P11.5 + B2.1/B2.2: per-command units + description (where applicable)
Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:07 +02:00
znetsixe
ef81013e96 B1.2: drop legacy 'overfillLevel' alias from thresholdValidator
Decision 2026-05-11: 'highVolumeSafetyLevel' is canonical. The legacy
'overfillLevel' name is gone from computeSafetyPoints + the validator
issue tuple. 'overfillVol' parallel alias kept (out of scope for this
task; flagged for follow-up). 130/130 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:13:21 +02:00
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
65 changed files with 7012 additions and 3050 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
# in sync — anything that shouldn't be committed AND shouldn't ship in the
# npm tarball goes in both files.
node_modules/
package-lock.json
*.tgz
.env
.env.*
.DS_Store
npm-debug.log*

31
.npmignore Normal file
View File

@@ -0,0 +1,31 @@
# === Mirrors .gitignore — items below this block are also excluded from
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
# the .gitignore inheritance (silent + surprising). ===
node_modules/
package-lock.json
*.tgz
.env
.env.*
.DS_Store
npm-debug.log*
# === Dev-only content the npm tarball doesn't need ===
# Tests + their harness — Node-RED loads the entry .js, not the test tree.
test/
*.test.js
# Wiki, screenshots, drawio diagrams — useful in the repo, big in the pack.
wiki/
# Local simulation harness + scenario data (dev-only). 870+ KB on disk.
simulations/
# Build/maintenance tooling not used at runtime.
tools/
# Project memory + IDE configs.
.claude/
.codex/
.repo-mem/
CLAUDE.md
CLAUDE.local.md

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
## Folder & File Layout
Every per-node file MUST use the folder name (`pumpingStation`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `pumpingStation.js` |
| Editor HTML | `pumpingStation.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

58
CONTRACT.md Normal file
View File

@@ -0,0 +1,58 @@
# 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.outflow` | `q_out` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a measured outflow value into the basin balance. Same payload conventions as `set.inflow`. |
| `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.

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

@@ -0,0 +1,479 @@
[
{
"id": "77f00aef1c966167",
"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": "aa3381b896eb2cfb",
"type": "group",
"z": "77f00aef1c966167",
"name": "Pumping Station (Process Cell)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#0c99d9",
"fill-opacity": "0.10"
},
"nodes": [
"8e78b6607deb33a7"
],
"x": 534,
"y": 351.5,
"w": 232,
"h": 97
},
{
"id": "4996420d47442fad",
"type": "group",
"z": "77f00aef1c966167",
"name": "1. Control mode",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"1155bbbde7c65363",
"e9bea0f95b557f5d"
],
"x": 94,
"y": 119,
"w": 272,
"h": 122
},
{
"id": "a9f9b38b0e00c1d7",
"type": "group",
"z": "77f00aef1c966167",
"name": "2. Flow signals (inflow / outflow)",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"7b2b5eb919b1ab15",
"3350187815774b95"
],
"x": 94,
"y": 279,
"w": 262,
"h": 122
},
{
"id": "42bf82c87d05f498",
"type": "group",
"z": "77f00aef1c966167",
"name": "3. Operator demand (manual mode only)",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"48c2262c345c46b9"
],
"x": 94,
"y": 479,
"w": 261,
"h": 82
},
{
"id": "234bdce20170061a",
"type": "group",
"z": "77f00aef1c966167",
"name": "4. Calibration",
"style": {
"stroke": "#666666",
"fill": "#ffdf7f",
"fill-opacity": "0.15",
"label": true,
"color": "#333333"
},
"nodes": [
"463eefdd54df89a5",
"2e0642275899fc79"
],
"x": 94,
"y": 599,
"w": 272,
"h": 122
},
{
"id": "f4ba4542514ed853",
"type": "group",
"z": "77f00aef1c966167",
"name": "Expected outputs",
"style": {
"stroke": "#666666",
"fill": "#d1d1d1",
"fill-opacity": "0.2",
"label": true,
"color": "#333333"
},
"nodes": [
"b2450e5ee2eebfaa",
"386af1ad8aa8ed12",
"c27c2655f199b530"
],
"x": 874,
"y": 299,
"w": 252,
"h": 202
},
{
"id": "b30af582f935bcb7",
"type": "comment",
"z": "77f00aef1c966167",
"name": "PumpingStation — Basic (Tier 1)",
"info": "Single pumpingStation node driven by inject buttons. Shows the canonical msg.topic command surface.\n\nDefault controlMode = levelbased. Switch to manual to honour set.demand.\n\nHOW TO USE\n1. Deploy the flow.\n2. (optional) Click \"set.mode = manual\" if you want set.demand to forward; otherwise leave it on levelbased and the ramp drives demand from level.\n3. Click \"set.inflow = 60 m³/h\" to push wastewater into the basin.\n4. Watch the basin fill on Port 0 (level, volume rise) and Port 1 (InfluxDB-shaped payload).\n5. In manual mode: click \"set.demand = 40\" — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.\n6. Click \"calibrate volume 25 m³\" or \"calibrate level 1.5 m\" to snap the predicted-volume integrator.\n\nPORTS\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (child handshake)",
"x": 650,
"y": 300,
"wires": []
},
{
"id": "1155bbbde7c65363",
"type": "inject",
"z": "77f00aef1c966167",
"g": "4996420d47442fad",
"name": "set.mode = manual",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "manual",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.mode",
"x": 230,
"y": 160,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "e9bea0f95b557f5d",
"type": "inject",
"z": "77f00aef1c966167",
"g": "4996420d47442fad",
"name": "set.mode = levelbased",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "levelbased",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.mode",
"x": 240,
"y": 200,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "7b2b5eb919b1ab15",
"type": "inject",
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"name": "set.inflow = 60 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "60",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.inflow",
"x": 240,
"y": 360,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "48c2262c345c46b9",
"type": "inject",
"z": "77f00aef1c966167",
"g": "42bf82c87d05f498",
"name": "set.demand = 40 %",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "40",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.demand",
"x": 230,
"y": 520,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "463eefdd54df89a5",
"type": "inject",
"z": "77f00aef1c966167",
"g": "234bdce20170061a",
"name": "calibrate volume 25 m3",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "25",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "cmd.calibrate.volume",
"x": 240,
"y": 640,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "2e0642275899fc79",
"type": "inject",
"z": "77f00aef1c966167",
"g": "234bdce20170061a",
"name": "calibrate level 1.5 m",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "1.5",
"vt": "num"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "cmd.calibrate.level",
"x": 240,
"y": 680,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "b2450e5ee2eebfaa",
"type": "debug",
"z": "77f00aef1c966167",
"g": "f4ba4542514ed853",
"name": "Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 980,
"y": 340,
"wires": []
},
{
"id": "386af1ad8aa8ed12",
"type": "debug",
"z": "77f00aef1c966167",
"g": "f4ba4542514ed853",
"name": "Port 1: InfluxDB",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 980,
"y": 400,
"wires": []
},
{
"id": "c27c2655f199b530",
"type": "debug",
"z": "77f00aef1c966167",
"g": "f4ba4542514ed853",
"name": "Port 2: Parent reg",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 990,
"y": 460,
"wires": []
},
{
"id": "8e78b6607deb33a7",
"type": "pumpingStation",
"z": "77f00aef1c966167",
"g": "aa3381b896eb2cfb",
"name": "",
"simulator": false,
"basinVolume": 50,
"basinHeight": 4,
"inflowLevel": 1.5,
"outflowLevel": 0.2,
"overflowLevel": 3.8,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.3,
"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": 1,
"uuid": "",
"supplier": "",
"category": "",
"assetType": "",
"model": "",
"unit": "",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "⊥",
"hasDistance": false,
"distance": "",
"controlMode": "levelbased",
"levelCurveType": "linear",
"logCurveFactor": 9,
"enableShiftedRamp": false,
"shiftLevel": 0,
"shiftArmPercent": 95,
"startLevel": 1,
"stopLevel": 0.5,
"minLevel": 0.20400000000000001,
"maxLevel": 3.8,
"flowSetpoint": null,
"flowDeadband": null,
"x": 650,
"y": 400,
"wires": [
[
"b2450e5ee2eebfaa"
],
[
"386af1ad8aa8ed12"
],
[
"c27c2655f199b530"
]
]
},
{
"id": "3350187815774b95",
"type": "inject",
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"name": "set.outflow= 80 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "set.outflow",
"payload": "80",
"payloadType": "num",
"x": 230,
"y": 320,
"wires": [
[
"8e78b6607deb33a7"
]
]
},
{
"id": "ef77c1819422a098",
"type": "global-config",
"env": [],
"modules": {
"EVOLV": "1.0.29"
}
}
]

1136
examples/02-Dashboard.json Normal file

File diff suppressed because it is too large Load Diff

86
examples/README.md Normal file
View File

@@ -0,0 +1,86 @@
# pumpingStation - Example Flows
Node-RED flows demonstrating the Phase-2 pumpingStation node on the
canonical topic API (`set.mode`, `set.inflow`, `set.outflow`, `set.demand`,
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
(`changemode`, `q_in`, `q_out`, `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-Dashboard.json` | 2 | Process Plant + Dashboard UI | Same command surface as Basic, but driven by FlowFuse Dashboard 2.0 widgets — `ui-button` controls + `ui-text` live status panel. |
## Prerequisites
- Node-RED with the EVOLV package installed (so the `pumpingStation`,
`measurement`, `machineGroupControl`, and `rotatingMachine` node
types are registered).
- For `02-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-Dashboard - what to try
1. Deploy.
2. Open the dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
3. Click **Mode: Manual** or **Mode: Levelbased** in the Controls panel.
4. Click **Inflow 60 m³/h** to push wastewater into the basin — the Status
panel on the right shows level / volume / volume % rising.
5. In manual mode, click **Demand 40 m³/h** — the value surfaces as
`Manual demand` in the Status panel and in the node's status badge.
6. Use **Calibrate V = 25 m³** or **Calibrate L = 1.5 m** to snap the
predicted-volume integrator.
All buttons fire the same canonical `msg.topic` as the Basic flow's inject
nodes; the only difference is the trigger. The Live status panel is fed by
Port 0 via a small fan-out function that caches last-known values so
delta-only updates never blank a row.
## 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
The current example JSON files are hand-maintained. If you re-introduce a
generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it
rather than editing the JSON directly.

View File

@@ -1,589 +0,0 @@
[
{
"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

@@ -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/Reference-Contracts.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Reference-Contracts.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
},
"repository": {
"type": "git",

View File

@@ -10,7 +10,6 @@
-->
<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>
@@ -24,17 +23,17 @@
<script>//test
RED.nodes.registerType("pumpingStation", {
category: "EVOLV",
color: "#0c99d9", // color for the node based on the S88 schema
color: "#8B4513",
defaults: {
name: { value: "" },
// Define station-specific properties
simulator: { value: false },
basinVolume: { value: 1 }, // m³, total empty basin
basinHeight: { value: 1 }, // m, floor to top
inflowLevel: { value: 0.8 }, // m, bottom/invert of inlet pipe above floor
basinVolume: { value: 50 }, // m³, total empty basin
basinHeight: { value: 4 }, // m, floor to top
inflowLevel: { value: 1.5 }, // 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
overflowLevel: { value: 3.8 }, // m, overflow elevation
defaultFluid: { value: "wastewater" },
inletPipeDiameter: { value: 0.3 }, // m
outletPipeDiameter: { value: 0.3 }, // m
@@ -85,9 +84,12 @@
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
startLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back)
holdLevel: { value: 1 }, // m, ramp 0%-foot; defaults to startLevel (= no hold zone)
deadZoneKeepAlivePercent: { value: 1 }, // % emitted across [stopLevel, startLevel] keep-alive band
minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top)
maxLevel: { value: 3.8 }, // m, 100% demand saturation
flowSetpoint: { value: null },
flowDeadband: { value: null }
@@ -413,6 +415,16 @@
<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" data-stroke="#27AE60" data-couples-line="ps-mode-line-holdLevel">
<div><label>holdLevel</label><div class="ps-sub">0 % ramp foot — leave at startLevel for no hold band</div></div>
<input type="number" id="node-input-holdLevel" 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>
@@ -469,6 +481,8 @@
<!-- 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-holdLevel" y1="24" y2="140" stroke="#27AE60" 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" />
@@ -484,6 +498,7 @@
(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>
@@ -558,6 +573,7 @@
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>

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,107 @@
// 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 ≤ holdLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
//
// startLevel is INTENTIONALLY not constrained against inflowLevel: setting
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
// configuration where the upstream pipe network is used as overflow storage
// before pumping engages. holdLevel (optional, defaults to startLevel when
// omitted) is the 0 % ramp foot — pumps engage at startLevel but hold at
// min flow until level rises through holdLevel.
//
// 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 <= highVolumeSafetyLevel` 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.
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, highVolumeSafetyLevel } = points;
// holdLevel is optional — when omitted (null/undefined/NaN) it equals
// startLevel at runtime, so skip both holdLevel-related checks in that
// case (the canonical engine semantics still hold). Explicit null/undefined
// check first so `Number(null) === 0` doesn't accidentally flag a default
// schema value as a real operator-provided one.
const rawHold = lvl.holdLevel;
const holdLevelProvided = rawHold != null && Number.isFinite(Number(rawHold));
const holdLevel = holdLevelProvided ? Number(rawHold) : null;
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, '<', 'maxLevel', lvl.maxLevel],
...(holdLevelProvided ? [
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
] : []),
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
];
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 };

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

@@ -0,0 +1,111 @@
'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);
// generalFunctions/commandRegistry's _normaliseUnits has already converted
// msg.payload to m3/h (the descriptor's units.default — see
// commands/index.js). Accepts {value, unit} objects upstream; we just read
// the normalized number here. _manualDemand is stored in m3/h, no further
// conversion needed.
const demand = Number(msg?.payload);
if (!Number.isFinite(demand)) {
log?.warn?.(`set.demand: invalid Qd value '${JSON.stringify(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}`);
});
};

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

@@ -0,0 +1,68 @@
'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' },
description: 'Switch the station between auto / manual control modes.',
handler: handlers.setMode,
},
{
topic: 'child.register',
aliases: ['registerChild'],
// payload is the Node-RED id (string) of the child node.
payloadSchema: { type: 'string' },
description: 'Register a child node (machine group, measurement, …) with this station.',
handler: handlers.registerChild,
},
{
topic: 'cmd.calibrate.volume',
aliases: ['calibratePredictedVolume'],
// any: payload may be a number or numeric string.
payloadSchema: { type: 'any' },
units: { measure: 'volume', default: 'm3' },
description: 'Calibrate the predicted-volume integrator to a known basin volume.',
handler: handlers.calibrateVolume,
},
{
topic: 'cmd.calibrate.level',
aliases: ['calibratePredictedLevel'],
payloadSchema: { type: 'any' },
units: { measure: 'length', default: 'm' },
description: 'Calibrate the predicted-volume integrator to a known basin level.',
handler: handlers.calibrateLevel,
},
{
topic: 'set.inflow',
aliases: ['q_in'],
// any: number, numeric string, or { value, unit, timestamp } object.
payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' },
description: 'Push a measured inflow value into the basin balance.',
handler: handlers.setInflow,
},
{
topic: 'set.outflow',
aliases: ['q_out'],
payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' },
description: 'Push a measured outflow value into the basin balance.',
handler: handlers.setOutflow,
},
{
topic: 'set.demand',
aliases: ['Qd'],
payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' },
description: 'Operator outflow demand setpoint for the station.',
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 };

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

@@ -0,0 +1,286 @@
// 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
// [max(startLevel, inflowLevel), maxLevel] using linear or log shape.
// Foot at startLevel when startLevel > inflowLevel allows buffering
// in the upstream sewer above the gravity-feed point.
// 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;
// The caller (run() below) already gated turn-off via the minLevel
// hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
// By the time we get here, pumps should be running — `0 %` is the engaged
// "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a
// soft turn-off. Forward unconditionally.
const forward = (group) => {
if (typeof group.setDemand !== 'function') {
logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`);
return Promise.resolve();
}
return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => {
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`);
});
};
await Promise.all(Object.values(machineGroups).map(forward));
}
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._shiftArmed = false;
host._shiftHoldValue = null;
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. Engagement gate. Pumps stay OFF until level rises through startLevel
// for the first time (rising-edge); once engaged they stay on until
// level drops through stopLevel (falling-edge — handled by case 2).
// Without an explicit stopLevel the gate collapses to `level >= startLevel`.
// Moved out of the percentControl path so 0 % can mean "engaged at
// min flow" instead of "stopped". Disengagement also clears the
// shifted-ramp hysteresis so it doesn't survive a stop/start cycle.
const isEngaged = host ? host._stopHystRunning : (level >= startLevel);
if (!isEngaged) {
controlState.percControl = 0;
if (host) {
host._shiftArmed = false;
host._shiftHoldValue = null;
host._lastDirection = direction;
}
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
return;
}
// 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators
// can raise it to introduce a hold band [startLevel, holdLevel] where
// pumps run at min flow before the ramp begins). `inflowLevel` does NOT
// shape the curve — it's basin geometry, not a control setpoint.
// Explicit null/undefined check first so `Number(null) === 0` doesn't
// silently put the ramp foot at the basin floor.
const rawHold = cfg.holdLevel;
const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold)))
? Number(rawHold) : startLevel;
const rampFoot = Math.max(startLevel, holdLevel);
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
// 5. 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) {
// Engaged (we passed the gate above) but below the ramp foot. Two
// sub-cases:
// (a) Inside the configurable hold band [startLevel, holdLevel] —
// emit 0 %, which MGC's setDemand interpolates to flow.min.
// (b) Inside the falling-edge keep-alive band [stopLevel, startLevel]
// — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps
// at least one pump turning rather than dispatching a clean min.
if (stopThresholdActive && 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}`
);
// We are past every off-gate, so the station is engaged and the computed
// demand is meant to drive pumps. If no machine group is registered the
// demand has nowhere to go and the pumps stay silent — the signature of a
// dropped Port 2 parent↔group registration (e.g. after a partial redeploy
// that recreated this node). Warn once until a group reappears so the
// failure isn't invisible.
const groupCount = machineGroups ? Object.keys(machineGroups).length : 0;
if (groupCount === 0) {
if (host && !host._warnedNoMachineGroup) {
logger?.warn?.(
`Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — `
+ `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; `
+ `redeploy/restart fully to re-run the Port 2 registration handshake.`
);
host._warnedNoMachineGroup = true;
}
} else if (host) {
host._warnedNoMachineGroup = false;
}
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
}
module.exports = {
name: 'levelbased',
run,
_scaleLevelToFlowPercent,
_curveShape,
_applyMachineGroupLevelControl,
_applyMachineLevelControl,
};

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

@@ -0,0 +1,49 @@
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, unitPolicy, logger } = ctx;
logger?.info?.(`Manual demand forwarded: ${demand}`);
if (machineGroups && Object.keys(machineGroups).length > 0) {
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
await Promise.all(
Object.values(machineGroups).map((group) =>
group.handleInput('parent', groupDemand).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}`);
}
}
}
// Neither a group nor a direct machine is registered, so the operator's
// demand silently goes nowhere. Surface it — the usual cause is a dropped
// Port 2 parent↔child registration after a partial redeploy.
const noGroups = !machineGroups || Object.keys(machineGroups).length === 0;
const noMachines = !machines || Object.keys(machines).length === 0;
if (noGroups && noMachines) {
logger?.warn?.(
`Manual demand ${demand} not forwarded — no machine group or machine is registered to this pumping station. `
+ `Check the parent↔child Port 2 registration (redeploy/restart fully to restore it).`
);
}
}
module.exports = {
name: 'manual',
run,
forwardDemand,
};

View File

@@ -142,6 +142,7 @@
// ≤-checks below are skipped rather than false-flagged).
const basinHraw = fNum('basinHeight');
const start = fNum('startLevel');
const hold = fNum('holdLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
const ovfl = fNum('overflowLevel');
@@ -154,8 +155,12 @@
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(start, max, '<'))
issues.push('startLevel must be < maxLevel');
if (!ok(start, hold, '<='))
issues.push('holdLevel must be ≥ startLevel (use startLevel for no hold band)');
if (!ok(hold, max, '<'))
issues.push('holdLevel must be < maxLevel');
if (!ok(inlet, max, '<='))
issues.push('inflowLevel must be ≤ maxLevel');
if (!ok(max, ovfl, '<='))

View File

@@ -3,8 +3,14 @@
// 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
// 0 < outflowLevel < dryRunLevel < startLevel < maxLevel ≤ overflowLevel ≤ basinHeight
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
//
// startLevel is intentionally NOT clamped against inflowLevel: pushing
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
// configuration where upstream pipe storage absorbs flow before pumping
// engages. The level-based ramp foot is max(startLevel, inflowLevel) so
// either ordering is valid.
//
// The user can still type out-of-range values via the keyboard (HTML5
// min/max only constrain the spinner). The validation ribbons in
@@ -52,10 +58,10 @@
setBounds('startLevel',
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
inlet ?? max ?? overflow ?? basinHeight);
max ?? overflow ?? basinHeight);
setBounds('inflowLevel',
start ?? EPS,
EPS,
max ?? overflow ?? basinHeight);
setBounds('maxLevel',
@@ -66,6 +72,21 @@
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);
// holdLevel — 0 % ramp foot. Defaults to startLevel (no hold band);
// when raised above startLevel, pumps engage at startLevel but emit
// 0 % across [startLevel, holdLevel] before the ramp begins. Bounds:
// startLevel ≤ holdLevel < maxLevel.
setBounds('holdLevel',
Number.isFinite(start) ? start : EPS,
max ?? overflow ?? basinHeight);
// Shift inputs (only relevant when shifted ramp enabled).
if (shiftEnabled) {
setBounds('shiftLevel',

View File

@@ -11,10 +11,13 @@
return Number.isFinite(v) ? v : null;
};
// Set a numeric input's value, or blank if not finite.
// Set a numeric input's value, or blank if not finite. Accepts numeric
// strings (Node-RED's auto-form-binding stores form values as strings).
ns.setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
if (!el) return;
const num = typeof val === 'number' ? val : parseFloat(val);
el.value = Number.isFinite(num) ? num : '';
};
// Add input + change listeners to a list of node-input-* ids.

View File

@@ -23,8 +23,16 @@
const svg = document.getElementById('ps-levelbased-mode-diagram');
if (!svg) return;
const start = fNum('startLevel');
const hold = fNum('holdLevel');
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. Renders as long as
// the typed value is a non-negative number — the start-vs-stop
// ordering check belongs to the validation ribbon, not the visual
// marker (otherwise the line vanishes while the user is mid-edit).
const stopRaw = fNum('stopLevel');
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 ? 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
@@ -85,11 +93,19 @@
return pts.join(' ');
};
// Up curve: same as before.
// Up curve. Engagement edge is startLevel (pump-on threshold); the
// ramp foot is holdLevel, with a Math.max(startLevel, …) safety
// floor — matching the runtime in levelBased.run.
// - holdLevel == startLevel (default): no hold band, 0..100 % across
// [startLevel, maxLevel].
// - holdLevel > startLevel: pumps engaged across [startLevel,
// holdLevel] at 0 % (= MGC flow.min), then 0..100 % across
// [holdLevel, 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, inlet, max));
const upFoot = Number.isFinite(hold) && hold > start ? hold : start;
if (up) up.setAttribute('points', buildPath(start, upFoot, max));
// Shifted-DOWN curve (only when shift enabled): represents the
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
@@ -152,6 +168,8 @@
[
['dryRunLevel', dryRun],
['startLevel', start],
['stopLevel', stop],
['holdLevel', hold],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],

View File

@@ -65,6 +65,17 @@
// Numeric field defaults.
ns.setNumberField('node-input-startLevel', node.startLevel);
ns.setNumberField('node-input-stopLevel', node.stopLevel);
// holdLevel defaults to startLevel when omitted (no hold band). Show
// the saved value if there is one; otherwise mirror startLevel so the
// user immediately sees the "no hold band" baseline. Coerce to Number
// because Node-RED form-bind stores numeric inputs as strings.
const holdNum = parseFloat(node.holdLevel);
ns.setNumberField('node-input-holdLevel',
Number.isFinite(holdNum) ? holdNum : node.startLevel);
const deadZoneNum = parseFloat(node.deadZoneKeepAlivePercent);
ns.setNumberField('node-input-deadZoneKeepAlivePercent',
Number.isFinite(deadZoneNum) ? deadZoneNum : 1);
ns.setNumberField('node-input-maxLevel', node.maxLevel);
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
@@ -77,16 +88,22 @@
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
// Bind redraws to the inputs each diagram cares about.
// Bind redraws to the inputs each diagram cares about. The basin
// diagram itself only paints inflow/outflow/overflow lines, but its
// validation ribbon also enforces startLevel/holdLevel/maxLevel
// ordering — so it has to refire when any of those change too, or
// the "Fix before deploy" ribbon goes stale mid-edit.
ns.bindRedraw(
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
'startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
'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',
['startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
'inflowLevel', 'outflowLevel', 'overflowLevel',
'dryRunThresholdPercent',
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
'shiftArmPercent'],
@@ -97,7 +114,7 @@
// so the next redraw + validation sees the correct min/max attrs.
ns.bindRedraw(
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
'inflowLevel', 'startLevel', 'outflowLevel',
'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
() => ns.bounds?.apply()

View File

@@ -50,6 +50,15 @@
node.logCurveFactor = parseNum('node-input-logCurveFactor');
node.startLevel = parseNum('node-input-startLevel');
node.maxLevel = parseNum('node-input-maxLevel');
// Persist as numbers — Node-RED's auto-form-binding would store these as
// strings, and oneditprepare's setNumberField rejects non-Number values,
// so the input would blank out on reopen.
const stopLevelVal = parseNum('node-input-stopLevel');
node.stopLevel = Number.isFinite(stopLevelVal) ? stopLevelVal : null;
const holdLevelVal = parseNum('node-input-holdLevel');
if (Number.isFinite(holdLevelVal)) node.holdLevel = holdLevelVal;
const deadZoneVal = parseNum('node-input-deadZoneKeepAlivePercent');
if (Number.isFinite(deadZoneVal)) node.deadZoneKeepAlivePercent = deadZoneVal;
// 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

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,296 @@
// 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 };
}
// Pick the best-available variant for one side of the basin balance.
// Mirrors selectBestNetFlow's variant precedence (measured first, then
// predicted) but resolves each side independently — so a real measured
// upstream sensor + a predicted pump outflow both feed the integrator.
// Returns the summed flow at the requested positions. The first variant
// that has any registered measurement at one of those positions wins,
// even if its sum is 0 (a sensor that reads 0 is still data).
_pickFlowSum(positions, flowUnit = 'm3/s') {
const buckets = this.measurements.measurements?.flow;
if (!buckets) return { sum: 0, variant: null };
for (const variant of this.flowVariants) {
const variantBucket = buckets[variant];
if (!variantBucket) continue;
const hasAny = positions.some((pos) => {
const posBucket = variantBucket[pos];
return posBucket && Object.keys(posBucket).length > 0;
});
if (!hasAny) continue;
return {
sum: this.measurements.sum('flow', variant, positions, flowUnit) || 0,
variant,
};
}
return { sum: 0, variant: null };
}
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.
// Inflow + outflow are resolved per-side: a real measured upstream
// sensor (variant=measured) + a predicted pump-curve outflow
// (variant=predicted) is the common realistic mix.
const inflowPick = this._pickFlowSum(this.flowPositions.inflow, flowUnit);
const outflowPick = this._pickFlowSum(this.flowPositions.outflow, flowUnit);
const inflow = inflowPick.sum;
const outflowReal = outflowPick.sum;
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, context.timestamp, 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,46 +1,16 @@
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,
@@ -61,227 +31,50 @@ class nodeClass {
defaultFluid: uiConfig.defaultFluid,
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
},
control:{
control: {
mode: uiConfig.controlMode,
levelbased:{
minLevel:uiConfig.minLevel,
startLevel:uiConfig.startLevel,
maxLevel:uiConfig.maxLevel,
levelbased: {
minLevel: uiConfig.minLevel,
startLevel: uiConfig.startLevel,
stopLevel: uiConfig.stopLevel,
holdLevel: uiConfig.holdLevel,
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
}
shiftArmPercent: uiConfig.shiftArmPercent,
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
},
safety:{
},
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
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds,
},
output: {
process: uiConfig.processOutputFormat,
dbase: uiConfig.dbaseOutputFormat
}
});
// 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(' | ')
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 'q_out': {
const val = Number(msg.payload);
const unit = msg?.unit;
const ts = msg?.timestamp || Date.now();
this.source.setManualOutflow(val, ts, unit);
break;
}
case 'Qd': {
// Manual demand: operator sets the target output via a
// dashboard slider. Only accepted when PS is in 'manual'
// mode — mirrors how rotatingMachine gates commands by
// mode (virtualControl vs auto).
const demand = Number(msg.payload);
if (!Number.isFinite(demand)) {
this.source.logger.warn(`Invalid Qd value: ${msg.payload}`);
break;
}
if (this.source.mode === 'manual') {
this.source.forwardDemandToChildren(demand).catch((err) =>
this.source.logger.error(`Failed to forward demand: ${err.message}`)
);
} else {
this.source.logger.debug(
`Qd ignored in ${this.source.mode} mode. Switch to manual to use the demand slider.`
);
}
break;
}
}
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,85 @@
// Throwaway probe — exercises the exact path:
// measurement child writes flow.measured.upstream → pumpingStation parent
// subscribes → getOutput() (≡ what Port 0 emits).
// Run with: node --test test/basic/_probe_upstream_emit.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
const { MeasurementContainer, configManager } = require('generalFunctions');
const EventEmitter = require('node:events');
// Minimal PumpingStation config — matches the editor defaults shape.
function makePsConfig() {
const ui = {
name: 'PS', basinVolume: 50, basinHeight: 5,
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
minHeightBasedOn: 'outlet',
controlMode: 'levelbased',
minLevel: 1, startLevel: 2, maxLevel: 4,
levelCurveType: 'linear',
processOutputFormat: 'process', dbaseOutputFormat: 'influxdb',
};
const cm = new configManager();
// Use the same buildConfig pipeline the runtime uses.
return cm.buildConfig('pumpingStation', ui, 'ps-probe', {
basin: {
volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
},
hydraulics: { minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
},
safety: {},
});
}
// Fake measurement child that looks exactly like the real one to the router:
// - softwareType 'measurement'
// - config.asset.type = 'flow'
// - config.functionality.positionVsParent = 'upstream'
// - .measurements is a real MeasurementContainer with a real emitter
function makeMeasurementChild(id = 'meas-probe') {
const measurements = new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s' },
});
// Real container ships an emitter; sanity check.
assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function');
return {
id,
source: {
config: {
general: { id, name: id },
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
asset: { type: 'flow' },
},
measurements,
},
};
}
test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeMeasurementChild();
// Register the child the same way the runtime does.
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
// Drive a value through the child's MeasurementContainer the way Channel
// does — type/variant/position chain then .value().
child.source.measurements
.type('flow').variant('measured').position('upstream')
.value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys);
for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`);
// The contract: the parent should surface the upstream measurement.
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0');
});

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,185 @@
// 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]);
// Registry normalises to the descriptor's `units.default` (m3/h) before
// the handler runs. 0.5 m3/s -> 1800 m3/h.
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, 1800);
assert.equal(calls.setManualInflow[0].u, 'm3/h');
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());
// After registry units-normalisation the handler always sees a number in
// the descriptor's default unit (m3/h). 0.5 m3/s -> 1800 m3/h.
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx());
assert.deepEqual(calls.setManualInflow[0], { v: 1800, ts: 1000, u: 'm3/h' });
// Object payload `{ value, unit }` is flattened to a number; 2 m3/h stays
// 2 m3/h. The timestamp travels on the msg envelope after normalisation
// (the per-payload `timestamp` field is not preserved by the flatten).
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,232 @@
// 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 = { setDemand: [], handleInput: [], turnOff: 0 };
return {
config: { general: { name } },
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
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.setDemand.length, 0, 'no demand sent in stop zone');
}
});
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
}
});
test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => {
const ctx = makeCtx(2);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
// Critical: at startLevel pumps are engaged at min flow, NOT turned off.
// The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps
// at this boundary even though the hysteresis was armed.
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel');
assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC');
assert.deepEqual(g._calls.setDemand[0], [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 setDemand(pct, "%")', 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.setDemand.length, 1, 'one forward per group');
assert.deepEqual(g._calls.setDemand[0], [50, '%']);
assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand');
assert.equal(g._calls.turnOff, 0);
}
});
test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => {
// startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between
// startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now
// the ramp is anchored at startLevel so level=2.5 → 25 %.
const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } });
ctx.basin = { inflowLevel: 3 };
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.ok(Math.abs(state.percControl - 25) < 1e-9,
`expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`);
});
test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => {
// Same geometry but operator raises holdLevel to 3 so the ramp's 0 %
// foot moves up. Level=2.5 should now sit in the hold band: pumps are
// engaged but emit 0 % (= MGC's flow.min, NOT turn-off).
const ctx = makeCtx(2.5, {
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 },
});
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0, '0 % in the configurable hold band');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band');
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
}
});
test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => {
// stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the
// band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %).
const ctx = makeCtx(1.5, {
levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 },
});
// Pre-arm: simulate that level previously crossed startLevel.
ctx.host = { _stopHystRunning: true };
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.deepEqual(g._calls.setDemand[0], [1, '%']);
}
});
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);
}
});
// Regression: a station engaged above startLevel but with no machine group
// registered (e.g. the Port 2 parent↔group registration was dropped by a
// partial redeploy) computes a real demand that goes nowhere. The strategy
// must surface this once, not fail silently. See the 2026-05-27 "PS not
// reacting to level" trace.
test('engaged with NO machine group registered → warns once (throttled via host)', async () => {
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged
ctx.machineGroups = {}; // registration lost
ctx.host = {};
const warns = [];
ctx.logger.warn = (m) => warns.push(m);
const state = { percControl: 0 };
await levelBased.run(ctx, state);
assert.ok(state.percControl > 0, 'demand is computed even though there is no group');
assert.equal(warns.length, 1, 'warns exactly once');
assert.match(warns[0], /no machine group is registered/i);
assert.equal(ctx.host._warnedNoMachineGroup, true);
// Subsequent ticks while still group-less stay quiet (no log spam).
await levelBased.run(ctx, state);
assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick');
});
test('warning re-arms after a group reappears then disappears again', async () => {
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } });
ctx.host = {};
const warns = [];
ctx.logger.warn = (m) => warns.push(m);
const state = { percControl: 0 };
ctx.machineGroups = {};
await levelBased.run(ctx, state);
assert.equal(warns.length, 1);
// Group registers again → flag clears, no new warning.
ctx.machineGroups = { a: makeGroup('A') };
await levelBased.run(ctx, state);
assert.equal(warns.length, 1);
assert.equal(ctx.host._warnedNoMachineGroup, false);
// Group lost again → warns once more.
ctx.machineGroups = {};
await levelBased.run(ctx, state);
assert.equal(warns.length, 2, 're-armed after recovery');
});

View File

@@ -0,0 +1,71 @@
// 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 { UnitPolicy } = require('generalFunctions');
const manual = require('../../src/control/manual');
const unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s' },
output: { flow: 'm3/s' },
requireUnitForTypes: [],
});
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", canonical m3/s demand) on every machine group', async () => {
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.forwardDemand(ctx, 360);
for (const g of Object.values(groups)) {
assert.equal(g._calls.handleInput.length, 1);
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
}
});
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: {}, unitPolicy, 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,183 @@
// 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.update integrates measured inflow when predicted side is empty', async () => {
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
// (the measurement node hard-codes variant='measured'), but the integrator
// used to read variant='predicted' only — so level stayed flat while the
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
// variant precedence per side.
const { fa, measurements } = makeAggregator();
const t0 = Date.now() - 10_000;
// Measured inflow at 'upstream' (one of the inflow position aliases),
// no outflow side at all.
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
.value(0.01, t0, 'm3/s');
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.01 × ~10 ≈ 2.10 m3.
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
});
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
// (predicted). The picker resolves each side independently, so the net
// balance uses both.
const { fa, measurements } = makeAggregator();
const t0 = Date.now() - 10_000;
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
.value(0.01, t0, 'm3/s');
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
.value(0.004, t0, 'm3/s');
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${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,81 @@
// Late-subscriber replay: a measurement child that already holds a value when
// the pumpingStation registers it (e.g. a once-only inject that fired during
// startup before the parent subscribed) must still surface on Port 0. The
// emitter only delivers future updates, so _subscribeMeasurement seeds from the
// child's current sample.
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('node:events');
const PumpingStation = require('../../src/specificClass');
const { MeasurementContainer, configManager } = require('generalFunctions');
function makePsConfig() {
const cm = new configManager();
return cm.buildConfig('pumpingStation', { name: 'PS' }, 'ps-replay', {
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
},
safety: {},
});
}
function makeFlowMeasurementChild(id = 'meas-replay') {
const measurements = new MeasurementContainer({ autoConvert: true, preferredUnits: { flow: 'm3/s' } });
assert.ok(typeof measurements.emitter?.on === 'function');
return {
id,
source: {
config: {
general: { id, name: id },
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
asset: { type: 'flow' },
},
measurements,
},
};
}
test('value written BEFORE registration is replayed on subscribe (once-inject timing)', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeFlowMeasurementChild();
// Child already holds a value — emitted into the void before the parent existed.
child.source.measurements
.type('flow').variant('measured').position('upstream')
.value(50, Date.now(), 'm3/h');
// Parent registers AFTER the value is present. Without replay it would only
// catch future emits and surface nothing.
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* after late subscribe');
});
test('no stored value → nothing replayed, no crash', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeFlowMeasurementChild('empty-child');
// Register with an empty child container; replay must be a safe no-op.
assert.doesNotThrow(() => ps.childRegistrationUtils.registerChild(child.source, 'upstream'));
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
assert.equal(upstreamKeys.length, 0, 'no upstream key when child has no value');
});
test('future emits still delivered after subscribe (listener intact)', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeFlowMeasurementChild('streaming-child');
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
// Emit AFTER registration — the normal streaming-sensor path.
child.source.measurements.type('flow').variant('measured').position('upstream').value(30, Date.now(), 'm3/h');
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
assert.ok(upstreamKeys.length > 0, 'normal post-subscribe emit still surfaces');
});

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

@@ -4,8 +4,36 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const PumpingStation = require('../../src/specificClass');
// machineGroups is a registry-backed getter (declareChildGetter) — direct
// assignment is no longer possible. Tests inject mock groups through the
// real registration handshake so the registry remains the source of truth.
function registerMockGroup(ps, id, behavior = {}) {
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
const mock = {
config: {
general: { id, name: id },
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
asset: { category: 'controller' },
},
measurements: {
emitter: { on: () => {} },
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
},
setDemand: behavior.setDemand
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
handleInput: behavior.handleInput
|| (async (...args) => { calls.handleInput.push(args); }),
turnOffAllMachines: behavior.turnOffAllMachines
|| (() => { calls.turnOff += 1; }),
_calls: calls,
};
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
return mock;
}
// Standard config shape. Override any section by passing { section: {...} }.
function makeConfig(overrides = {}) {
const base = {
@@ -57,6 +85,39 @@ function makeConfig(overrides = {}) {
return base;
}
function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
return {
config: {
general: { id: name, name },
functionality: { positionVsParent: position },
asset: { type },
},
measurements: new MeasurementContainer({
autoConvert: true,
preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
}),
};
}
test('level child subscription records one sample per event for level-rate fallback', async () => {
const ps = new PumpingStation(makeConfig());
const child = makeMeasurementChild();
ps._subscribeMeasurement(child);
child.measurements.type('level').variant('measured').position('atequipment')
.value(1.0, 1000, 'm');
child.measurements.type('level').variant('measured').position('atequipment')
.value(1.1, 3000, 'm');
const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
assert.deepEqual(series.values, [1.0, 1.1]);
const net = ps.flowAggregator.selectBestNetFlow();
assert.equal(net.source, 'level:measured');
assert.equal(net.direction, 'filling');
assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
});
test('Basin geometry — derived values', async (t) => {
const ps = new PumpingStation(makeConfig());
@@ -138,7 +199,10 @@ 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', () => {
await t.test('startLevel > inflowLevel is allowed (sewer-buffer mode), no issue raised', () => {
// Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
// to fill past the inlet before pumps engage. levelBased shifts the ramp
// foot to startLevel; the validator no longer flags the ordering.
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
@@ -146,7 +210,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
},
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
assert.ok(!ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'),
'startLevel vs inflowLevel ordering must not raise an issue');
});
await t.test('outflowLevel >= inflowLevel flagged', () => {
@@ -229,82 +294,84 @@ test('Calibration — predicted volume and level', async (t) => {
test('Levelbased control zones — _controlLevelBased', async (t) => {
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
const ps = new PumpingStation(makeConfig());
let turnOffCalls = 0;
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => { turnOffCalls++; },
handleInput: async () => {},
};
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(0.5); // below minLevel=1
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(turnOffCalls, 1);
assert.equal(mock._calls.turnOff, 1);
});
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
// pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
assert.equal(mock._calls.turnOff, 1);
assert.equal(mock._calls.setDemand.length, 0);
});
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4
await ps._controlLevelBased('filling');
// Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
assert.equal(mock._calls.setDemand.length, 1);
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
});
await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
const ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %.
await ps._controlLevelBased('filling');
assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
assert.equal(mock._calls.setDemand.length, 1);
assert.equal(mock._calls.setDemand[0][1], '%');
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
});
await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
}));
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel]
await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
});
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', 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].
registerMockGroup(ps, 'mgc1');
// Climb above startLevel, then fall to a level inside [start, inflow]. With
// the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
// level still produces a positive demand on the way down.
ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased();
assert.ok(ps.percControl > 0);
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 %
await ps._controlLevelBased();
// Without shift the foot is inflowLevel → 0% in the hold zone.
assert.equal(ps.percControl, 0);
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
});
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.
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
// The original shifted-ramp test was authored against the legacy ramp
// foot = inflowLevel (=3). With the new defaults the foot moves to
// startLevel (=2), which changes every percentage in the trace. Pin
// the foot back to 3 by setting holdLevel = 3 — that keeps this test's
// arithmetic self-consistent: 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({
@@ -312,16 +379,12 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
registerMockGroup(ps, 'mgc1');
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
ps.calibratePredictedLevel(3.5);
await ps._controlLevelBased('filling');
@@ -358,16 +421,14 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
// Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
// self-consistent with the original test (up curve 0 %@3 → 100 %@4).
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
await ps._controlLevelBased('draining');
@@ -388,14 +449,12 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
// holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
// the legacy assertion bracket.
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
await ps._controlLevelBased('filling');
assert.ok(ps.percControl > 50);
@@ -404,11 +463,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(4.5); // above maxLevel=4
await ps._controlLevelBased();
assert.ok(ps.percControl >= 100);

View File

@@ -0,0 +1,124 @@
// 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
// ≤ highVolumeSafetyLevel 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 >= highVolumeSafetyLevel triggers issue', () => {
const { basin } = validBasinAndCfg();
// highVolumeSafetyLevel = overflowLevel × highPct/100 = 4.5 × 0.80 = 3.6.
// maxLevel 4 > 3.6 → expect a `maxLevel <= highVolumeSafetyLevel` 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 === 'highVolumeSafetyLevel');
assert.ok(hit, 'expected a maxLevel <= highVolumeSafetyLevel 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 <= highVolumeSafetyLevel 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

@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
function loadDashboardFlow() {
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
const flowPath = path.join(__dirname, '../../examples/02-Dashboard.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
@@ -22,27 +22,29 @@ function makeContextStub() {
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');
const ps = flow.find((n) => n.type === 'pumpingStation');
const parser = flow.find((n) => n.id === 'fn_status_split');
const levelChart = flow.find((n) => n.id === 'ui_chart_level');
const volumeChart = flow.find((n) => n.id === 'ui_chart_volume');
const flowChart = flow.find((n) => n.id === 'ui_chart_flow');
assert.ok(ps, 'ps_node_basic should exist');
assert.ok(ps, 'pumpingStation node 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.inletPipeDiameter, 0.3);
assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist');
assert.equal(parser.outputs, 6);
assert.ok(parser, 'fn_status_split should exist');
assert.equal(parser.outputs, 14);
assert.equal(levelChart.type, 'ui-chart');
assert.equal(demandChart.type, 'ui-chart');
assert.equal(volumeChart.type, 'ui-chart');
assert.equal(flowChart.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 parser = flow.find((n) => n.id === 'fn_status_split');
assert.ok(parser, 'fn_status_split should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
@@ -56,8 +58,12 @@ test('basic dashboard parser routes process fields to charts and state text', ()
payload: {
'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5,
'volumePercent.predicted.atequipment.default': 65,
'flow.predicted.in.default': 0.005,
'flow.predicted.out.default': 0.002,
'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25,
mode: 'levelbased',
direction: 'filling',
safetyState: 'normal',
isOverflowing: false,
@@ -66,22 +72,25 @@ test('basic dashboard parser routes process fields to charts and state text', ()
}, 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/);
assert.equal(out.length, 14);
assert.equal(out[0].payload, 'levelbased');
assert.equal(out[1].payload, 'filling');
assert.equal(out[2].payload, '3.25 m');
assert.equal(out[3].payload, '32.50 m³');
assert.equal(out[4].payload, '65.00 %');
assert.equal(out[5].payload, '25.0 %');
assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
assert.deepEqual(out[10], { topic: 'Inflow', payload: 18 });
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
assert.ok(Array.isArray(out[13].payload));
});
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 parser = flow.find((n) => n.id === 'fn_status_split');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
@@ -89,6 +98,6 @@ test('basic dashboard parser keeps previous values when process output sends onl
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);
assert.equal(out[2].payload, '3.10 m');
assert.equal(out[5].payload, '20.0 %');
});

View File

@@ -37,7 +37,11 @@ function makeConfig() {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4,
// holdLevel pins the ramp foot at 3 to preserve the original geometry
// (up curve 0 %@3 → 100 %@4). New default would put the foot at
// startLevel=2; this test specifically exercises shifted-ramp arming
// behaviour, not the ramp-foot semantic itself.
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4,
curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
@@ -50,17 +54,34 @@ function makeConfig() {
};
}
// machineGroups is a registry-backed getter (declareChildGetter) — inject
// the fake MGC via the real child-registration handshake so the registry
// stays the source of truth across configure() and tick().
function registerMockGroup(ps, id, demands) {
const mock = {
config: {
general: { id, name: id },
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
asset: { category: 'controller' },
},
measurements: {
emitter: { on: () => {} },
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
},
handleInput: async (_src, d) => { demands.push(d); },
turnOffAllMachines: () => {},
};
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
return mock;
}
// 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); },
};
registerMockGroup(ps, 'mgc1', demands);
// Seed level at startLevel so the run begins idle.
ps.calibratePredictedLevel(2.0);
// Override Date.now via a controllable clock that advances `step()`.

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

131
wiki/Home.md Normal file
View File

@@ -0,0 +1,131 @@
# pumpingStation
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue) ![s88](https://img.shields.io/badge/S88-Process_Cell-0c99d9) ![status](https://img.shields.io/badge/status-trial--ready-brightgreen)
A `pumpingStation` models a wet-well lift station: one basin with sensors, and one or more pumps that move water against an elevation difference. It integrates basin volume each tick, picks a control mode (level-based by default), and sends a demand setpoint to its pumps so the basin level stays inside its safe operating band.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | A wet-well lift station: a basin + N pumps |
| S88 level | Process Cell |
| Use it when | You need to lift water from a low point to a higher one, with sensors driving demand |
| Don't use it for | Pressurised distribution networks (use a pumpingStation cascade or VGC instead), or a single pump with no basin (parent a `rotatingMachine` directly) |
| Children it accepts | `measurement`, `machine`, `machinegroup`, `pumpingstation` |
---
## How it looks in Node-RED
![pumpingStation node and edit dialog](_partial-screenshots/pumpingStation/01-node-and-editor.png)
---
## What it models
A rectangular basin with measured inflow, measured (or pump-summed) outflow, and a level sensor. The diagram below is the live source; open it in [draw.io](https://app.diagrams.net/) to edit.
![Basin model — physical reference diagram](diagrams/basin-model.drawio.svg)
The basin has five horizontal reference lines that matter to the controller:
| Line | Role |
|:---|:---|
| `overflowLevel` | Physical weir crest. Above this level the basin is spilling. |
| `maxLevel` | Demand saturates at 100 % at or above this level. |
| `startLevel` | Falling-ramp returns to 0 % demand here; deadband upper bound. |
| `minLevel` | Below this level the controller commands all pumps off. |
| `dryRunLevel` | Pump-protection cutoff (safety layer, mode-independent). |
---
## Try it &mdash; 3-minute demo
Import the basic example flow, deploy, and watch the basin react to inject buttons.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/pumpingStation/examples/01-Basic.json \
http://localhost:1880/flow
```
![Basic example flow in Node-RED editor](_partial-screenshots/pumpingStation/02-basic-flow.png)
What to click in the dashboard after deploy:
1. `set.mode = levelbased` &rarr; the controller switches to level-based mode.
2. `set.inflow = 60 m³/h` &rarr; inflow is now feeding the basin.
3. `cmd.calibrate.level = 1.5 m` &rarr; the volume integrator syncs to a known level.
4. Watch Port 0 in the debug pane: level rises, predicted volume integrates, demand follows the curve.
> [!IMPORTANT]
> **GIF needed.** Demo recording of the basic flow reacting to mode + inflow clicks. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
---
## Typical wiring
The two patterns you'll see most.
### Standalone (`01-Basic.json`)
![Standalone wiring — inject buttons → pumpingStation → debug](_partial-screenshots/pumpingStation/03-wiring-standalone.png)
### With a measurement child and an MGC parent
![Integrated wiring — measurement → pumpingStation → MGC → 2 pumps](_partial-screenshots/pumpingStation/04-wiring-integrated.png)
---
## The five things you'll send
| Topic | Payload | What it does |
|:---|:---|:---|
| `set.mode` | `"levelbased"` or `"manual"` | Switches control strategy. Manual exposes `set.demand` as the direct setpoint. |
| `set.demand` | number, m³/h | Operator outflow setpoint. Honoured in `manual` mode. |
| `set.inflow` | number, m³/h | Push a measured inflow into the basin balance (if you don't have a `measurement` child for inflow). |
| `cmd.calibrate.level` | number, m | Sync the volume integrator to a known level reading. Useful at startup. |
| `cmd.calibrate.volume` | number, m³ | Sync the volume integrator to a known volume reading. |
## What you'll see come out
Sample Port 0 message (delta-compressed &mdash; only changed fields each tick):
```json
{
"topic": "pumpingStation#PS1",
"payload": {
"level": 1.62,
"volume": 32.4,
"direction": "filling",
"demand": 38,
"safety": { "blocked": false },
"etaSeconds": 412
}
}
```
| Field | Meaning |
|:---|:---|
| `level` | Current basin level (m). Measured if a level `measurement` is registered; predicted otherwise. |
| `volume` | Integrated predicted volume (m³). |
| `direction` | `filling` / `draining` / `steady` based on the flow dead-band. |
| `demand` | What the station is asking its pumps to do (0&ndash;100 %). |
| `safety.blocked` | True when the safety layer is overriding the control loop. |
| `etaSeconds` | Predicted time to full (if filling) or empty (if draining). |
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, state chart, lifecycle sequence, output ports |
| [Reference &mdash; Examples](Reference-Examples) | All shipped example flows + Docker compose snippet + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use this node, known limitations, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

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

View File

@@ -0,0 +1,158 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue)
> [!NOTE]
> Code structure for `pumpingStation`: the three-tier sandwich, the `src/` layout, the FSM, the lifecycle, and the output-port pipeline. Everything here is reproducible from `src/`. For an intuitive overview, return to [Home](Home).
---
## Three-tier code layout
```
nodes/pumpingStation/
|
+-- pumpingStation.js entry: RED.nodes.registerType('pumpingstation', NodeClass)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestration only)
| |
| +-- commands/
| | index.js topic descriptors
| | handlers.js pure handler functions
| |
| +-- basin/
| | BasinGeometry.js basin shape, level <-> volume conversion
| | thresholdValidator.js derives + validates safety / control thresholds
| |
| +-- measurement/
| | flowAggregator.js net-flow + predicted-volume integrator
| | measurementRouter.js routes measurement-child events
| | calibration.js calibrate-to-known-level / volume helpers
| |
| +-- control/
| | index.js mode dispatcher (levelbased, manual, ...)
| |
| +-- safety/
| safetyController.js dry-run + high-volume + panic guards
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `pumpingStation.js` | Type registration | Yes |
| nodeClass | `src/nodeClass.js` | Input routing, tick loop, output ports, status badge | Yes |
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; run them in `tick()`; nothing more | No |
The specificClass is stitching, not implementation. All real work lives in `basin/`, `measurement/`, `control/`, `safety/`.
---
## State chart &mdash; safety controller
The pumpingStation does not have a per-mode FSM (control modes are stateless transfer functions). The state machine that matters is the **safety controller**, which can block or pass control commands.
```mermaid
stateDiagram-v2
[*] --> running
running --> blocked_dryrun: level < dryRunLevel
running --> blocked_highvolume: level >= highVolumeSafetyLevel
running --> blocked_panic: no-data panic timer expires
blocked_dryrun --> running: level recovers above hysteresis
blocked_highvolume --> running: level falls below hysteresis
blocked_panic --> running: data resumes
```
Each `blocked_*` state sets `safety.blocked = true` on Port 0 and prevents the control layer from emitting a non-zero demand. The hysteresis is mode-independent and lives in `src/safety/safetyController.js`.
### Safety-rules asymmetry
The `dryRunLevel` and `highVolumeSafetyLevel` rules differ in **which children they stop**:
![Dry-run vs high-volume safety asymmetry](diagrams/safety-rules.drawio.svg)
| Rule | What stops | Why |
|:---|:---|:---|
| Dry run | All children (pumps off) | Pumps cavitate without water; protect the equipment |
| High volume | Only outflow-side pumps | Spill is the lesser evil; some pumps may still serve safety functions |
---
## Lifecycle &mdash; one tick
```mermaid
sequenceDiagram
autonumber
participant tick as 1s tick
participant sc as specificClass.tick()
participant fa as flowAggregator
participant safe as safetyController
participant ctrl as control[mode]
participant out as Port 0 / 1
tick->>sc: tick()
sc->>fa: update predicted volume
fa->>fa: pick best net-flow source (measured / aggregated)
sc->>safe: evaluate
alt safety blocked
safe-->>sc: { blocked: true }
Note over sc: skip control layer
else safe to run
sc->>ctrl: strategies[mode].run(context)
ctrl-->>sc: demand 0..100
end
sc->>out: getOutput() &mdash; emit Port 0 + Port 1 deltas
```
Each tick is 1 Hz. The output pipeline (Port 0 + Port 1) is driven by `outputUtils.formatMsg` &mdash; only changed fields are sent.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot consumed by downstream Node-RED logic | `{topic, payload: {level, volume, demand, direction, safety, etaSeconds}}` |
| 1 (telemetry) | InfluxDB line-protocol string with the same fields as Port 0 | `pumpingStation,id=PS1 level=1.62,volume=32.4 ...` |
| 2 (register / control) | `child.register` upward at init; internal control plumbing later | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
See [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
---
## Tick timing and event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| `setInterval(1000)` | `BaseNodeAdapter` lifecycle | `specificClass.tick()` &mdash; the per-second integrator update |
| `measurement` emitter event | Child node's `emitter.emit(<type>.measured.<position>, ...)` | `measurementRouter` updates the basin balance |
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to a handler |
| `child.register` from another node | Port 2 of a child | `_subscribeMeasurement` or `_subscribePredictedFlow` |
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| Basin geometry, level/volume conversion | `src/basin/BasinGeometry.js`, `src/basin/thresholdValidator.js` |
| Net-flow selection, predicted-volume integration | `src/measurement/flowAggregator.js` |
| Calibration commands | `src/measurement/calibration.js` |
| Control modes (level-based, manual, future modes) | `src/control/index.js` |
| Safety blocks | `src/safety/safetyController.js` |
| Topic dispatch | `src/commands/index.js` + `src/commands/handlers.js` |
| Adapter, ticking, output ports | `src/nodeClass.js` (and `BaseNodeAdapter` in `generalFunctions`) |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows |
| [Reference &mdash; Limitations](Reference-Limitations) | Known limitations and open questions |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

164
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,164 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue) ![autogen](https://img.shields.io/badge/sections-autogenerated-orange)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `pumpingStation`. The topic-contract and data-model sections are **regenerated by `npm run wiki:all`** &mdash; do not hand-edit between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Source of truth for everything on this page: the node's `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/pumpingStation.json`.
>
> For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The **Unit** column reflects each descriptor's `units: { measure, default }` declaration. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
| `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
| `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
<!-- END AUTOGEN: topic-contract -->
---
## Data model &mdash; `getOutput()` shape
Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `direction` | string | — | `"steady"` |
| `dryRunLevel` | number | — | `0.20400000000000001` |
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
| `flowSource` | null | — | `null` |
| `heightBasin` | number | m | `1` |
| `highVolumeSafetyLevel` | number | — | `2.45` |
| `highVolumeSafetyVol` | number | — | `2.45` |
| `inflowLevel` | number | m | `2` |
| `inletPipeDiameter` | number | — | `0.4` |
| `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` |
| `outletPipeDiameter` | number | — | `0.4` |
| `overflowLevel` | number | m | `2.5` |
| `percControl` | number | % | `0` |
| `predictedOverflowRate` | number | — | `0` |
| `predictedOverflowVolume` | number | — | `0` |
| `predictedUnderflowVolume` | 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 -->
Sample values come from a stub instantiation in `wikiGen` &mdash; in a live deployment the volume key is shaped `volume.<variant>.<position>.<childId>` per the standard [Measurement Key Shape](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions#measurement-key-shape).
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/pumpingStation.json`.
### Basin geometry (`config.basin`)
| Form field | Config key | Default | Unit | Notes |
|:---|:---|:---|:---|:---|
| Basin Volume | `basin.volume` | `1` | m3 | Total geometric storage from floor to rim |
| Basin Height | `basin.height` | `1` | m | Floor-to-rim wall height |
| Inlet Elevation | `basin.inflowLevel` | `2` | m | Bottom of incoming pipe, from floor |
| Outlet Elevation | `basin.outflowLevel` | `0.2` | m | Top of pump-suction pipe, from floor |
| Inlet Pipe Diameter | `basin.inletPipeDiameter` | `0.4` | m | For future hydraulic upgrades |
| Outlet Pipe Diameter | `basin.outletPipeDiameter` | `0.4` | m | For future hydraulic upgrades |
| Overflow Level | `basin.overflowLevel` | `2.5` | m | Physical overflow weir crest |
### Safety thresholds (`config.safety`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| High-Volume Safety % | `safety.highVolumeSafetyThresholdPercent` | `98` | Trigger high-volume safety at this fill % |
| Dry-Run Safety Level | `safety.dryRunLevel` | `0.2` | Below this level all pumps stop |
| Enable High-Volume Safety | `safety.enableHighVolumeSafety` | `true` | Master switch |
> [!WARNING]
> Earlier versions used `enableOverfillProtection` and `overfillThresholdPercent`. Those names are deprecated. The current canonical names are `enableHighVolumeSafety` and `highVolumeSafetyThresholdPercent`. See `.claude/refactor/OPEN_QUESTIONS.md` for the alias-removal timeline.
### Control mode (`config.control`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Mode | `control.mode` | `"levelbased"` | One of `levelbased`, `manual`, `flowbased`*, `pressureBased`*, `percentageBased`*, `powerBased`*, `hybrid`*. Asterisked modes are placeholders in code. |
| Level Curve Type | `control.levelbased.curveType` | `"linear"` | `linear` or `log` |
| Log Curve Factor | `control.levelbased.logCurveFactor` | `0.5` | Slope tuning for log curve |
| Min Level | `control.levelbased.minLevel` | `0.3` | Demand hard-zero below this |
| Start Level | `control.levelbased.startLevel` | `0.5` | Falling-ramp returns to 0 % here |
| Stop Level | `control.levelbased.stopLevel` | `0.4` | Schmitt-trigger lower bound for pump-count keep-alive |
| Max Level | `control.levelbased.maxLevel` | `2.3` | Demand saturates at 100 % here |
| Enable Shifted Ramp | `control.levelbased.enableShiftedRamp` | `true` | Hysteresis-armed shift between rising / falling ramps |
| Manual Flow Setpoint | `control.manual.flowSetpoint` | `0` | Honoured in `manual` mode |
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Time-left full / empty threshold | `general.timeleftToFullOrEmptyThresholdSeconds` | `120` | ETA below this triggers warning state |
| Flow dead-band | `general.flowThreshold` | `1e-4` m³/s | Net-flow below this is treated as steady |
---
## Child registration
Source: `nodes/pumpingStation/src/specificClass.js` `configure()`, lines 107&ndash;116.
| Software type | Filter | Wired to | Side-effect |
|:---|:---|:---|:---|
| `measurement` | any | `_subscribeMeasurement` | Subscribes to the measurement's emitter; updates basin balance |
| `machine` | only if no `machinegroup` parent is present | direct dispatch | Bypassed when an MGC is the predicted-flow source |
| `machinegroup` | any | `_subscribePredictedFlow` | Reads aggregated predicted flow from the MGC |
| `pumpingstation` | any | `_subscribePredictedFlow` | Cascaded PS &mdash; reads predicted outflow of upstream station |
The router only subscribes to the **highest-level aggregator** for predicted flow. If an MGC is present, direct `machine` children are not double-counted.
---
## Unit policy
Source: `nodes/pumpingStation/src/specificClass.js` lines 21&ndash;30.
| Quantity | Canonical (internal) | Output (rendered) |
|:---|:---|:---|
| Flow | `m3/s` | `m3/s` (also `netFlowRate`) |
| Level | `m` | `m` |
| Volume | `m3` | `m3` |
| Pressure | `Pa` | (not surfaced) |
| Power | `W` | (not surfaced) |
| Temperature | `K` | (not surfaced) |
`overflowVolume` and `underflowVolume` are explicitly listed in the policy output so the `MeasurementContainer` keeps the integrator's `m3` unit on those streams (`FlowAggregator` writes spill / underflow per tick).
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, state chart, lifecycle |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows |
| [Reference &mdash; Limitations](Reference-Limitations) | Known limitations and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

147
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,147 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue)
> [!NOTE]
> Every example flow shipped under `nodes/pumpingStation/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/pumpingStation/examples/`.
---
## Shipped examples
| File | Tier | What it shows |
|:---|:---:|:---|
| `examples/01-Basic.json` | 1 | Single pumpingStation driven by inject nodes &mdash; no parent, no dashboard. Numbered driver groups for Mode / Flow signals / Operator demand / Calibration. |
| `examples/02-Dashboard.json` | 2 | Same command surface as Basic, driven by a FlowFuse Dashboard 2.0 page (Controls + live Status rows + 4 trend charts + raw-output table). |
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import.
3. Drag-and-drop the JSON file, or paste its contents.
4. Click Deploy.
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/pumpingStation/examples/01-Basic.json \
http://localhost:1880/flow
```
---
## Example 01 &mdash; Basic standalone
![Basic example flow in Node-RED editor](_partial-screenshots/pumpingStation/02-basic-flow.png)
### Nodes on the tab
| Type | Purpose |
|:---|:---|
| `comment` | Tab header / instructions |
| `inject` &times; 7 | Buttons to send `set.mode` (manual / levelbased), `set.inflow`, `set.outflow`, `set.demand`, `cmd.calibrate.volume`, `cmd.calibrate.level` |
| `pumpingStation` | The unit under test |
| `debug` &times; 3 | Port 0 (process), Port 1 (InfluxDB), Port 2 (parent reg) |
Driver injects are wrapped in four numbered groups: **1. Control mode**, **2. Flow signals (inflow / outflow)**, **3. Operator demand (manual mode only)**, **4. Calibration**. Debug nodes sit in a separate **Debug outputs (sidebar)** group on the right.
### What to do after deploy
1. (optional) Click `set.mode = manual` if you want `set.demand` to forward; otherwise leave it on the default `levelbased` and the ramp drives demand from level.
2. Click `set.inflow = 60 m³/h` &mdash; the basin starts filling. Watch Port 0 in the debug pane: `direction` flips to `filling`, `level` rises, predicted volume integrates.
3. In manual mode: click `set.demand = 40` &mdash; the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.
4. Click `cmd.calibrate.volume = 25 m³` (or `cmd.calibrate.level = 1.5 m`) to snap the predicted-volume integrator.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 14. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
---
## Example 02 &mdash; Dashboard
> [!IMPORTANT]
> **Screenshot needed.** Two captures from `02-Dashboard.json`:
> 1. The editor tab (left controls column + pumpingStation + Live-status group on the right).
> 2. The rendered dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
>
> Save as `wiki/_partial-screenshots/pumpingStation/05-ex02-editor.png` and `06-ex02-dashboard.png`.
> Replace this callout with both image links.
### What it adds vs Example 01
| Addition | Why |
|:---|:---|
| FlowFuse `ui-base` + `ui-theme` + `ui-page` setup | One dashboard page hosting four widget groups |
| `ui-button` &times; 7 (Controls group) | Replace the inject buttons one-for-one &mdash; each carries the canonical `msg.topic` directly |
| `ui-text` &times; 7 (Status group) | Live readouts: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand |
| `ui-chart` &times; 4 (Trends group) | Level (m), Volume (m³), Volume % (0&ndash;100), Flow (m³/h, multi-series Inflow / Outflow / Net) |
| `ui-template` (Raw output group) | Full key/value table of the latest Port 0 cache &mdash; every field the node emits, sorted |
| Fan-out function | Caches last-known values so delta-only Port 0 updates never blank a status row, and forwards numeric values to the charts |
The buttons fire the **same canonical `msg.topic`** as the inject nodes in Example 01 &mdash; there is no separate dashboard command surface to learn.
Required: `@flowfuse/node-red-dashboard` (Dashboard 2.0) installed in the Node-RED instance.
### What to do after deploy
1. Open `http://localhost:1880/dashboard/pumpingstation-basic`.
2. Click `Mode: Manual` or `Mode: Levelbased`.
3. Click `Inflow 60 m³/h` &mdash; Status panel level / volume / vol% rise; the Level / Volume / Flow charts plot the trends.
4. In manual mode click `Demand 40 m³/h` &mdash; `Manual demand` row updates, node badge appends `Qd=40 m³/h`.
5. Inspect the **Raw output** table at the bottom of the page for the full Port 0 surface (basin geometry, dryRunLevel, highVolumeSafetyLevel, predictedOverflowVolume, &hellip;).
> [!IMPORTANT]
> **GIF needed.** Capture clicking through Mode &rarr; Inflow &rarr; Demand and the charts reacting. 20&ndash;30 s is enough.
>
> Save as `wiki/_partial-gifs/pumpingStation/02-ex02-dashboard.gif`.
> Replace this callout with the image link.
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Debug recipes
| Symptom | First thing to check |
|:---|:---|
| Status badge stuck on `no data` | Did the level `measurement` child register? Tap Port 2 of the measurement with a `debug` node and confirm a `child.register` msg fires once at init. |
| Level rises but `volume` stays at `minVol` | Volume integrator hasn't been calibrated. Send `cmd.calibrate.level = <real level>` once. |
| Demand stays at 0 % even though level is high | Mode might be `manual` &mdash; check `set.mode`. Or the safety layer is blocking (look at `safety.blocked` on Port 0). |
| Predicted volume drifts | Net-flow source is wrong. Look at `flowSource` on Port 0; it should match the highest-level aggregator you have wired in. |
| `enableLog: 'debug'` floods the container log | Toggle it off in the node's config. Never ship a demo with debug logging enabled. |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, state chart, lifecycle |
| [Reference &mdash; Limitations](Reference-Limitations) | Known limitations and open questions |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where this node fits in a larger plant |

View File

@@ -0,0 +1,104 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue)
> [!NOTE]
> What `pumpingStation` does not do, current rough edges, and open questions tracked against the refactor. Live source for the open items: `.claude/refactor/OPEN_QUESTIONS.md` in the EVOLV superproject.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| Pressurised distribution network without a basin | Cascade pumpingStations, or a `valveGroupControl` parented to a flow source |
| Single pump, no basin, no level sensor | Parent a `rotatingMachine` directly under a UI driver |
| Air manifold (compressor + valves) | A future `compressorStation` &mdash; not implemented |
| Open-channel flow without a wet-well | Out of scope for the current basin model (rectangular prismatic only) |
| Sludge thickening basin | Use a `settler` &mdash; different settling-velocity model required |
---
## Known limitations
### Implemented modes vs schema modes
The schema's `control.mode` enum lists eight modes, but only two are implemented in code:
| Mode | Status | Notes |
|:---|:---|:---|
| `levelbased` | Implemented | Default; the most production-tested path |
| `manual` | Implemented | Operator's `set.demand` is forwarded unchanged |
| `flowbased` | Placeholder | Schema accepts it; runtime falls back to levelbased |
| `pressureBased` | Placeholder | Same as above |
| `percentageBased` | Placeholder | Same as above |
| `powerBased` | Placeholder | Same as above |
| `hybrid` | Placeholder | Same as above |
| `mpc` | Not in code | Reserved name |
If you select an unimplemented mode in the editor, the basin runs but the controller stays in level-based. Tracked.
### Basin shape
Only rectangular prismatic basins are supported. Cylindrical, frusto-conical, or stepped basins would need a new `BasinGeometry` implementation. The `volume = level * surfaceArea` relationship is hard-wired.
### Net-flow source selection
When both an MGC parent and direct rotatingMachine children are wired, the station subscribes only to the MGC's predicted flow. If you intentionally have MGC + extra individual pumps, the extras are invisible to the volume integrator. The router protects against double-counting but does not warn about this edge case.
### Aliases not yet removed
The following legacy aliases still work but log a deprecation warning on first use. They are scheduled for removal in Phase 7:
| Canonical | Legacy alias |
|:---|:---|
| `set.mode` | `changemode` |
| `set.inflow` | `q_in` |
| `set.outflow` | `q_out` |
| `set.demand` | `Qd` |
| `cmd.calibrate.volume` | `calibratePredictedVolume` |
| `cmd.calibrate.level` | `calibratePredictedLevel` |
| `child.register` | `registerChild` |
Update integrations now.
---
## Open questions (tracked)
Pulled from `.claude/refactor/OPEN_QUESTIONS.md`. Last reviewed on the date in the badge above.
| Question | Where it lives |
|:---|:---|
| `overfillVol` alias drop &mdash; same shape as the already-done `overfillLevel` drop | OPEN_QUESTIONS.md (pumpingStation entry) |
| Net-flow source warning when multiple aggregators are wired | Internal &mdash; not yet ticketed |
| Cylindrical basin geometry | Internal &mdash; not yet ticketed |
| Docker E2E sign-off (P2.14) | OPEN_QUESTIONS.md (Phase 6) |
---
## Migration notes
### From pre-refactor
| Pre-refactor | Now |
|:---|:---|
| `enableOverfillProtection` | `enableHighVolumeSafety` |
| `overfillThresholdPercent` | `highVolumeSafetyThresholdPercent` |
| Legacy topics (`changemode`, `q_in`, ...) | Canonical topics (see [Reference &mdash; Contracts](Reference-Contracts) for the alias map) |
| `basic.flow.json` (legacy) | `01-Basic.json` (canonical-topic version) |
### Renamed safety thresholds
The safety layer used to expose threshold fields named `overfill*`. Those names suggested the layer prevents overflow specifically; in practice the rule handles high-volume conditions more broadly (high level + low inflow / outflow imbalance). The current names (`highVolumeSafety*`) reflect that.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters (alias map at the end) |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, state chart |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows |

17
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,17 @@
### pumpingStation
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)

View File

@@ -0,0 +1,2 @@
# Downloadable example flow JSONs.
# Canonical examples live under nodes/pumpingStation/examples/.

View File

@@ -0,0 +1,4 @@
# Dashboard interaction GIFs for pumpingStation.
# Naming: NN-short-description.gif
# Optimise with: gifsicle -O3 --lossy=80 in.gif -o out.gif
# Target <= 1 MB.

View File

@@ -0,0 +1,3 @@
# Node-RED editor screenshots for pumpingStation.
# Naming: NN-short-description.png
# See Home.md callouts.

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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