Compare commits

19 Commits

Author SHA1 Message Date
znetsixe
177a37e15c chore(examples): remove build-examples.js generator — examples are hand-authored one-offs
The generator rotted (emitted 02-Integration/03-Dashboard while the repo kept
01-Basic/02-Dashboard), produced lint-failing flows, and was the only one of 11
nodes with such a tool. Per-node example flows are illustrative one-offs: the
JSON is the source of truth, validated by flow-lint. README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:04:50 +02:00
znetsixe
089a7fa2c4 docs(examples): 01-Basic demonstrates the command envelope explicitly
- inject() helper gains optional unit/origin props (msg.unit / msg.origin).
- Value injects (set.inflow, set.demand, cmd.calibrate.volume/level) now carry
  an explicit msg.unit so the example documents the unit the value is in;
  fixed the set.demand label (m3/h, not %). Lint-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:47:19 +02:00
znetsixe
e47de87adb feat(commands): unit shorthand + collapse duplicated value/unit parsing; wiki sync
- 5 descriptors -> unit: shorthand (cmd.calibrate.volume/level, set.inflow/
  outflow/demand).
- setInflow/setOutflow: drop the hand-rolled scalar-vs-object parsing — the
  registry now normalises every shape to a number in the descriptor unit; the
  handlers become guarded one-liners (matching setDemand).
- Regenerate wiki topic-contract + command-envelope note (msg.origin).

143/143 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:41:32 +02:00
znetsixe
fc6491dc23 change(ps): emit output flow in m³/s
Set UnitPolicy output flow/netFlowRate to m³/s (was m³/h).

NOTE: this reverts the output-unit half of e041877 ("keep canonical
flow in m³/s, emit output in m³/h"). Committed at user direction during
dev-lzm → development integration; flagged for review against the
documented PS units decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:37:14 +02:00
Rene De Ren
2fb083da63 docs(contract): add worked msg examples for every input + output port
Reference-Contracts.md now carries a concrete `msg = { topic, payload, ... }`
example for each of the 7 input topics (plus the built-in query.units) and for
all three output ports (Port 0 process, Port 1 InfluxDB, Port 2 registration),
plus the emitter-event shape. Shapes verified against commandRegistry unit
normalisation and the process/influxdb formatters. Examples sit outside the
AUTOGEN markers so `npm run wiki:all` stays a no-op on them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:20:04 +02:00
Rene De Ren
4889fdaaf0 docs(contract): close output-contract gaps — mode/manualDemand, Port-2 topic, output manifest
- wiki/Reference-Contracts.md: regenerate data-model (npm run wiki:all) so the
  two live getOutput() keys `mode` and `manualDemand` are documented; refresh
  stale sample values; bump code-ref badge -> a83a85e; add human note describing
  the two control-state keys.
- CONTRACT.md: fix Port-2 outgoing topic registerChild -> child.register
  (registerChild is the deprecated *input* alias, not what the node emits).
- test/_output-manifest.md: add the mandatory output manifest (Port 0/1/2 +
  emitter events + the 15-way fn_status_split fan-out) with honest coverage gaps.
- test/integration/basic-dashboard-flow.test.js: fix stale fan-out count 14->15
  (output 14 = percControl chart added upstream); assert out[14].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:11 +02:00
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
33 changed files with 1488 additions and 1644 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

@@ -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.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. | | `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.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. | | `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. Aliases log a one-time deprecation warning the first time they fire.
@@ -24,8 +25,9 @@ Aliases log a one-time deprecation warning the first time they fire.
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the - **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter. `'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one - **Port 2 (registration):** at startup the node sends one
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }` `{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
to the upstream parent. to the upstream parent (`child.register` is canonical; `registerChild` is the
deprecated *input* alias, not what this node emits).
## Events emitted by `source.measurements.emitter` ## Events emitted by `source.measurements.emitter`

View File

@@ -1,479 +1,360 @@
[ [
{ {
"id": "77f00aef1c966167", "id": "ps_basic_tab",
"type": "tab", "type": "tab",
"label": "PumpingStation - Basic", "label": "PumpingStation - Basic",
"disabled": false, "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." "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"
},
{
"p": "unit",
"v": "m3/h",
"vt": "str"
}
],
"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 m3/h",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "40",
"vt": "num"
},
{
"p": "unit",
"v": "m3/h",
"vt": "str"
}
],
"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"
},
{
"p": "unit",
"v": "m3",
"vt": "str"
}
],
"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"
},
{
"p": "unit",
"v": "m",
"vt": "str"
}
],
"topic": "cmd.calibrate.level",
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"x": 220,
"y": 400,
"wires": [
[
"ps_basic_node"
]
]
},
{
"id": "ps_basic_node",
"type": "pumpingStation",
"z": "ps_basic_tab",
"name": "Pumping Station",
"simulator": false,
"basinVolume": 50,
"basinHeight": 3.5,
"inflowLevel": 3,
"outflowLevel": 0.2,
"overflowLevel": 3.2,
"defaultFluid": "wastewater",
"inletPipeDiameter": 0.3,
"outletPipeDiameter": 0.3,
"pipelineLength": 80,
"maxDischargeHead": 24,
"staticHead": 12,
"maxInflowRate": 200,
"temperatureReferenceDegC": 15,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"enableDryRunProtection": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 2,
"overfillThresholdPercent": 98,
"minHeightBasedOn": "outlet",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"refHeight": "NAP",
"basinBottomRef": 1,
"uuid": "example-ps-001",
"supplier": "WBD-RD",
"category": "station",
"assetType": "pumpingstation",
"model": "demo-50m3",
"unit": "m3/h",
"enableLog": true,
"logLevel": "info",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": "",
"distanceUnit": "m",
"distanceDescription": "",
"controlMode": "levelbased",
"startLevel": 1.2,
"minLevel": 0.4,
"maxLevel": 2.8,
"flowSetpoint": null,
"flowDeadband": null,
"x": 1320,
"y": 300,
"wires": [
[
"ps_basic_format"
],
[
"ps_basic_dbg_influx"
],
[
"ps_basic_dbg_parent"
]
]
},
{
"id": "ps_basic_format",
"type": "function",
"z": "ps_basic_tab",
"name": "Merge deltas + format",
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1560,
"y": 280,
"wires": [
[
"ps_basic_dbg_process"
]
]
},
{
"id": "ps_basic_dbg_process",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 0: Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1800,
"y": 240,
"wires": []
},
{
"id": "ps_basic_dbg_influx",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 1: InfluxDB",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 320,
"wires": []
},
{
"id": "ps_basic_dbg_parent",
"type": "debug",
"z": "ps_basic_tab",
"name": "Port 2: Parent reg",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 1800,
"y": 380,
"wires": []
},
{
"id": "grp_ps_basic",
"type": "group",
"z": "ps_basic_tab",
"name": "Pumping Station (PC)",
"style": {
"label": true,
"stroke": "#000000",
"fill": "#0c99d9",
"fill-opacity": "0.10"
}, },
{ "nodes": [
"id": "aa3381b896eb2cfb", "ps_basic_node",
"type": "group", "ps_basic_format"
"z": "77f00aef1c966167", ],
"name": "Pumping Station (Process Cell)", "x": 1290,
"style": { "y": 230,
"label": true, "w": 500,
"stroke": "#000000", "h": 140
"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"
}
}
]

View File

@@ -166,8 +166,8 @@
"id": "b30af582f935bcb7", "id": "b30af582f935bcb7",
"type": "comment", "type": "comment",
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"name": "PumpingStation Dashboard (Tier 2)", "name": "PumpingStation \u2014 Dashboard (Tier 2)",
"info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons set.mode (manual / levelbased)\n- Inflow / Outflow buttons set.inflow / set.outflow (60 / 80 m³/h)\n- Demand button set.demand (40 m³/h, manual mode only)\n- Calibrate buttons cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m³), Volume %, Flow (in/out/net m³/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.", "info": "Same command surface as the Basic example, driven by a FlowFuse dashboard.\n\nOpen /dashboard/pumpingstation-basic after deploy.\n\nCONTROLS panel\n- Mode buttons \u2192 set.mode (manual / levelbased)\n- Inflow / Outflow buttons \u2192 set.inflow / set.outflow (60 / 80 m\u00b3/h)\n- Demand button \u2192 set.demand (40 m\u00b3/h, manual mode only)\n- Calibrate buttons \u2192 cmd.calibrate.volume / cmd.calibrate.level\n\nSTATUS panel\n- 7 text rows: Mode, Direction, Level, Volume, Volume %, percControl, Manual demand\n\nTRENDS panel\n- 4 charts: Level (m), Volume (m\u00b3), Volume %, Flow (in/out/net m\u00b3/h)\n\nRAW OUTPUT panel\n- Full key/value dump of the latest Port 0 cache (sorted). Shows every field the node emits including basin geometry, safety thresholds, predicted overflow/underflow.\n\nThe fan-out function caches last-known values so delta-only Port 0 updates never blank a row.",
"x": 660, "x": 660,
"y": 320, "y": 320,
"wires": [] "wires": []
@@ -332,13 +332,13 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7", "g": "a9f9b38b0e00c1d7",
"group": "ui_group_ctrl", "group": "ui_group_ctrl",
"name": "Inflow 60 m³/h", "name": "Inflow 60 m\u00b3/h",
"label": "Inflow 60 m³/h", "label": "Inflow 60 m\u00b3/h",
"order": 3, "order": 3,
"width": "3", "width": "3",
"height": "1", "height": "1",
"emulateClick": false, "emulateClick": false,
"tooltip": "Push a measured inflow of 60 m³/h into the basin balance", "tooltip": "Push a measured inflow of 60 m\u00b3/h into the basin balance",
"color": "", "color": "",
"bgcolor": "", "bgcolor": "",
"icon": "south", "icon": "south",
@@ -360,13 +360,13 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7", "g": "a9f9b38b0e00c1d7",
"group": "ui_group_ctrl", "group": "ui_group_ctrl",
"name": "Outflow 80 m³/h", "name": "Outflow 80 m\u00b3/h",
"label": "Outflow 80 m³/h", "label": "Outflow 80 m\u00b3/h",
"order": 4, "order": 4,
"width": "3", "width": "3",
"height": "1", "height": "1",
"emulateClick": false, "emulateClick": false,
"tooltip": "Push a measured outflow of 80 m³/h into the basin balance", "tooltip": "Push a measured outflow of 80 m\u00b3/h into the basin balance",
"color": "", "color": "",
"bgcolor": "", "bgcolor": "",
"icon": "north", "icon": "north",
@@ -388,13 +388,13 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "42bf82c87d05f498", "g": "42bf82c87d05f498",
"group": "ui_group_ctrl", "group": "ui_group_ctrl",
"name": "Demand 40 m³/h", "name": "Demand 40 m\u00b3/h",
"label": "Demand 40 m³/h (manual)", "label": "Demand 40 m\u00b3/h (manual)",
"order": 5, "order": 5,
"width": "6", "width": "6",
"height": "1", "height": "1",
"emulateClick": false, "emulateClick": false,
"tooltip": "Operator outflow demand only forwarded when mode = manual", "tooltip": "Operator outflow demand \u2014 only forwarded when mode = manual",
"color": "", "color": "",
"bgcolor": "", "bgcolor": "",
"icon": "speed", "icon": "speed",
@@ -416,13 +416,13 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "234bdce20170061a", "g": "234bdce20170061a",
"group": "ui_group_ctrl", "group": "ui_group_ctrl",
"name": "Calibrate V=25 m³", "name": "Calibrate V=25 m\u00b3",
"label": "Calibrate V = 25 m³", "label": "Calibrate V = 25 m\u00b3",
"order": 6, "order": 6,
"width": "3", "width": "3",
"height": "1", "height": "1",
"emulateClick": false, "emulateClick": false,
"tooltip": "Snap the predicted-volume integrator to 25 m³", "tooltip": "Snap the predicted-volume integrator to 25 m\u00b3",
"color": "", "color": "",
"bgcolor": "", "bgcolor": "",
"icon": "tune", "icon": "tune",
@@ -472,8 +472,8 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "grp_status_panel", "g": "grp_status_panel",
"name": "fan-out Port 0 (status + charts + raw)", "name": "fan-out Port 0 (status + charts + raw)",
"func": "// Port 0 emits delta-only cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '';\nconst dir = cache.direction || '';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 06: status text widgets\n { payload: mode },\n { payload: dir },\n { payload: num(level, 2, 'm') },\n { payload: num(volume, 2, 'm³') },\n { payload: num(volPct, 2, '%') },\n { payload: num(pct, 1, '%') },\n { payload: mode === 'manual'\n ? (Number.isFinite(+dem) ? num(dem, 1, 'm³/h') : 'not set')\n : '' },\n // 79: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 1012: flow chart (three series share the same chart node)\n chart('Inflow', qInH),\n chart('Outflow', qOutH),\n chart('Net', qNetH),\n // 13: raw key/value rows for the ui-template\n { payload: rawRows },\n];\n", "func": "// Port 0 emits delta-only \u2014 cache last-known so deltas never blank a row.\n// Keys with dots use the runtime childId (= node id), so we pattern-match\n// by prefix rather than hardcoding.\nconst cache = context.get('cache') || {};\nconst p = msg.payload || {};\nfor (const k in p) cache[k] = p[k];\ncontext.set('cache', cache);\n\nconst findByPrefix = (prefix) => {\n for (const k of Object.keys(cache)) if (k.startsWith(prefix)) return cache[k];\n return null;\n};\nconst num = (v, dp, unit) => {\n const n = +v;\n if (!Number.isFinite(n)) return '\u2014';\n return n.toFixed(dp) + (unit ? ' ' + unit : '');\n};\n\nconst level = findByPrefix('level.predicted.atequipment.');\nconst volume = findByPrefix('volume.predicted.atequipment.');\nconst volPct = findByPrefix('volumePercent.predicted.atequipment.');\nconst qInS = findByPrefix('flow.predicted.in.');\nconst qOutS = findByPrefix('flow.predicted.out.');\nconst qNetS = findByPrefix('netFlowRate.predicted.atequipment.');\nconst qInH = Number.isFinite(+qInS) ? +qInS * 3600 : null;\nconst qOutH = Number.isFinite(+qOutS) ? +qOutS * 3600 : null;\nconst qNetH = Number.isFinite(+qNetS) ? +qNetS * 3600 : null;\nconst pct = cache.percControl;\nconst dem = cache.manualDemand;\nconst mode = cache.mode || '\u2014';\nconst dir = cache.direction || '\u2014';\n\nconst chart = (topic, v) => Number.isFinite(+v) ? { topic, payload: +v } : null;\n\n// Raw view: every cached key, sorted, with values prettified for display.\nconst rawRows = Object.keys(cache).sort().map((k) => {\n const v = cache[k];\n let display;\n if (v === null || v === undefined) display = '\u2014';\n else if (typeof v === 'number') display = Number.isInteger(v) ? String(v) : v.toFixed(4);\n else display = String(v);\n return { key: k, value: display };\n});\n\nreturn [\n // 0\u20136: status text widgets\n { payload: mode },\n { payload: dir },\n { payload: num(level, 2, 'm') },\n { payload: num(volume, 2, 'm\u00b3') },\n { payload: num(volPct, 2, '%') },\n { payload: num(pct, 1, '%') },\n { payload: mode === 'manual'\n ? (Number.isFinite(+dem) ? num(dem, 1, 'm\u00b3/h') : 'not set')\n : '\u2014' },\n // 7\u20139: single-series charts\n chart('Level', level),\n chart('Volume', volume),\n chart('Volume %', volPct),\n // 10\u201312: flow chart (three series share the same chart node)\n chart('Inflow', qInH),\n chart('Outflow', qOutH),\n chart('Net', qNetH),\n // 13: raw key/value rows for the ui-template\n { payload: rawRows },\n // 14: percControl chart\n chart('percControl', pct),\n];\n",
"outputs": 14, "outputs": 15,
"timeout": 0, "timeout": 0,
"noerr": 0, "noerr": 0,
"initialize": "", "initialize": "",
@@ -523,6 +523,9 @@
], ],
[ [
"ui_tpl_raw" "ui_tpl_raw"
],
[
"ui_chart_pumping_perccontrol"
] ]
] ]
}, },
@@ -740,8 +743,8 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "grp_status_panel", "g": "grp_status_panel",
"group": "ui_group_trends", "group": "ui_group_trends",
"name": "Volume (m³)", "name": "Volume (m\u00b3)",
"label": "Volume (m³)", "label": "Volume (m\u00b3)",
"order": 2, "order": 2,
"width": 6, "width": 6,
"height": 4, "height": 4,
@@ -754,7 +757,7 @@
"xAxisPropertyType": "timestamp", "xAxisPropertyType": "timestamp",
"xAxisFormat": "", "xAxisFormat": "",
"xAxisFormatType": "auto", "xAxisFormatType": "auto",
"yAxisLabel": "m³", "yAxisLabel": "m\u00b3",
"yAxisProperty": "payload", "yAxisProperty": "payload",
"yAxisPropertyType": "msg", "yAxisPropertyType": "msg",
"xmin": "", "xmin": "",
@@ -862,8 +865,8 @@
"z": "77f00aef1c966167", "z": "77f00aef1c966167",
"g": "grp_status_panel", "g": "grp_status_panel",
"group": "ui_group_trends", "group": "ui_group_trends",
"name": "Flow (m³/h)", "name": "Flow (m\u00b3/h)",
"label": "Flow (m³/h) — Inflow / Outflow / Net", "label": "Flow (m\u00b3/h) \u2014 Inflow / Outflow / Net",
"order": 4, "order": 4,
"width": 6, "width": 6,
"height": 4, "height": 4,
@@ -876,7 +879,7 @@
"xAxisPropertyType": "timestamp", "xAxisPropertyType": "timestamp",
"xAxisFormat": "", "xAxisFormat": "",
"xAxisFormatType": "auto", "xAxisFormatType": "auto",
"yAxisLabel": "m³/h", "yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload", "yAxisProperty": "payload",
"yAxisPropertyType": "msg", "yAxisPropertyType": "msg",
"xmin": "", "xmin": "",
@@ -1029,7 +1032,7 @@
"enableLog": false, "enableLog": false,
"logLevel": "error", "logLevel": "error",
"positionVsParent": "atEquipment", "positionVsParent": "atEquipment",
"positionIcon": "", "positionIcon": "\u22a5",
"hasDistance": false, "hasDistance": false,
"distance": "", "distance": "",
"controlMode": "levelbased", "controlMode": "levelbased",
@@ -1066,5 +1069,68 @@
"modules": { "modules": {
"EVOLV": "1.0.29" "EVOLV": "1.0.29"
} }
},
{
"id": "ui_chart_pumping_perccontrol",
"type": "ui-chart",
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "percControl",
"label": "percControl (%) \u2014 pumping-station demand",
"order": 5,
"width": 6,
"height": 4,
"chartType": "line",
"category": "topic",
"categoryType": "msg",
"xAxisLabel": "time",
"xAxisType": "time",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisLabel": "%",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "100",
"removeOlder": "15",
"removeOlderUnit": "60",
"removeOlderPoints": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"showLegend": false,
"className": "",
"colors": [
"#A347E1",
"#FF0000",
"#FF7F0E",
"#2CA02C",
"#0095FF",
"#D62728",
"#FF9896",
"#9467BD",
"#C5B0D5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"x": 1240,
"y": 560,
"wires": [
[]
]
} }
] ]

View File

@@ -79,8 +79,12 @@ These flows follow the EVOLV layout rule set in
- **Group boxes** wrap each parent + its direct children, coloured by the - **Group boxes** wrap each parent + its direct children, coloured by the
parent's S88 level. parent's S88 level.
## Regenerating ## Maintaining
The current example JSON files are hand-maintained. If you re-introduce a These example flows are **hand-authored one-offs** — edit the JSON directly.
generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it There is intentionally no generator: examples are illustrative, not produced in
rather than editing the JSON directly. bulk. Validate any change with `flow-lint`:
```bash
node ../../../tools/flow-lint/bin/flow-lint.js 01-Basic.json 02-Dashboard.json
```

View File

@@ -23,7 +23,7 @@
<script>//test <script>//test
RED.nodes.registerType("pumpingStation", { RED.nodes.registerType("pumpingStation", {
category: "EVOLV", category: "EVOLV",
color: "#0c99d9", // color for the node based on the S88 schema color: "#8B4513",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
@@ -86,6 +86,8 @@
shiftArmPercent: { value: 95 }, shiftArmPercent: { value: 95 },
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge) startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back) 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) minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top)
maxLevel: { value: 3.8 }, // m, 100% demand saturation maxLevel: { value: 3.8 }, // m, 100% demand saturation
flowSetpoint: { value: null }, flowSetpoint: { value: null },
@@ -418,6 +420,11 @@
<input type="number" id="node-input-stopLevel" min="0" step="0.01" /> <input type="number" id="node-input-stopLevel" min="0" step="0.01" />
<span class="ps-unit">m</span> <span class="ps-unit">m</span>
</div> </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 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> <div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span> <span 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-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-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-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-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-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" /> <line id="ps-mode-line-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> <label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;"> <select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option> <option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option> <option value="json">json</option>
<option value="csv">csv</option> <option value="csv">csv</option>
</select> </select>

View File

@@ -4,7 +4,14 @@
// //
// Invariants enforced (level-space, bottom → top): // Invariants enforced (level-space, bottom → top):
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight // 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. // dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
// The validator recomputes them so a config that places minLevel below the // 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 points = computeSafetyPoints(basin, safety);
const { dryRunLevel, highVolumeSafetyLevel } = points; 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 = [ const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel], ['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel], ['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin], ['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel], ['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel], ['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel], ['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
...(holdLevelProvided ? [
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
] : []),
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel], ['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
]; ];

View File

@@ -48,42 +48,29 @@ exports.calibrateLevel = (source, msg, ctx) => {
source.calibratePredictedLevel(v); source.calibratePredictedLevel(v);
}; };
exports.setInflow = (source, msg) => { // The registry has already normalised any accepted shape (number, numeric
// Payload is either a number (legacy q_in shape) or // string, or { value, unit } object) to a number in the descriptor unit
// { value, unit, timestamp } (richer object form). // (m3/h) and tagged msg.unit. Handlers just read the normalised scalar.
const p = msg.payload; exports.setInflow = (source, msg, ctx) => {
let value; const log = _logger(source, ctx);
let unit; const value = Number(msg.payload);
let timestamp; if (!Number.isFinite(value)) {
if (p !== null && typeof p === 'object') { log?.warn?.(`set.inflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
value = Number(p.value); return;
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
} }
source.setManualInflow(value, timestamp, unit); source.setManualInflow(value, msg.timestamp, msg.unit);
}; };
exports.setOutflow = (source, msg) => { exports.setOutflow = (source, msg, ctx) => {
// Manual q_out — basin-docs dashboard injects a drain rate without // Manual q_out — basin-docs dashboard injects a drain rate without wiring a
// wiring a real pump. Same payload shape as q_in. // real pump. Same normalised shape as set.inflow.
const p = msg.payload; const log = _logger(source, ctx);
let value; const value = Number(msg.payload);
let unit; if (!Number.isFinite(value)) {
let timestamp; log?.warn?.(`set.outflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
if (p !== null && typeof p === 'object') { return;
value = Number(p.value);
unit = p.unit;
timestamp = p.timestamp || Date.now();
} else {
value = Number(p);
unit = msg?.unit;
timestamp = msg?.timestamp || Date.now();
} }
source.setManualOutflow(value, timestamp, unit); source.setManualOutflow(value, msg.timestamp, msg.unit);
}; };
exports.setDemand = (source, msg, ctx) => { exports.setDemand = (source, msg, ctx) => {

View File

@@ -26,9 +26,10 @@ module.exports = [
{ {
topic: 'cmd.calibrate.volume', topic: 'cmd.calibrate.volume',
aliases: ['calibratePredictedVolume'], aliases: ['calibratePredictedVolume'],
// any: payload may be a number or numeric string. // any: payload may be a number, numeric string, or { value, unit } object —
// the registry normalises all of them to a number in `unit` before the handler.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volume', default: 'm3' }, unit: 'm3',
description: 'Calibrate the predicted-volume integrator to a known basin volume.', description: 'Calibrate the predicted-volume integrator to a known basin volume.',
handler: handlers.calibrateVolume, handler: handlers.calibrateVolume,
}, },
@@ -36,16 +37,15 @@ module.exports = [
topic: 'cmd.calibrate.level', topic: 'cmd.calibrate.level',
aliases: ['calibratePredictedLevel'], aliases: ['calibratePredictedLevel'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'length', default: 'm' }, unit: 'm',
description: 'Calibrate the predicted-volume integrator to a known basin level.', description: 'Calibrate the predicted-volume integrator to a known basin level.',
handler: handlers.calibrateLevel, handler: handlers.calibrateLevel,
}, },
{ {
topic: 'set.inflow', topic: 'set.inflow',
aliases: ['q_in'], aliases: ['q_in'],
// any: number, numeric string, or { value, unit, timestamp } object.
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Push a measured inflow value into the basin balance.', description: 'Push a measured inflow value into the basin balance.',
handler: handlers.setInflow, handler: handlers.setInflow,
}, },
@@ -53,7 +53,7 @@ module.exports = [
topic: 'set.outflow', topic: 'set.outflow',
aliases: ['q_out'], aliases: ['q_out'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Push a measured outflow value into the basin balance.', description: 'Push a measured outflow value into the basin balance.',
handler: handlers.setOutflow, handler: handlers.setOutflow,
}, },
@@ -61,7 +61,7 @@ module.exports = [
topic: 'set.demand', topic: 'set.demand',
aliases: ['Qd'], aliases: ['Qd'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
units: { measure: 'volumeFlowRate', default: 'm3/h' }, unit: 'm3/h',
description: 'Operator outflow demand setpoint for the station.', description: 'Operator outflow demand setpoint for the station.',
handler: handlers.setDemand, handler: handlers.setDemand,
}, },

View File

@@ -7,7 +7,9 @@
// through the dead band [stopLevel, startLevel] emitting a small // through the dead band [stopLevel, startLevel] emitting a small
// keep-alive demand so MGC keeps a single pump draining the basin. // keep-alive demand so MGC keeps a single pump draining the basin.
// 3. Up-curve mapping — level mapped to demand 0..100 % across // 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 // 4. Shifted-ramp hysteresis — when the up-curve crosses
// shiftArmPercent the strategy ARMS; on the next filling→draining // shiftArmPercent the strategy ARMS; on the next filling→draining
// flip it captures the up-curve value as `hold`; while 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) { async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
if (!machineGroups || Object.keys(machineGroups).length === 0) return; if (!machineGroups || Object.keys(machineGroups).length === 0) return;
await Promise.all( // The caller (run() below) already gated turn-off via the minLevel
Object.values(machineGroups).map((group) => // hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
group.handleInput('parent', percentControl).catch((err) => { // By the time we get here, pumps should be running — `0 %` is the engaged
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`); // "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) { async function _applyMachineLevelControl(machines, percentControl, logger) {
@@ -118,6 +128,8 @@ async function run(ctx, controlState, direction) {
controlState.percControl = 0; controlState.percControl = 0;
if (host) { if (host) {
host._stopHystRunning = false; host._stopHystRunning = false;
host._shiftArmed = false;
host._shiftHoldValue = null;
host._lastDirection = direction; host._lastDirection = direction;
} }
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines()); 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 // 3. Engagement gate. Pumps stay OFF until level rises through startLevel
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel] // for the first time (rising-edge); once engaged they stay on until
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel]. // level drops through stopLevel (falling-edge — handled by case 2).
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel; // 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); const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
// 4. Shifted-ramp arming. // 5. Shifted-ramp arming.
if (host) { if (host) {
if (cfg.enableShiftedRamp) { if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95; const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
@@ -177,10 +214,14 @@ async function run(ctx, controlState, direction) {
let percControl; let percControl;
if (!inDrainingHold) { if (!inDrainingHold) {
if (level < rampFoot) { if (level < rampFoot) {
// While engaged via stopLevel hysteresis AND inside the dead band // Engaged (we passed the gate above) but below the ramp foot. Two
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a // sub-cases:
// single pump running. // (a) Inside the configurable hold band [startLevel, holdLevel] —
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) { // 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)) const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
? Number(cfg.deadZoneKeepAlivePercent) : 1; ? Number(cfg.deadZoneKeepAlivePercent) : 1;
percControl = Math.max(0, keepAlive); 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}` `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); await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
} }

View File

@@ -4,13 +4,14 @@ async function run() {
} }
async function forwardDemand(ctx, demand) { async function forwardDemand(ctx, demand) {
const { machineGroups, machines, logger } = ctx; const { machineGroups, machines, unitPolicy, logger } = ctx;
logger?.info?.(`Manual demand forwarded: ${demand}`); logger?.info?.(`Manual demand forwarded: ${demand}`);
if (machineGroups && Object.keys(machineGroups).length > 0) { if (machineGroups && Object.keys(machineGroups).length > 0) {
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
await Promise.all( await Promise.all(
Object.values(machineGroups).map((group) => 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}`); 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 = { module.exports = {

View File

@@ -142,6 +142,7 @@
// ≤-checks below are skipped rather than false-flagged). // ≤-checks below are skipped rather than false-flagged).
const basinHraw = fNum('basinHeight'); const basinHraw = fNum('basinHeight');
const start = fNum('startLevel'); const start = fNum('startLevel');
const hold = fNum('holdLevel');
const inlet = fNum('inflowLevel'); const inlet = fNum('inflowLevel');
const max = fNum('maxLevel'); const max = fNum('maxLevel');
const ovfl = fNum('overflowLevel'); const ovfl = fNum('overflowLevel');
@@ -154,8 +155,12 @@
issues.push('outflowLevel must be > 0'); issues.push('outflowLevel must be > 0');
if (!ok(dryLvl, start, '<')) if (!ok(dryLvl, start, '<'))
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`); issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
if (!ok(start, inlet, '<=')) if (!ok(start, max, '<'))
issues.push('startLevel must be ≤ inflowLevel'); 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, '<=')) if (!ok(inlet, max, '<='))
issues.push('inflowLevel must be ≤ maxLevel'); issues.push('inflowLevel must be ≤ maxLevel');
if (!ok(max, ovfl, '<=')) if (!ok(max, ovfl, '<='))

View File

@@ -3,8 +3,14 @@
// the current values of related inputs, so the up/down arrows stop at // the current values of related inputs, so the up/down arrows stop at
// values that respect the basin hierarchy: // values that respect the basin hierarchy:
// //
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel // 0 < outflowLevel < dryRunLevel < startLevel < maxLevel ≤ overflowLevel ≤ basinHeight
// ≤ shiftLevel ≤ 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 // The user can still type out-of-range values via the keyboard (HTML5
// min/max only constrain the spinner). The validation ribbons in // min/max only constrain the spinner). The validation ribbons in
@@ -52,10 +58,10 @@
setBounds('startLevel', setBounds('startLevel',
Number.isFinite(dryRun) ? dryRun + EPS : EPS, Number.isFinite(dryRun) ? dryRun + EPS : EPS,
inlet ?? max ?? overflow ?? basinHeight); max ?? overflow ?? basinHeight);
setBounds('inflowLevel', setBounds('inflowLevel',
start ?? EPS, EPS,
max ?? overflow ?? basinHeight); max ?? overflow ?? basinHeight);
setBounds('maxLevel', setBounds('maxLevel',
@@ -73,6 +79,14 @@
Number.isFinite(dryRun) ? dryRun + EPS : EPS, Number.isFinite(dryRun) ? dryRun + EPS : EPS,
start ?? inlet ?? max ?? overflow ?? basinHeight); 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). // Shift inputs (only relevant when shifted ramp enabled).
if (shiftEnabled) { if (shiftEnabled) {
setBounds('shiftLevel', setBounds('shiftLevel',

View File

@@ -11,10 +11,13 @@
return Number.isFinite(v) ? v : null; 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) => { ns.setNumberField = (id, val) => {
const el = document.getElementById(id); 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. // 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'); const svg = document.getElementById('ps-levelbased-mode-diagram');
if (!svg) return; if (!svg) return;
const start = fNum('startLevel'); const start = fNum('startLevel');
const hold = fNum('holdLevel');
const inlet = fNum('inflowLevel'); const inlet = fNum('inflowLevel');
const max = fNum('maxLevel'); const max = fNum('maxLevel');
// Optional stopLevel — explicit pump-off threshold. Drawn as its // Optional stopLevel — explicit pump-off threshold. Drawn as its
// own marker line; does NOT shift the ramp foot. Must be < startLevel // own marker line; does NOT shift the ramp foot. Renders as long as
// for the marker to render. // 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 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% // dryRunLevel is derived from the basin's outflowLevel + dryRun%
// (no separate input). Below dryRunLevel the runtime hard-stops; // (no separate input). Below dryRunLevel the runtime hard-stops;
// we draw it as the leftmost vertical marker so the user sees // we draw it as the leftmost vertical marker so the user sees
@@ -91,18 +94,17 @@
}; };
// Up curve. Engagement edge is startLevel (pump-on threshold); the // Up curve. Engagement edge is startLevel (pump-on threshold); the
// ramp foot is inflowLevel — matching the runtime in // ramp foot is holdLevel, with a Math.max(startLevel, …) safety
// _controlLevelBased, which scales demand over [inflowLevel, maxLevel]. // floor — matching the runtime in levelBased.run.
// The OFF baseline is drawn for level < startLevel; between startLevel // - holdLevel == startLevel (default): no hold band, 0..100 % across
// and inflowLevel demand sits flat at 0 % (system armed but not yet // [startLevel, maxLevel].
// ramping); from inflowLevel demand ramps to 100 % at 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 up = document.getElementById('ps-mode-curve-up');
const down = document.getElementById('ps-mode-curve-down'); const down = document.getElementById('ps-mode-curve-down');
const downLabel = document.getElementById('ps-mode-curve-down-label'); const downLabel = document.getElementById('ps-mode-curve-down-label');
// Runtime falls back to startLevel when inflowLevel is missing const upFoot = Number.isFinite(hold) && hold > start ? hold : start;
// (basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel); mirror that
// in the preview so the curve is still drawn instead of blank.
const upFoot = Number.isFinite(inlet) && inlet > start ? inlet : start;
if (up) up.setAttribute('points', buildPath(start, upFoot, max)); if (up) up.setAttribute('points', buildPath(start, upFoot, max));
// Shifted-DOWN curve (only when shift enabled): represents the // Shifted-DOWN curve (only when shift enabled): represents the
@@ -167,6 +169,7 @@
['dryRunLevel', dryRun], ['dryRunLevel', dryRun],
['startLevel', start], ['startLevel', start],
['stopLevel', stop], ['stopLevel', stop],
['holdLevel', hold],
['inflowLevel', inlet], ['inflowLevel', inlet],
['maxLevel', max], ['maxLevel', max],
['overflowLevel', overflow], ['overflowLevel', overflow],

View File

@@ -65,6 +65,17 @@
// Numeric field defaults. // Numeric field defaults.
ns.setNumberField('node-input-startLevel', node.startLevel); 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-maxLevel', node.maxLevel);
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor); ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
ns.setNumberField('node-input-shiftLevel', node.shiftLevel); ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
@@ -77,16 +88,22 @@
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp'); const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
if (shiftCheckbox) shiftCheckbox.checked = !!node.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( ns.bindRedraw(
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel', ['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
'startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'], 'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
ns.basinDiagram.redraw ns.basinDiagram.redraw
); );
ns.bindRedraw( ns.bindRedraw(
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent), // dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
// so the mode preview must redraw when either of those change. // so the mode preview must redraw when either of those change.
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel', ['startLevel', 'stopLevel', 'holdLevel', 'maxLevel',
'inflowLevel', 'outflowLevel', 'overflowLevel',
'dryRunThresholdPercent', 'dryRunThresholdPercent',
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel', 'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
'shiftArmPercent'], 'shiftArmPercent'],
@@ -97,7 +114,7 @@
// so the next redraw + validation sees the correct min/max attrs. // so the next redraw + validation sees the correct min/max attrs.
ns.bindRedraw( ns.bindRedraw(
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel', ['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
'inflowLevel', 'startLevel', 'outflowLevel', 'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent', 'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'], 'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
() => ns.bounds?.apply() () => ns.bounds?.apply()

View File

@@ -50,6 +50,15 @@
node.logCurveFactor = parseNum('node-input-logCurveFactor'); node.logCurveFactor = parseNum('node-input-logCurveFactor');
node.startLevel = parseNum('node-input-startLevel'); node.startLevel = parseNum('node-input-startLevel');
node.maxLevel = parseNum('node-input-maxLevel'); 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 // minLevel is no longer a user input — it's the derived dryRunLevel
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still // (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
// uses node.minLevel as the unconditional STOP threshold; we set it // 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 }; 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() { update() {
const flowUnit = 'm3/s'; const flowUnit = 'm3/s';
const now = Date.now(); const now = Date.now();
@@ -64,8 +90,13 @@ class FlowAggregator {
// Synthetic spill flow lives at its OWN position ('overflow') — // Synthetic spill flow lives at its OWN position ('overflow') —
// not as a child of 'out'. That keeps it out of the operational // not as a child of 'out'. That keeps it out of the operational
// outflow sum here so no self-subtraction is needed. // outflow sum here so no self-subtraction is needed.
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0; // Inflow + outflow are resolved per-side: a real measured upstream
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0; // 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 }; if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };

View File

@@ -34,7 +34,7 @@ class MeasurementRouter {
onLevelMeasurement(position, value, context = {}) { onLevelMeasurement(position, value, context = {}) {
this.measurements.type('level').variant('measured').position(position) 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 series = this.measurements.type('level').variant('measured').position(position);
const levelMeters = series.getCurrentValue('m'); const levelMeters = series.getCurrentValue('m');

View File

@@ -37,6 +37,7 @@ class nodeClass extends BaseNodeAdapter {
minLevel: uiConfig.minLevel, minLevel: uiConfig.minLevel,
startLevel: uiConfig.startLevel, startLevel: uiConfig.startLevel,
stopLevel: uiConfig.stopLevel, stopLevel: uiConfig.stopLevel,
holdLevel: uiConfig.holdLevel,
maxLevel: uiConfig.maxLevel, maxLevel: uiConfig.maxLevel,
// Editor names the field levelCurveType; runtime uses curveType. // Editor names the field levelCurveType; runtime uses curveType.
curveType: uiConfig.levelCurveType || uiConfig.curveType, curveType: uiConfig.levelCurveType || uiConfig.curveType,
@@ -44,6 +45,7 @@ class nodeClass extends BaseNodeAdapter {
enableShiftedRamp: uiConfig.enableShiftedRamp, enableShiftedRamp: uiConfig.enableShiftedRamp,
shiftLevel: uiConfig.shiftLevel, shiftLevel: uiConfig.shiftLevel,
shiftArmPercent: uiConfig.shiftArmPercent, shiftArmPercent: uiConfig.shiftArmPercent,
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
}, },
}, },
safety: { safety: {

View File

@@ -18,8 +18,14 @@ class PumpingStation extends BaseDomain {
static name = 'pumpingStation'; static name = 'pumpingStation';
// Internal math runs in m3/s for flow and m for level so the volume // Internal math runs in m3/s for flow and m for level so the volume
// integrator (flow × dt) is unit-consistent. Strict canonicals make // integrator (flow × dt) is unit-consistent canonical stays m3/s, the
// unit drift in child-fed measurements an explicit error. // 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 // overflowVolume / underflowVolume are listed in output so the
// MeasurementContainer keeps the integrator's m³ unit on those streams // MeasurementContainer keeps the integrator's m³ unit on those streams
// (FlowAggregator writes spill / underflow per tick). // (FlowAggregator writes spill / underflow per tick).
@@ -146,6 +152,7 @@ class PumpingStation extends BaseDomain {
levelVariants: this.levelVariants, levelVariants: this.levelVariants,
volVariants: this.volVariants, volVariants: this.volVariants,
flowThreshold: this.flowThreshold, flowThreshold: this.flowThreshold,
unitPolicy: this.unitPolicy,
host: this, host: this,
}; };
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines }); Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
@@ -262,7 +269,7 @@ class PumpingStation extends BaseDomain {
}; };
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {}; const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0; const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
const netFlowM3h = (this.state?.netFlow ?? 0) * 3600; const netFlowM3h = this.unitPolicy.convert(this.state?.netFlow ?? 0, 'm3/s', 'm3/h', 'status badge netFlow');
const mode = this.mode || '?'; const mode = this.mode || '?';
const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand) const manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null; ? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
@@ -285,14 +292,32 @@ class PumpingStation extends BaseDomain {
const measurementType = child.config.asset.type; const measurementType = child.config.asset.type;
const eventName = `${measurementType}.measured.${position}`; const eventName = `${measurementType}.measured.${position}`;
child.measurements.emitter.on(eventName, (eventData = {}) => { const handle = (eventData = {}) => {
this.logger.debug( this.logger.debug(
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}` `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) this.measurements.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit); .value(eventData.value, eventData.timestamp, eventData.unit);
this.measurementRouter.route(measurementType, eventData.value, position, eventData); 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) { _subscribePredictedFlow(child) {

101
test/_output-manifest.md Normal file
View File

@@ -0,0 +1,101 @@
# pumpingStation output manifest
> Single source of truth for **what this node emits and where it is tested**, per
> [`.claude/rules/output-coverage.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/output-coverage.md).
> Generated against code-ref `a83a85e`. Regenerate the wiki contract with
> `npm run wiki:all` and re-check this table whenever `getOutput()`,
> `src/commands/index.js`, or an `examples/*.json` fan-out changes.
**Null convention for this node:** a Port-0 key whose source is not yet
available is emitted as **explicit `null`** (e.g. `timeleft`, `flowSource`,
`manualDemand` outside manual mode), never silently absent. Delta-compression on
Port 0 then drops keys whose value is unchanged since the previous tick.
## Port 0 (process data) — `specificClass.getOutput()` → `outputUtils.formatMsg(..., 'process')`
`msg.topic = config.general.name`. Keys below are the full pre-delta-compression set.
| Key | Source | Type | States tested | Test file |
|---|---|---|---|---|
| `mode` | `getOutput``this.mode` | string (`levelbased`/`manual`/`flowbased`/`none`) | populated (`manual`) | test/basic/specificClass.test.js |
| `manualDemand` | `getOutput``_manualDemand` | number m³/h, `null` outside manual | populated, null | test/basic/specificClass.test.js |
| `direction` | `getOutput``state.direction` | string (`filling`/`draining`/`steady`) | present | test/basic/specificClass.test.js |
| `flowSource` | `getOutput``state.flowSource` | string, `null` when no source | null (pre-child) | test/basic/specificClass.test.js |
| `timeleft` | `getOutput``state.seconds` | number s, `null` when steady | present, null | test/basic/specificClass.test.js |
| `percControl` | `getOutput``controlState.percControl` | number % 0..100 | 0, 25, 50, 75, 85, 100 | test/basic/specificClass.test.js |
| `dryRunLevel` | `_computeSafetyPoints` | number m | populated | test/basic/specificClass.test.js |
| `dryRunSafetyVol` | `_computeSafetyPoints` | number m³ | populated | test/basic/specificClass.test.js |
| `highVolumeSafetyLevel` | `_computeSafetyPoints` | number m | populated | test/basic/specificClass.test.js |
| `highVolumeSafetyVol` | `_computeSafetyPoints` | number m³ | populated | test/basic/specificClass.test.js |
| `predictedOverflowVolume` | `measurements` overflowVolume | number m³ | populated, 0 | test/basic/specificClass.test.js |
| `predictedOverflowRate` | `measurements` flow.overflow | number m³/s | populated, 0 | test/basic/specificClass.test.js |
| `predictedUnderflowVolume` | `measurements` underflowVolume | number m³ | 0 | test/basic/specificClass.test.js |
| `volume.predicted.atequipment.<childId>` | `measurements.getFlattenedOutput` | number m³ | populated | test/basic/specificClass.test.js |
| basin geometry: `heightBasin`, `surfaceArea`, `maxVol`, `minVol`, `maxVolAtOverflow`, `minVolAtInflow`, `minVolAtOutflow`, `volEmptyBasin`, `inflowLevel`, `outflowLevel`, `overflowLevel`, `inletPipeDiameter`, `outletPipeDiameter`, `minHeightBasedOn` | `basin.snapshot()` | number (m/m²/m³) / string | populated | test/basic/specificClass.test.js, test/basic/BasinGeometry.basic.test.js |
## Port 1 (InfluxDB telemetry) — `formatMsg(..., 'influxdb')`
Same key set as Port 0 (formatted via the `influxdb` formatter rather than
`process`). Field names == Port-0 keys; `config.general.name` is the measurement
tag. No Port-1-only fields. Covered transitively by the Port-0 tests above; a
dedicated Port-1 line-protocol assertion is a **gap** (see below).
## Port 2 (registration / control plumbing) — `BaseNodeAdapter._scheduleRegistration`
| Topic | Source | Payload shape | States tested | Test file |
|---|---|---|---|---|
| `child.register` | `BaseNodeAdapter.js:122` | `{ topic:'child.register', payload:<node.id>, positionVsParent, distance }` | — | _(gap — see below)_ |
> Note: the canonical outgoing topic is **`child.register`** (matching the input
> registry). Earlier docs said `registerChild`; that is the deprecated input
> alias, not what this node emits.
## Child-facing events — `measurements.emitter`
Fired as `<type>.<variant>.<position>` when a series receives a value. Parents
subscribe by event name (data-driven, not a fixed catalogue):
| Event | When | Test file |
|---|---|---|
| `volume.predicted.atequipment` | each integrator tick | test/basic/flowAggregator.basic.test.js |
| `level.predicted.atequipment` | recomputed from volume | test/basic/specificClass.test.js |
| `flow.predicted.in` (child `manual-qin`) | `set.inflow` handler | test/basic/measurementRouter.basic.test.js |
| `overflowVolume`/`underflowVolume`/`flow.predicted.overflow` | integrator hits a physical bound | test/basic/flowAggregator.basic.test.js |
## Example-flow function-node fan-out
### examples/02-Dashboard.json :: `fn_status_split` (outputs: 15)
| # | Target widget | Payload | Populated | Degraded/null |
|---|---|---|---|---|
| 0 | ui-text "Mode" | string | ✔ structure | gap |
| 1 | ui-text "Direction" | string | ✔ | gap |
| 2 | ui-text "Level" | number m | ✔ | gap |
| 3 | ui-text "Volume" | number m³ | ✔ | gap |
| 4 | ui-text "Volume %" | number % | ✔ | gap |
| 5 | ui-text "percControl" | number % | ✔ | gap |
| 6 | ui-text "Manual demand" | number m³/h or — | gap | gap |
| 7 | ui-chart "Level (m)" | `{topic,payload:number}` or no-msg | ✔ | gap |
| 8 | ui-chart "Volume (m³)" | ″ | ✔ | gap |
| 9 | ui-chart "Volume %" | ″ | ✔ | gap |
| 10 | ui-chart "Flow (m³/h)" — Inflow | ″ | ✔ | gap |
| 11 | ui-chart "Flow (m³/h)" — Outflow | ″ | ✔ | gap |
| 12 | ui-chart "Flow (m³/h)" — Net | ″ | ✔ | gap |
| 13 | ui-template "Raw output table" | whole object (array) | ✔ | gap |
| 14 | ui-chart "percControl" | `{topic:'percControl',payload:number}` | ✔ | gap |
Populated/structure coverage: test/integration/basic-dashboard-flow.test.js
(asserts output count = 15 and routes outputs 014). **Degraded/empty-input**
coverage (no `payload:null` reaching any `ui-chart`) is still a gap — see below.
## Known coverage gaps (tracked, prospective per the rule)
The output-coverage rule applies prospectively. Outstanding items for this node:
- [ ] Dedicated `test/basic/output-port0.test.js` exercising **every** key above
in both populated and degraded (pre-tick / null) states.
- [ ] Port-1 line-protocol assertion (field names + tag).
- [ ] Port-2 `child.register` payload-shape test.
- [ ] `fn_status_split` degraded/empty-input fan-out test (no `payload:null` to
any `ui-chart`) — the failure mode the rule was written for. The structure
test in `basic-dashboard-flow.test.js` covers the populated path only.

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) { function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 }; const calls = { setDemand: [], handleInput: [], turnOff: 0 };
return { return {
config: { general: { name } }, config: { general: { name } },
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
handleInput: async (...args) => { calls.handleInput.push(args); }, handleInput: async (...args) => { calls.handleInput.push(args); },
turnOffAllMachines: () => { calls.turnOff += 1; }, turnOffAllMachines: () => { calls.turnOff += 1; },
_calls: calls, _calls: calls,
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
assert.equal(state.percControl, 0); assert.equal(state.percControl, 0);
for (const g of Object.values(ctx.machineGroups)) { for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group'); 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 // Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
// is commanded to 0 % (not "unchanged"). MGC still receives the command; // hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
// only the explicit minLevel hard-stop path skips handleInput. // MGC doesn't kick a pump on at flow.min before the gate is ever passed.
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => { test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
const ctx = makeCtx(1.5); const ctx = makeCtx(1.5);
const state = { percControl: 17 }; const state = { percControl: 17 };
await levelBased.run(ctx, state); 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)) { for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0); assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group'); assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
} }
}); });
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 ctx = makeCtx(2);
const state = { percControl: null }; const state = { percControl: null };
await levelBased.run(ctx, state); await levelBased.run(ctx, state);
assert.equal(state.percControl, 0); 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 () => { 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); 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 ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
const state = { percControl: null }; const state = { percControl: null };
await levelBased.run(ctx, state); await levelBased.run(ctx, state);
assert.equal(state.percControl, 50); assert.equal(state.percControl, 50);
for (const g of Object.values(ctx.machineGroups)) { for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.handleInput.length, 1, 'one forward per group'); assert.equal(g._calls.setDemand.length, 1, 'one forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]); 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); 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 () => { test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
const ctx = makeCtx(NaN); const ctx = makeCtx(NaN);
let warned = false; 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); 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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { UnitPolicy } = require('generalFunctions');
const manual = require('../../src/control/manual'); const manual = require('../../src/control/manual');
const unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s' },
output: { flow: 'm3/s' },
requireUnitForTypes: [],
});
function makeGroup(name) { function makeGroup(name) {
const calls = { handleInput: [] }; const calls = { handleInput: [] };
return { return {
@@ -28,15 +35,15 @@ function makeLogger() {
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }; 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 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)) { for (const g of Object.values(groups)) {
assert.equal(g._calls.handleInput.length, 1); 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 () => { test('run() is a no-op (manual mode is event-driven)', async () => {
const groups = { a: makeGroup('A') }; 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 }); await manual.run(ctx, { percControl: 0 });
assert.equal(groups.a._calls.handleInput.length, 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}`); 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 () => { test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
const { fa, measurements } = makeAggregator(); const { fa, measurements } = makeAggregator();
measurements.type('flow').variant('measured').position('in').child('m') 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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { MeasurementContainer } = require('generalFunctions');
const PumpingStation = require('../../src/specificClass'); const PumpingStation = require('../../src/specificClass');
// machineGroups is a registry-backed getter (declareChildGetter) — direct // machineGroups is a registry-backed getter (declareChildGetter) — direct
// assignment is no longer possible. Tests inject mock groups through the // assignment is no longer possible. Tests inject mock groups through the
// real registration handshake so the registry remains the source of truth. // real registration handshake so the registry remains the source of truth.
function registerMockGroup(ps, id, behavior = {}) { function registerMockGroup(ps, id, behavior = {}) {
const calls = { handleInput: [], turnOff: 0 }; const calls = { setDemand: [], handleInput: [], turnOff: 0 };
const mock = { const mock = {
config: { config: {
general: { id, name: id }, general: { id, name: id },
@@ -21,6 +22,8 @@ function registerMockGroup(ps, id, behavior = {}) {
emitter: { on: () => {} }, emitter: { on: () => {} },
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {}, setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
}, },
setDemand: behavior.setDemand
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
handleInput: behavior.handleInput handleInput: behavior.handleInput
|| (async (...args) => { calls.handleInput.push(args); }), || (async (...args) => { calls.handleInput.push(args); }),
turnOffAllMachines: behavior.turnOffAllMachines turnOffAllMachines: behavior.turnOffAllMachines
@@ -82,6 +85,39 @@ function makeConfig(overrides = {}) {
return base; 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) => { test('Basin geometry — derived values', async (t) => {
const ps = new PumpingStation(makeConfig()); const ps = new PumpingStation(makeConfig());
@@ -163,7 +199,10 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel')); 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({ const ps = new PumpingStation(makeConfig({
control: { control: {
mode: 'levelbased', mode: 'levelbased',
@@ -171,7 +210,8 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' }, 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', () => { await t.test('outflowLevel >= inflowLevel flagged', () => {
@@ -261,51 +301,77 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
assert.equal(mock._calls.turnOff, 1); assert.equal(mock._calls.turnOff, 1);
}); });
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => { await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
const ps = new PumpingStation(makeConfig()); const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand ps.percControl = 42; // simulated previous demand
const mock = registerMockGroup(ps, 'mgc1'); const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2 ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased(); await ps._controlLevelBased();
assert.equal(ps.percControl, 0); 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 ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1'); 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'); await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0); 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 () => { await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
const ps = new PumpingStation(makeConfig());
const 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 () => {
const ps = new PumpingStation(makeConfig()); const ps = new PumpingStation(makeConfig());
registerMockGroup(ps, 'mgc1'); 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); ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased(); await ps._controlLevelBased();
assert.ok(ps.percControl > 0); 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(); await ps._controlLevelBased();
// Without shift the foot is inflowLevel → 0% in the hold zone. assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
assert.equal(ps.percControl, 0);
}); });
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => { await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4. // 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. // shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
// shiftLevel=3.5 ⇒ held output starts ramping down at this level. // shiftLevel=3.5 ⇒ held output starts ramping down at this level.
const ps = new PumpingStation(makeConfig({ const ps = new PumpingStation(makeConfig({
@@ -313,7 +379,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
mode: 'levelbased', mode: 'levelbased',
allowedModes: new Set(['levelbased']), allowedModes: new Set(['levelbased']),
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, enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
}, },
}, },
@@ -355,7 +421,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
mode: 'levelbased', mode: 'levelbased',
allowedModes: new Set(['levelbased']), allowedModes: new Set(['levelbased']),
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, enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
}, },
}, },
@@ -381,7 +449,9 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
control: { control: {
mode: 'levelbased', mode: 'levelbased',
allowedModes: new Set(['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'); registerMockGroup(ps, 'mgc1');

View File

@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
function loadDashboardFlow() { 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')); return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
} }
@@ -22,27 +22,29 @@ function makeContextStub() {
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => { test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
const flow = loadDashboardFlow(); const flow = loadDashboardFlow();
const ps = flow.find((n) => n.id === 'ps_node_basic'); const ps = flow.find((n) => n.type === 'pumpingStation');
const parser = flow.find((n) => n.id === 'ps_parse_output'); const parser = flow.find((n) => n.id === 'fn_status_split');
const levelChart = flow.find((n) => n.id === 'ps_chart_level'); const levelChart = flow.find((n) => n.id === 'ui_chart_level');
const demandChart = flow.find((n) => n.id === 'ps_chart_demand'); 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.type, 'pumpingStation');
assert.equal(ps.controlMode, 'levelbased'); assert.equal(ps.controlMode, 'levelbased');
assert.equal(ps.levelCurveType, 'linear'); assert.equal(ps.levelCurveType, 'linear');
assert.equal(ps.inletPipeDiameter, 0.4); assert.equal(ps.inletPipeDiameter, 0.3);
assert.equal(ps.outletPipeDiameter, 0.3); assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist'); assert.ok(parser, 'fn_status_split should exist');
assert.equal(parser.outputs, 6); assert.equal(parser.outputs, 15);
assert.equal(levelChart.type, 'ui-chart'); 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', () => { test('basic dashboard parser routes process fields to charts and state text', () => {
const flow = loadDashboardFlow(); const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output'); const parser = flow.find((n) => n.id === 'fn_status_split');
assert.ok(parser, 'ps_parse_output should exist'); assert.ok(parser, 'fn_status_split should exist');
const func = new Function('msg', 'context', 'node', parser.func); const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub(); const context = makeContextStub();
@@ -56,8 +58,12 @@ test('basic dashboard parser routes process fields to charts and state text', ()
payload: { payload: {
'level.predicted.atequipment.default': 3.25, 'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5, '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, 'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25, percControl: 25,
mode: 'levelbased',
direction: 'filling', direction: 'filling',
safetyState: 'normal', safetyState: 'normal',
isOverflowing: false, isOverflowing: false,
@@ -66,22 +72,26 @@ test('basic dashboard parser routes process fields to charts and state text', ()
}, context, node); }, context, node);
assert.ok(Array.isArray(out)); assert.ok(Array.isArray(out));
assert.equal(out.length, 6); assert.equal(out.length, 15);
assert.equal(out[0].topic, 'level'); assert.equal(out[0].payload, 'levelbased');
assert.equal(out[0].payload, 3.25); assert.equal(out[1].payload, 'filling');
assert.equal(out[1].topic, 'volume'); assert.equal(out[2].payload, '3.25 m');
assert.equal(out[1].payload, 32.5); assert.equal(out[3].payload, '32.50 m³');
assert.equal(out[2].topic, 'demand'); assert.equal(out[4].payload, '65.00 %');
assert.equal(out[2].payload, 25); assert.equal(out[5].payload, '25.0 %');
assert.equal(out[3].topic, 'net_flow'); assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
assert.equal(out[3].payload, 0.003); assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
assert.match(out[4].payload, /normal/); assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
assert.match(out[5].payload, /level=3.25 m/); 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));
assert.deepEqual(out[14], { topic: 'percControl', payload: 25 });
}); });
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => { test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
const flow = loadDashboardFlow(); 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 func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub(); const context = makeContextStub();
const node = { send() {} }; const node = { send() {} };
@@ -89,6 +99,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); func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
const out = func({ payload: { percControl: 20 } }, context, node); const out = func({ payload: { percControl: 20 } }, context, node);
assert.equal(out[0].payload, 3.1); assert.equal(out[2].payload, '3.10 m');
assert.equal(out[2].payload, 20); assert.equal(out[5].payload, '20.0 %');
}); });

View File

@@ -37,7 +37,11 @@ function makeConfig() {
mode: 'levelbased', mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']), allowedModes: new Set(['levelbased', 'manual']),
levelbased: { 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, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80, enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
}, },

View File

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

View File

@@ -1,6 +1,6 @@
# Reference &mdash; Contracts # Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-b825ac1-blue) ![autogen](https://img.shields.io/badge/sections-autogenerated-orange) ![code-ref](https://img.shields.io/badge/code--ref-a83a85e-blue) ![autogen](https://img.shields.io/badge/sections-autogenerated-orange)
> [!NOTE] > [!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`. > 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`.
@@ -11,7 +11,11 @@
## Topic contract ## 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. The **Unit** column reflects each descriptor's declared unit (via the `unit: 'm3/h'` shorthand or the legacy `units: { measure, default }`; the measure is derived from the unit). The default unit is what the commandRegistry coerces incoming values to before the handler runs.
**Command envelope (all EVOLV nodes).** Every command shares one envelope on top of `msg.topic`:
- **Value + unit** — send `msg.payload` as a number (with optional sibling `msg.unit`) **or** as `{ value, unit }`. The registry always converts the value to the descriptor's unit before the handler; numeric strings are converted too. A missing unit assumes the descriptor default.
- **`msg.origin`** — the control authority that issued the command: `parent` (automation/parent controller, the default), `GUI` (SCADA/HMI operator), or `fysical` (physical buttons). On nodes with a control mode, the mode's `allowedSources` decides which origins are accepted; releasing control is done by changing the mode.
<!-- BEGIN AUTOGEN: topic-contract --> <!-- BEGIN AUTOGEN: topic-contract -->
@@ -19,14 +23,56 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
|---|---|---|---|---| |---|---|---|---|---|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. | | `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. | | `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.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. | | `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.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.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. | | `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
<!-- END AUTOGEN: topic-contract --> <!-- END AUTOGEN: topic-contract -->
### Input message examples
One worked `msg` per accepted topic. Send these into **Port 0**. For unit-bearing
topics the commandRegistry converts `msg.unit` (or a `{ value, unit }` payload) to
the default unit *before* the handler runs &mdash; so the unit is optional and any
[compatible unit](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) is accepted.
```js
// 1. set.mode — switch control strategy
msg = { topic: 'set.mode', payload: 'manual' }; // manual | levelbased | flowbased | none
// 2. child.register — register a child (usually arrives on Port 2 from the child;
// this is the manual form). payload = the child node's Node-RED id.
msg = { topic: 'child.register', payload: 'a1b2c3d4.ef567', positionVsParent: 'upstream' };
// positionVsParent: upstream | downstream | atequipment (or in | out for predicted-flow children)
// 3. cmd.calibrate.volume — seed the predicted-volume integrator (default m³)
msg = { topic: 'cmd.calibrate.volume', payload: 12.5 }; // 12.5 m³
msg = { topic: 'cmd.calibrate.volume', payload: 12500, unit: 'L' }; // 12 500 L → auto-converted to 12.5 m³
// 4. cmd.calibrate.level — seed the predicted level (default m)
msg = { topic: 'cmd.calibrate.level', payload: 1.8 }; // 1.8 m
// 5. set.inflow — push a measured inflow (default m³/h)
msg = { topic: 'set.inflow', payload: 45 }; // 45 m³/h
msg = { topic: 'set.inflow', payload: 12.5, unit: 'L/s' }; // 12.5 L/s → 45 m³/h
msg = { topic: 'set.inflow', payload: { value: 45, unit: 'm3/h' }, timestamp: 1716998400000 };
// 6. set.outflow — push a measured/forced outflow (default m³/h)
msg = { topic: 'set.outflow', payload: 30 }; // 30 m³/h drawn from the basin
// 7. set.demand — operator outflow setpoint (default m³/h); ignored unless mode === 'manual'
msg = { topic: 'set.demand', payload: 120 }; // 120 m³/h
// Built-in (every EVOLV node): query.units — ask which units each topic accepts.
// Replies on Port 0 with { topic:'query.units', payload:{ node, units } }.
msg = { topic: 'query.units', payload: null };
```
> Deprecated aliases behave identically and log a one-time warning, e.g.
> `{ topic: 'q_in', payload: 45 }` ≡ `set.inflow`, `{ topic: 'Qd', payload: 120 }` ≡ `set.demand`.
--- ---
## Data model &mdash; `getOutput()` shape ## Data model &mdash; `getOutput()` shape
@@ -39,35 +85,88 @@ Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUt
|---|---|---|---| |---|---|---|---|
| `direction` | string | — | `"steady"` | | `direction` | string | — | `"steady"` |
| `dryRunLevel` | number | — | `0.20400000000000001` | | `dryRunLevel` | number | — | `0.20400000000000001` |
| `dryRunSafetyVol` | number | — | `0.20400000000000001` | | `dryRunSafetyVol` | number | — | `2.55` |
| `flowSource` | null | — | `null` | | `flowSource` | null | — | `null` |
| `heightBasin` | number | m | `1` | | `heightBasin` | number | m | `4` |
| `highVolumeSafetyLevel` | number | — | `2.45` | | `highVolumeSafetyLevel` | number | — | `3.7239999999999998` |
| `highVolumeSafetyVol` | number | — | `2.45` | | `highVolumeSafetyVol` | number | — | `46.55` |
| `inflowLevel` | number | m | `2` | | `inflowLevel` | number | m | `1.5` |
| `inletPipeDiameter` | number | — | `0.4` | | `inletPipeDiameter` | number | — | `0.4` |
| `maxVol` | number | m3 | `1` | | `manualDemand` | null | | `null` |
| `maxVolAtOverflow` | number | m3 | `2.5` | | `maxVol` | number | m3 | `50` |
| `maxVolAtOverflow` | number | m3 | `47.5` |
| `minHeightBasedOn` | string | — | `"outlet"` | | `minHeightBasedOn` | string | — | `"outlet"` |
| `minVol` | number | m3 | `0.2` | | `minVol` | number | m3 | `2.5` |
| `minVolAtInflow` | number | m3 | `2` | | `minVolAtInflow` | number | m3 | `18.75` |
| `minVolAtOutflow` | number | m3 | `0.2` | | `minVolAtOutflow` | number | m3 | `2.5` |
| `mode` | string | — | `"levelbased"` |
| `outflowLevel` | number | m | `0.2` | | `outflowLevel` | number | m | `0.2` |
| `outletPipeDiameter` | number | — | `0.4` | | `outletPipeDiameter` | number | — | `0.4` |
| `overflowLevel` | number | m | `2.5` | | `overflowLevel` | number | m | `3.8` |
| `percControl` | number | % | `0` | | `percControl` | number | % | `0` |
| `predictedOverflowRate` | number | — | `0` | | `predictedOverflowRate` | number | — | `0` |
| `predictedOverflowVolume` | number | — | `0` | | `predictedOverflowVolume` | number | — | `0` |
| `predictedUnderflowVolume` | number | — | `0` | | `predictedUnderflowVolume` | number | — | `0` |
| `surfaceArea` | number | m2 | `1` | | `surfaceArea` | number | m2 | `12.5` |
| `timeleft` | null | s | `null` | | `timeleft` | null | s | `null` |
| `volEmptyBasin` | number | m3 | `1` | | `volEmptyBasin` | number | m3 | `50` |
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` | | `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `2.5` |
<!-- END AUTOGEN: data-model --> <!-- 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). 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).
> [!NOTE]
> Two control-state keys carry the live operating mode rather than a measurement:
> - `mode` &mdash; string, the active control strategy (`levelbased` / `manual` / `flowbased` / `none`). Echoes the most recent `set.mode` input.
> - `manualDemand` &mdash; number (m³/h) or `null`. The operator outflow setpoint last accepted via `set.demand`; `null` outside `manual` mode.
### Output message examples
The node emits on three ports every tick (`outputUtils.formatMsg`). Port 0 / Port 1
fire only when at least one field changed (delta-compression); Port 2 fires once at
startup. `topic` is the station's configured name (here `"PS-Influent-01"`).
```js
// Port 0 — process data. payload = only the keys that changed this tick.
msg = {
topic: 'PS-Influent-01',
payload: {
mode: 'levelbased',
direction: 'filling',
percControl: 25,
'level.predicted.atequipment.default': 3.25, // m
'volume.predicted.atequipment.default': 32.5, // m³
timeleft: 400, // s, or null when steady
manualDemand: null // m³/h, or null outside manual mode
}
};
// Port 1 — InfluxDB telemetry. Same changed fields, wrapped for the InfluxDB node.
msg = {
topic: 'PS-Influent-01',
payload: {
measurement: 'PS-Influent-01',
fields: { percControl: 25, 'volume.predicted.atequipment.default': 32.5 },
tags: { id: 'a1b2c3d4.ef567', softwareType: 'pumpingstation', type: 'pumpingStation' },
timestamp: '2026-05-29T10:00:00.000Z' // Date
}
};
// Port 2 — registration handshake, sent once at startup to the upstream parent.
msg = {
topic: 'child.register',
payload: 'a1b2c3d4.ef567', // this node's id
positionVsParent: 'atEquipment',
distance: null
};
```
> **Child-facing events** are not Port messages &mdash; they fire on
> `source.measurements.emitter` as `<type>.<variant>.<position>`, e.g. event
> `volume.predicted.atequipment` with payload `{ value: 32.5, unit: 'm3', timestamp }`.
> Parents subscribe by event name.
--- ---
## Configuration schema &mdash; editor form to config keys ## Configuration schema &mdash; editor form to config keys