3 Commits

Author SHA1 Message Date
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
7 changed files with 242 additions and 29 deletions

View File

@@ -25,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

@@ -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

@@ -68,11 +68,14 @@
ns.setNumberField('node-input-stopLevel', node.stopLevel); ns.setNumberField('node-input-stopLevel', node.stopLevel);
// holdLevel defaults to startLevel when omitted (no hold band). Show // holdLevel defaults to startLevel when omitted (no hold band). Show
// the saved value if there is one; otherwise mirror startLevel so the // the saved value if there is one; otherwise mirror startLevel so the
// user immediately sees the "no hold band" baseline. // 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', ns.setNumberField('node-input-holdLevel',
Number.isFinite(node.holdLevel) ? node.holdLevel : node.startLevel); Number.isFinite(holdNum) ? holdNum : node.startLevel);
const deadZoneNum = parseFloat(node.deadZoneKeepAlivePercent);
ns.setNumberField('node-input-deadZoneKeepAlivePercent', ns.setNumberField('node-input-deadZoneKeepAlivePercent',
Number.isFinite(node.deadZoneKeepAlivePercent) ? node.deadZoneKeepAlivePercent : 1); 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);

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

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

@@ -35,7 +35,7 @@ test('basic dashboard flow contains the pumpingStation node and trend widgets',
assert.equal(ps.inletPipeDiameter, 0.3); assert.equal(ps.inletPipeDiameter, 0.3);
assert.equal(ps.outletPipeDiameter, 0.3); assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'fn_status_split should exist'); assert.ok(parser, 'fn_status_split should exist');
assert.equal(parser.outputs, 14); assert.equal(parser.outputs, 15);
assert.equal(levelChart.type, 'ui-chart'); assert.equal(levelChart.type, 'ui-chart');
assert.equal(volumeChart.type, 'ui-chart'); assert.equal(volumeChart.type, 'ui-chart');
assert.equal(flowChart.type, 'ui-chart'); assert.equal(flowChart.type, 'ui-chart');
@@ -72,7 +72,7 @@ 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, 14); assert.equal(out.length, 15);
assert.equal(out[0].payload, 'levelbased'); assert.equal(out[0].payload, 'levelbased');
assert.equal(out[1].payload, 'filling'); assert.equal(out[1].payload, 'filling');
assert.equal(out[2].payload, '3.25 m'); assert.equal(out[2].payload, '3.25 m');
@@ -86,6 +86,7 @@ test('basic dashboard parser routes process fields to charts and state text', ()
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 }); assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 }); assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
assert.ok(Array.isArray(out[13].payload)); 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', () => {

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`.
@@ -19,14 +19,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 +81,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