Compare commits

19 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
55 changed files with 3183 additions and 4321 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.

View File

@@ -12,6 +12,7 @@ Hand-maintained for Phase 2; the `## Inputs` table is generated from
| `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.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
# pumpingStation - Example Flows
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
canonical topic API (`set.mode`, `set.inflow`, `set.demand`,
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`, `Qd`, `calibratePredictedVolume`,
(`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.
@@ -12,15 +12,14 @@ one-time deprecation warning; these fresh flows use the canonical names only.
| 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). |
| `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 `03-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
- For `02-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
## How to load
@@ -46,28 +45,22 @@ import into their own tabs and can be deployed immediately.
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
## 02-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.
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
@@ -88,12 +81,6 @@ These flows follow the EVOLV layout rule set in
## 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.
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

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

View File

@@ -5,8 +5,8 @@
"main": "pumpingStation.js",
"scripts": {
"test": "node --test test/",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki: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": {

View File

@@ -23,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
@@ -84,10 +84,12 @@
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
startLevel: { value: null },
stopLevel: { 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 }
@@ -418,6 +420,11 @@
<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>
@@ -475,6 +482,7 @@
<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" />
@@ -565,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

@@ -4,7 +4,14 @@
//
// Invariants enforced (level-space, bottom → top):
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
// 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
@@ -56,14 +63,26 @@ function validateThresholdOrdering(basin, levelbased, safety) {
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, '<=', 'inflowLevel', basin.inflowLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
...(holdLevelProvided ? [
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
] : []),
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
];

View File

@@ -88,9 +88,14 @@ exports.setOutflow = (source, msg) => {
exports.setDemand = (source, msg, ctx) => {
const log = _logger(source, ctx);
const demand = Number(msg.payload);
// 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 '${msg.payload}'`);
log?.warn?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
return;
}
if (source.mode !== 'manual') {

View File

@@ -7,7 +7,9 @@
// through the dead band [stopLevel, startLevel] emitting a small
// keep-alive demand so MGC keeps a single pump draining the basin.
// 3. Up-curve mapping — level mapped to demand 0..100 % across
// [inflowLevel, maxLevel] using linear or log shape.
// [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
@@ -45,13 +47,21 @@ function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
await Promise.all(
Object.values(machineGroups).map((group) =>
group.handleInput('parent', percentControl).catch((err) => {
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
})
)
);
// 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) {
@@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) {
controlState.percControl = 0;
if (host) {
host._stopHystRunning = false;
host._shiftArmed = false;
host._shiftHoldValue = null;
host._lastDirection = direction;
}
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
@@ -131,13 +143,38 @@ async function run(ctx, controlState, direction) {
}
}
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
// 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);
// 4. Shifted-ramp arming.
// 5. Shifted-ramp arming.
if (host) {
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
@@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) {
let percControl;
if (!inDrainingHold) {
if (level < rampFoot) {
// While engaged via stopLevel hysteresis AND inside the dead band
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
// single pump running.
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
// 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);
@@ -212,6 +253,26 @@ async function run(ctx, controlState, direction) {
`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);
}

View File

@@ -4,13 +4,14 @@ async function run() {
}
async function forwardDemand(ctx, demand) {
const { machineGroups, machines, logger } = ctx;
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', demand).catch((err) => {
group.handleInput('parent', groupDemand).catch((err) => {
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
})
)
@@ -27,6 +28,18 @@ async function forwardDemand(ctx, demand) {
}
}
}
// 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 = {

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',
@@ -73,6 +79,14 @@
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,13 +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. Must be < startLevel
// for the marker to render.
// 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 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
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
@@ -90,14 +93,19 @@
return pts.join(' ');
};
// Up curve. Foot is startLevel (the configured pump-on threshold and
// ramp foot per the runtime in _controlLevelBased). The OFF baseline
// is drawn for level < startLevel; at startLevel demand jumps from
// OFF to 0 % and ramps up to 100 % at maxLevel.
// 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, start, 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
@@ -161,6 +169,7 @@
['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

@@ -57,6 +57,32 @@ class FlowAggregator {
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();
@@ -64,8 +90,13 @@ class FlowAggregator {
// Synthetic spill flow lives at its OWN position ('overflow') —
// not as a child of 'out'. That keeps it out of the operational
// outflow sum here so no self-subtraction is needed.
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
// 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 };

View File

@@ -34,7 +34,7 @@ class MeasurementRouter {
onLevelMeasurement(position, value, context = {}) {
this.measurements.type('level').variant('measured').position(position)
.value(value).unit(context.unit);
.value(value, context.timestamp, context.unit);
const series = this.measurements.type('level').variant('measured').position(position);
const levelMeters = series.getCurrentValue('m');

View File

@@ -37,6 +37,7 @@ class nodeClass extends BaseNodeAdapter {
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,
@@ -44,6 +45,7 @@ class nodeClass extends BaseNodeAdapter {
enableShiftedRamp: uiConfig.enableShiftedRamp,
shiftLevel: uiConfig.shiftLevel,
shiftArmPercent: uiConfig.shiftArmPercent,
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
},
},
safety: {

View File

@@ -18,15 +18,21 @@ class PumpingStation extends BaseDomain {
static name = 'pumpingStation';
// Internal math runs in m3/s for flow and m for level so the volume
// integrator (flow × dt) is unit-consistent. Strict canonicals make
// unit drift in child-fed measurements an explicit error.
// integrator (flow × dt) is unit-consistent canonical stays m3/s, the
// platform-wide convention every cross-node consumer (MGC demand math,
// physics-sanity) assumes. Strict canonicals make unit drift in child-fed
// measurements an explicit error.
// Output flow / netFlowRate are emitted in m3/h so telemetry/dashboard
// series land on the same axis as the rest of the pump group (verified
// slice #47); the m3/s→m3/h presentation conversion happens at the output
// boundary only — it never touches the canonical integrator basis.
// overflowVolume / underflowVolume are listed in output so the
// MeasurementContainer keeps the integrator's m³ unit on those streams
// (FlowAggregator writes spill / underflow per tick).
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: {
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
flow: 'm3/h', netFlowRate: 'm3/h', level: 'm', volume: 'm3',
overflowVolume: 'm3', underflowVolume: 'm3',
},
requireUnitForTypes: [],
@@ -44,6 +50,12 @@ class PumpingStation extends BaseDomain {
this.controlState = { percControl: 0 };
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
// Last operator demand from set.demand in manual mode. Stored on the
// host so getOutput()/status reflect it even when no children are
// registered yet (otherwise forwardDemand is invisible on Port 0/1).
// Cleared on mode change away from manual.
this._manualDemand = null;
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
// Exposed as instance fields because the e2e/basic tests assert on them
// directly. levelBased strategy reads/writes via the same names.
@@ -140,6 +152,7 @@ class PumpingStation extends BaseDomain {
levelVariants: this.levelVariants,
volVariants: this.volVariants,
flowThreshold: this.flowThreshold,
unitPolicy: this.unitPolicy,
host: this,
};
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
@@ -172,6 +185,8 @@ class PumpingStation extends BaseDomain {
if (this.config.control.allowedModes?.has?.(newMode)) {
this.logger.info(`Control mode changing from ${this.mode} to ${newMode}`);
this.mode = newMode;
if (newMode !== 'manual') this._manualDemand = null;
this.notifyOutputChanged();
} else {
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
}
@@ -183,7 +198,11 @@ class PumpingStation extends BaseDomain {
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
forwardDemandToChildren(demand) { return control.manual.forwardDemand(this.context(), demand); }
forwardDemandToChildren(demand) {
this._manualDemand = Number.isFinite(demand) ? demand : null;
this.notifyOutputChanged();
return control.manual.forwardDemand(this.context(), demand);
}
// Direct delegations preserved so existing tests can drive the strategy
// without re-mocking the dispatch layer.
@@ -220,6 +239,8 @@ class PumpingStation extends BaseDomain {
out.flowSource = this.state.flowSource;
out.timeleft = this.state.seconds;
out.percControl = this.controlState.percControl;
out.mode = this.mode;
out.manualDemand = this._manualDemand;
// Derived safety thresholds — exposed so editor + dashboards can show
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
@@ -247,15 +268,14 @@ class PumpingStation extends BaseDomain {
steady: { arrow: '⏸️', fill: 'green' },
};
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
const vol = this.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
const maxVol = this.basin?.maxVolAtOverflow ?? 0;
const netFlowM3h = (this.state?.netFlow ?? 0) * 3600;
const seconds = this.state?.seconds;
const tStr = seconds != null ? `t≈${Math.round(seconds / 60)} min` : null;
const netFlowM3h = this.unitPolicy.convert(this.state?.netFlow ?? 0, 'm3/s', 'm3/h', 'status badge netFlow');
const mode = this.mode || '?';
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
return statusBadge.compose(
[`${arrow} ${pct.toFixed(1)}%`, `V=${vol.toFixed(2)} / ${maxVol.toFixed(2)}`, `net: ${netFlowM3h.toFixed(0)} m³/h`, tStr],
[mode, `${arrow} ${pct.toFixed(1)}%`, `net: ${netFlowM3h.toFixed(0)} m³/h`, manualPart],
{ fill, shape: 'dot' }
);
}
@@ -272,14 +292,32 @@ class PumpingStation extends BaseDomain {
const measurementType = child.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
child.measurements.emitter.on(eventName, (eventData = {}) => {
const handle = (eventData = {}) => {
this.logger.debug(
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
);
if (measurementType === 'level') {
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
return;
}
this.measurements.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
});
};
child.measurements.emitter.on(eventName, handle);
// Seed from the child's current value. The emitter only delivers FUTURE
// updates, so a parent that registers after the child already emitted
// (e.g. a once-only inject that fired during startup before this
// subscription existed) would otherwise never see that value. Replaying
// the last sample makes a late subscriber pick up the present state.
const series = child.measurements
.type(measurementType).variant('measured').position(position).get?.();
const sample = series?.getLaggedSample?.(0);
if (sample && sample.value != null) {
handle({ ...sample, childName: child.config.general.name });
}
}
_subscribePredictedFlow(child) {

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

@@ -24,9 +24,10 @@ function makeMeasurements(levelMeters) {
}
function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 };
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,
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
assert.equal(state.percControl, 0);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone');
}
});
// basin-docs behavior: between minLevel and the active ramp foot, demand
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
// only the explicit minLevel hard-stop path skips handleInput.
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
// 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 driven to 0 in the hold zone');
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
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 (lower edge of ramp)', async () => {
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 () => {
@@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i
assert.equal(state.percControl, 100);
});
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
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.handleInput.length, 1, 'one forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
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;
@@ -128,3 +182,51 @@ test('no valid level → warns and returns without mutating percControl or calli
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

@@ -4,8 +4,15 @@
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 {
@@ -28,15 +35,15 @@ function makeLogger() {
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
}
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
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: {}, logger: makeLogger() };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.forwardDemand(ctx, 50);
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', 50]);
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
}
});
@@ -54,7 +61,7 @@ test('forwardDemand with no machineGroups but direct machines splits demand even
test('run() is a no-op (manual mode is event-driven)', async () => {
const groups = { a: makeGroup('A') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.run(ctx, { percControl: 0 });
assert.equal(groups.a._calls.handleInput.length, 0);
});

View File

@@ -58,6 +58,48 @@ test('FlowAggregator.update integrates inflow-outflow over delta-t', async () =>
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')

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

@@ -4,13 +4,14 @@
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 = { handleInput: [], turnOff: 0 };
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
const mock = {
config: {
general: { id, name: id },
@@ -21,6 +22,8 @@ function registerMockGroup(ps, id, behavior = {}) {
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
@@ -82,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());
@@ -163,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',
@@ -171,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', () => {
@@ -261,51 +301,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
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 mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(mock._calls.handleInput[0][1], 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 mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
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(mock._calls.handleInput[0][1], 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 () => {
const ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1');
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(mock._calls.handleInput.length, 1);
assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9);
});
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', 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());
registerMockGroup(ps, 'mgc1');
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
// 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({
@@ -313,7 +379,7 @@ 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,
},
},
@@ -355,7 +421,9 @@ 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,
},
},
@@ -381,7 +449,9 @@ 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 },
},
}));
registerMockGroup(ps, 'mgc1');

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

View File

@@ -1,333 +1,131 @@
# pumpingStation
> **Reflects code as of `530f84a` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
![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)
## 1. What this node is
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.
**pumpingStation** is an S88 Process Cell that owns a wet-well basin and orchestrates the pumps that drain it. It tracks measured and predicted volume, evaluates safety interlocks (dry-run, overfill), and dispatches a control strategy that hands a demand setpoint to one or more downstream machine groups or individual pumps. Stateful (control mode) and tick-driven (1 s integrator). See [`wiki/functional-description.md`](functional-description) for the full behaviour spec.
---
## 2. Position in the platform
## At a glance
```mermaid
flowchart LR
meas_lvl[measurement<br/>type=level<br/>position=atequipment]:::ctrl
meas_in[measurement<br/>type=flow<br/>position=upstream]:::ctrl
ps[pumpingStation<br/>Process Cell]:::pc
mgc[machineGroupControl<br/>Unit]:::unit
pump[rotatingMachine<br/>Equipment]:::equip
| 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` |
meas_lvl -->|level.measured.atequipment| ps
meas_in -->|flow.measured.upstream| ps
pump -->|child.register| mgc
mgc -->|child.register| ps
mgc -->|flow.predicted.downstream| ps
ps -->|set.demand| mgc
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
---
## 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
```
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md §10.1`.
![Basic example flow in Node-RED editor](_partial-screenshots/pumpingStation/02-basic-flow.png)
## 3. Capability matrix
What to click in the dashboard after deploy:
| Capability | Status | Notes |
|---|---|---|
| Predicts basin volume from net flow | ✅ | Integrator seeded from `basin.minVol`; recomputes level each tick. |
| Accepts measured level / volume / pressure / flow | ✅ | Routed via `measurementRouter` on child registration. |
| Level-based control strategy | ✅ | Linear or log ramp between `startLevel` and `maxLevel`. |
| Flow-based control strategy | ✅ | PID against `flowSetpoint`. |
| Manual demand passthrough | ✅ | `set.demand` only honoured in `manual` mode. |
| Dry-run safety interlock | ✅ | Shuts downstream pumps when volume < `minVol` while draining. Blocks control. |
| Overfill safety interlock | ✅ | Shuts upstream equipment when volume > threshold while filling. Control keeps running. |
| No-data panic | ✅ | Shuts ALL machines and blocks control when no volume reading is available. |
| Cascaded sub-stations | ⚠️ | Accepted via `pumpingstation` softwareType but not exercised in production. |
| pressureBased / powerBased / hybrid modes | ❌ | Enumerated in schema but not dispatched — only `levelbased`, `flowbased`, `manual`. |
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.
## 4. Code map
> [!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`.
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass, commands<br/>static tickInterval = 1000 ms"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["PumpingStation.configure()<br/>declares ChildRouter rules<br/>tick() → flowAggregator → safety → control"]
end
subgraph concerns["src/ concern modules"]
basin["basin/<br/>BasinGeometry · thresholdValidator"]
measurement["measurement/<br/>flowAggregator · measurementRouter · calibration"]
control["control/<br/>levelBased · flowBased · manual · dispatch"]
safety["safety/<br/>SafetyController"]
commands["commands/<br/>topic registry · handlers"]
end
nc --> sc
sc --> basin
sc --> measurement
sc --> control
sc --> safety
nc --> commands
---
## 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
}
}
```
| Module | Owns | Read first if you're changing |
|---|---|---|
| `basin/` | Geometry, volume↔level conversion, threshold ordering | Capacity, level↔volume math, fill %. |
| `measurement/` | Net-flow aggregation, predicted-volume integrator, calibration | Predicted volume / time-to-full. |
| `control/` | Strategy dispatch (`levelbased`, `flowbased`, `manual`) | Demand calculation, mode behaviour. |
| `safety/` | Dry-run + overfill rules, pump-shutdown side-effects | Safety envelope, alarm reactions. |
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
| 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). |
## 5. Topic contract
---
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
## Need more?
<!-- BEGIN AUTOGEN: topic-contract -->
| 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 |
| 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 -->
## 6. Child registration
Mirrors the `ChildRouter` declarations in `specificClass.js → configure()`.
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m["measurement"]:::ctrl
mach["machine<br/>(rotatingMachine)"]:::equip
mgc["machinegroup<br/>(machineGroupControl)"]:::unit
sub["pumpingstation<br/>(sub-station)"]:::pc
end
m -->|"&lt;type&gt;.measured.&lt;position&gt;"| route1[_subscribeMeasurement<br/>→ measurementRouter]
mach -->|flow.predicted.out| route2[_subscribePredictedFlow<br/>+ flowAggregator]
mgc -->|flow.predicted.out| route2
sub -->|flow.predicted.out| route2
route1 --> tick[tick / integrator]
route2 --> tick
classDef ctrl fill:#a9daee,color:#000
classDef equip fill:#86bbdd,color:#000
classDef unit fill:#50a8d9,color:#000
classDef pc fill:#0c99d9,color:#fff
```
| softwareType | onRegister side-effect | Subscribed events |
|---|---|---|
| `measurement` | `_subscribeMeasurement(child)` — writes to MeasurementContainer by type + position. | `<type>.measured.<position>` for any type (level, flow, pressure, …). |
| `machine` | Added to `this.machines`. **Skipped when a `machinegroup` is present** — avoids double-counting predicted flow. | `flow.predicted.<in\|out>` per `positionVsParent`. |
| `machinegroup` | Added to `this.machineGroups`. | `flow.predicted.<in\|out>`. |
| `pumpingstation` | Added to `this.stations`. | `flow.predicted.<in\|out>`. |
## 7. Lifecycle — what one tick does
```mermaid
sequenceDiagram
participant child as measurement / pump child
participant ps as pumpingStation
participant fa as flowAggregator
participant sf as safetyController
participant ctl as control strategy
participant out as Port-0 output
child->>ps: data event (level.measured.atequipment / flow.predicted.out)
ps->>ps: ChildRouter dispatches to _subscribeMeasurement / _subscribePredictedFlow
Note over ps: every 1000 ms (static tickInterval = 1000)
ps->>fa: tick() — net flow · ETA · predicted volume integrator
ps->>sf: evaluate({direction, secondsRemaining})
alt no-volume-data panic
sf-->>ps: blocked=true, reason='no-volume-data'
sf-->>ps: ALL machines shut down
else dry-run (vol < minVol AND draining)
sf-->>ps: blocked=true, reason='dry-run'
sf-->>ps: downstream machines + machineGroups shut down
else overfill (vol > threshold AND filling)
sf-->>ps: blocked=false, reason='overfill'
sf-->>ps: upstream machines + child stations shut down
ps->>ctl: dispatch(mode, ctx, controlState)
ctl-->>ps: percControl updated — pumps keep draining
else safety clear
ps->>ctl: dispatch(mode, ctx, controlState)
ctl-->>ps: percControl updated
end
ps->>ps: notifyOutputChanged()
ps->>out: msg{topic, payload (delta-compressed)}
```
For control-strategy details see [`wiki/modes/`](modes/README).
## 8. Data model — `getOutput()`
What lands on Port 0. Built in `getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `direction` | string | — | `"steady"` |
| `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 -->
The `<nodeId>` segment of the MeasurementContainer key is the Node-RED node id assigned at deploy time; auto-gen substitutes a placeholder stub.
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Basin: volume / height]
f2[Levels: inflow / outflow / overflow]
f3[Control mode]
f4[Level-based setpoints: startLevel / stopLevel / minLevel / maxLevel]
f5[Safety: dry-run % / high-volume %]
end
subgraph config["Domain config slice"]
c1[basin.volume<br/>basin.height]
c2[basin.inflowLevel<br/>basin.outflowLevel<br/>basin.overflowLevel]
c3[control.mode]
c4[control.levelbased.startLevel<br/>control.levelbased.stopLevel<br/>control.levelbased.minLevel<br/>control.levelbased.maxLevel]
c5[safety.dryRunThresholdPercent<br/>safety.highVolumeSafetyThresholdPercent]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| `basinVolume` | `basin.volume` | `1` | > 0 (m³) | `BasinGeometry` |
| `basinHeight` | `basin.height` | `1` | > 0 (m) | `BasinGeometry` |
| `inflowLevel` | `basin.inflowLevel` | `0.8` | ≥ 0 (m) | threshold validator, control ramp foot |
| `outflowLevel` | `basin.outflowLevel` | `0.2` | ≥ 0 (m) | dead-volume floor |
| `overflowLevel` | `basin.overflowLevel` | `0.9` | > 0 (m) | overfill safety ceiling |
| `controlMode` | `control.mode` | `levelbased` | enum | `control/dispatch` |
| `levelCurveType` | `control.levelbased.curveType` | `linear` | `linear` \| `log` | `levelBased.run` |
| `logCurveFactor` | `control.levelbased.logCurveFactor` | `9` | > 0 | log-curve steepness |
| `enableShiftedRamp` | `control.levelbased.enableShiftedRamp` | `false` | bool | hysteresis ramp |
| `startLevel` | `control.levelbased.startLevel` | `null` | ≥ 0 (m) | ramp zero-point |
| `stopLevel` | `control.levelbased.stopLevel` | `null` | ≥ 0 (m) | Schmitt-trigger off threshold |
| `minLevel` | `control.levelbased.minLevel` | `null` | ≥ 0 (m) | `levelBased.run` |
| `maxLevel` | `control.levelbased.maxLevel` | `null` | ≤ overflowLevel (m) | ramp 100 % point |
| `flowSetpoint` | `control.flowbased.setpoint` | `null` | ≥ 0 (m³/h) | flow-PID target |
| `enableDryRunProtection` | `safety.enableDryRunProtection` | `true` | bool | `SafetyController._dryRunRule` |
| `dryRunThresholdPercent` | `safety.dryRunThresholdPercent` | `2` | 0100 % | dry-run trip volume |
| `enableHighVolumeSafety` | `safety.enableHighVolumeSafety` | `true` | bool | `SafetyController._overfillRule` |
| `highVolumeSafetyThresholdPercent` | `safety.highVolumeSafetyThresholdPercent` | `98` | 0100 % | overfill trip volume |
| `timeleftToFullOrEmptyThresholdSeconds` | `safety.timeleftToFullOrEmptyThresholdSeconds` | `0` | ≥ 0 (s) | ETA-based pre-trip guard |
> `enableOverfillProtection` and `overfillThresholdPercent` are **deprecated aliases** still accepted by `SafetyController` for back-compat. Use `enableHighVolumeSafety` and `highVolumeSafetyThresholdPercent` in new flows. See `OPEN_QUESTIONS.md` (B1.2 resolved).
## 10. State chart
pumpingStation has two orthogonal state vectors: **control mode** (operator-driven, persistent) and **safety state** (data-driven, evaluated every tick). The e-stop path is the no-volume-data panic that shuts all machines independently.
```mermaid
stateDiagram-v2
state ControlMode {
[*] --> levelbased
levelbased --> flowbased : set.mode
flowbased --> manual : set.mode
manual --> levelbased : set.mode
manual --> none : set.mode
levelbased --> none : set.mode
none --> levelbased : set.mode
}
state SafetyState {
[*] --> nominal
nominal --> dryRun : vol < minVol AND draining
nominal --> overfill : vol > highVolThreshold AND filling
nominal --> panic : no volume reading
dryRun --> nominal : vol ≥ minVol
overfill --> nominal : vol ≤ highVolThreshold
panic --> nominal : volume reading restored
}
```
| Safety state | `blocked` | Control dispatch | Side-effects |
|---|---|---|---|
| `nominal` | false | runs normally | — |
| `dryRun` | **true** | **skipped** | downstream machines + machineGroups shut down |
| `overfill` | false | runs (pumps must drain) | upstream machines + child stations shut down |
| `panic` | **true** | **skipped** | **ALL** machines shut down |
`dryRun` is triggered when `direction='draining'` AND vol < `minVol × (1 + dryRunThresholdPercent/100)`.
`overfill` is triggered when `direction='filling'` AND vol > `maxVolAtOverflow × (highVolumeSafetyThresholdPercent/100)`.
## 11. Examples
All three tiers are written and runnable. Import any file via the Node-RED editor or the Admin API.
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/01-Basic.json` | Single pumpingStation driven by inject nodes — no parent, no dashboard. Try `set.inflow`, `set.mode`, `cmd.calibrate.volume`. | ✅ |
| Integration | `examples/02-Integration.json` | pumpingStation + `machineGroupControl` + 2 `rotatingMachine` pumps + level `measurement`. Demonstrates Phase-2 parent/child handshake and `levelbased` control driving real pumps. | ✅ |
| Dashboard | `examples/03-Dashboard.json` | Tier 2 plumbing + FlowFuse Dashboard 2.0 page — 3 charts (flow / level / volume %), mode dropdown, demand slider. | ✅ |
| Headless | `examples/standalone-demo.js` | Node.js-only simulator, no Node-RED required. | ✅ |
See `examples/README.md` for layout conventions (link channels, lane positions, group boxes).
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| Status badge stuck on `❔ 0.0%` | No volume/level measurement registered yet. Watch Port 2. | Editor debug tap on Port 2 + `_subscribeMeasurement` log line. |
| `direction` always `steady` | Net flow inside `general.flowThreshold` dead-band (default 0.0001 m³/s ≈ 0.36 m³/h). | `flowAggregator.deriveDirection`. |
| `set.demand` ignored | Mode isn't `manual`. Confirm with `set.mode=manual` first. | `handlers.setDemand` debug log. |
| Predicted volume drifts off measured | Integrator needs a calibration anchor. Fire `cmd.calibrate.volume` with a known basin volume. | `measurement/calibration.js`. |
| Pumps don't stop on dry-run | `safety.enableDryRunProtection` must be `true` AND `direction` must be `'draining'`. | `SafetyController._dryRunRule`. |
| Threshold-ordering warnings on startup | `validateThresholdOrdering` detected violations (e.g. `inflowLevel > overflowLevel`). | `basin/thresholdValidator.js`. |
| All machines shut down immediately | No volume reading reached the node — panic path in SafetyController. Check child registration sequence. | `SafetyController.evaluate` line 59. |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
## 13. When you would NOT use this node
- Use `rotatingMachine` directly for a single pump with no basin model. pumpingStation adds overhead that pays off only when you need predicted volume, time-to-full, or multi-pump orchestration.
- Don't use pumpingStation to schedule a fixed pump rota. Its control modes are reactive (level / flow / manual demand), not calendar-driven. Use an external scheduler and wire it in via `set.demand`.
- Skip pumpingStation if you only need flow or pressure measurements with no wet-well state. A bare `machineGroupControl` is lighter when the basin is modelled elsewhere or not at all.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Cascaded `pumpingstation` children accepted but semantics of nested stations are not test-covered in production scenarios. | TBD — exercise in Docker E2E before promoting. |
| 2 | `pressureBased`, `percentageBased`, `powerBased`, and `hybrid` are listed in the config enum but not dispatched — only `levelbased`, `flowbased`, `manual` are implemented. | `control/index.js` |
| 3 | Predicted-volume integrator drifts over long horizons without a measured-level calibration source. `cmd.calibrate.volume` is operator-triggered, not automatic. | Operator procedure; auto-calibration from level sensor is future work. |
| 4 | `enableOverfillProtection` / `overfillThresholdPercent` deprecated aliases still accepted by `SafetyController` (back-compat). Remove after one release cycle. | B1.2 resolved in `OPEN_QUESTIONS.md`. |
[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