Compare commits

13 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:14 +02:00
27 changed files with 877 additions and 158 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.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
| `set.outflow` | `q_out` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a measured outflow value into the basin balance. Same payload conventions as `set.inflow`. |
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
Aliases log a one-time deprecation warning the first time they fire.

View File

@@ -166,8 +166,8 @@
"id": "b30af582f935bcb7",
"type": "comment",
"z": "77f00aef1c966167",
"name": "PumpingStation 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.",
"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 \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,
"y": 320,
"wires": []
@@ -332,13 +332,13 @@
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"group": "ui_group_ctrl",
"name": "Inflow 60 m³/h",
"label": "Inflow 60 m³/h",
"name": "Inflow 60 m\u00b3/h",
"label": "Inflow 60 m\u00b3/h",
"order": 3,
"width": "3",
"height": "1",
"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": "",
"bgcolor": "",
"icon": "south",
@@ -360,13 +360,13 @@
"z": "77f00aef1c966167",
"g": "a9f9b38b0e00c1d7",
"group": "ui_group_ctrl",
"name": "Outflow 80 m³/h",
"label": "Outflow 80 m³/h",
"name": "Outflow 80 m\u00b3/h",
"label": "Outflow 80 m\u00b3/h",
"order": 4,
"width": "3",
"height": "1",
"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": "",
"bgcolor": "",
"icon": "north",
@@ -388,13 +388,13 @@
"z": "77f00aef1c966167",
"g": "42bf82c87d05f498",
"group": "ui_group_ctrl",
"name": "Demand 40 m³/h",
"label": "Demand 40 m³/h (manual)",
"name": "Demand 40 m\u00b3/h",
"label": "Demand 40 m\u00b3/h (manual)",
"order": 5,
"width": "6",
"height": "1",
"emulateClick": false,
"tooltip": "Operator outflow demand only forwarded when mode = manual",
"tooltip": "Operator outflow demand \u2014 only forwarded when mode = manual",
"color": "",
"bgcolor": "",
"icon": "speed",
@@ -416,13 +416,13 @@
"z": "77f00aef1c966167",
"g": "234bdce20170061a",
"group": "ui_group_ctrl",
"name": "Calibrate V=25 m³",
"label": "Calibrate V = 25 m³",
"name": "Calibrate V=25 m\u00b3",
"label": "Calibrate V = 25 m\u00b3",
"order": 6,
"width": "3",
"height": "1",
"emulateClick": false,
"tooltip": "Snap the predicted-volume integrator to 25 m³",
"tooltip": "Snap the predicted-volume integrator to 25 m\u00b3",
"color": "",
"bgcolor": "",
"icon": "tune",
@@ -472,8 +472,8 @@
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"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",
"outputs": 14,
"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": 15,
"timeout": 0,
"noerr": 0,
"initialize": "",
@@ -523,6 +523,9 @@
],
[
"ui_tpl_raw"
],
[
"ui_chart_pumping_perccontrol"
]
]
},
@@ -740,8 +743,8 @@
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Volume (m³)",
"label": "Volume (m³)",
"name": "Volume (m\u00b3)",
"label": "Volume (m\u00b3)",
"order": 2,
"width": 6,
"height": 4,
@@ -754,7 +757,7 @@
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisLabel": "m³",
"yAxisLabel": "m\u00b3",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
@@ -862,8 +865,8 @@
"z": "77f00aef1c966167",
"g": "grp_status_panel",
"group": "ui_group_trends",
"name": "Flow (m³/h)",
"label": "Flow (m³/h) — Inflow / Outflow / Net",
"name": "Flow (m\u00b3/h)",
"label": "Flow (m\u00b3/h) \u2014 Inflow / Outflow / Net",
"order": 4,
"width": 6,
"height": 4,
@@ -876,7 +879,7 @@
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisLabel": "m³/h",
"yAxisLabel": "m\u00b3/h",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
@@ -1029,7 +1032,7 @@
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"positionIcon": "\u22a5",
"hasDistance": false,
"distance": "",
"controlMode": "levelbased",
@@ -1066,5 +1069,68 @@
"modules": {
"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

@@ -23,7 +23,7 @@
<script>//test
RED.nodes.registerType("pumpingStation", {
category: "EVOLV",
color: "#0c99d9", // color for the node based on the S88 schema
color: "#8B4513",
defaults: {
name: { value: "" },
@@ -86,6 +86,8 @@
shiftArmPercent: { value: 95 },
startLevel: { value: 1 }, // m, pump-on threshold (engagement edge)
stopLevel: { value: 0.5 }, // m, pump-off threshold (hysteresis fall-back)
holdLevel: { value: 1 }, // m, ramp 0%-foot; defaults to startLevel (= no hold zone)
deadZoneKeepAlivePercent: { value: 1 }, // % emitted across [stopLevel, startLevel] keep-alive band
minLevel: { value: 0.3 }, // m, hard-stop (just above outflow pipe top)
maxLevel: { value: 3.8 }, // m, 100% demand saturation
flowSetpoint: { value: null },
@@ -418,6 +420,11 @@
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#27AE60" data-couples-line="ps-mode-line-holdLevel">
<div><label>holdLevel</label><div class="ps-sub">0 % ramp foot — leave at startLevel for no hold band</div></div>
<input type="number" id="node-input-holdLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
@@ -475,6 +482,7 @@
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
<line id="ps-mode-line-holdLevel" y1="24" y2="140" stroke="#27AE60" stroke-dasharray="2 2" />
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
@@ -565,6 +573,7 @@
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>

View File

@@ -4,7 +4,14 @@
//
// Invariants enforced (level-space, bottom → top):
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
// dryRunLevel ≤ minLevel ≤ startLevel ≤ holdLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
//
// startLevel is INTENTIONALLY not constrained against inflowLevel: setting
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
// configuration where the upstream pipe network is used as overflow storage
// before pumping engages. holdLevel (optional, defaults to startLevel when
// omitted) is the 0 % ramp foot — pumps engage at startLevel but hold at
// min flow until level rises through holdLevel.
//
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
// The validator recomputes them so a config that places minLevel below the
@@ -56,14 +63,26 @@ function validateThresholdOrdering(basin, levelbased, safety) {
const points = computeSafetyPoints(basin, safety);
const { dryRunLevel, highVolumeSafetyLevel } = points;
// holdLevel is optional — when omitted (null/undefined/NaN) it equals
// startLevel at runtime, so skip both holdLevel-related checks in that
// case (the canonical engine semantics still hold). Explicit null/undefined
// check first so `Number(null) === 0` doesn't accidentally flag a default
// schema value as a real operator-provided one.
const rawHold = lvl.holdLevel;
const holdLevelProvided = rawHold != null && Number.isFinite(Number(rawHold));
const holdLevel = holdLevelProvided ? Number(rawHold) : null;
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
...(holdLevelProvided ? [
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
] : []),
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,13 +23,16 @@
const svg = document.getElementById('ps-levelbased-mode-diagram');
if (!svg) return;
const start = fNum('startLevel');
const hold = fNum('holdLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
// Optional stopLevel — explicit pump-off threshold. Drawn as its
// own marker line; does NOT shift the ramp foot. Must be < startLevel
// for the marker to render.
// own marker line; does NOT shift the ramp foot. Renders as long as
// the typed value is a non-negative number — the start-vs-stop
// ordering check belongs to the validation ribbon, not the visual
// marker (otherwise the line vanishes while the user is mid-edit).
const stopRaw = fNum('stopLevel');
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 ? stopRaw : null;
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
// (no separate input). Below dryRunLevel the runtime hard-stops;
// we draw it as the leftmost vertical marker so the user sees
@@ -91,18 +94,17 @@
};
// Up curve. Engagement edge is startLevel (pump-on threshold); the
// ramp foot is inflowLevel — matching the runtime in
// _controlLevelBased, which scales demand over [inflowLevel, maxLevel].
// The OFF baseline is drawn for level < startLevel; between startLevel
// and inflowLevel demand sits flat at 0 % (system armed but not yet
// ramping); from inflowLevel demand ramps to 100 % at maxLevel.
// ramp foot is holdLevel, with a Math.max(startLevel, …) safety
// floor — matching the runtime in levelBased.run.
// - holdLevel == startLevel (default): no hold band, 0..100 % across
// [startLevel, maxLevel].
// - holdLevel > startLevel: pumps engaged across [startLevel,
// holdLevel] at 0 % (= MGC flow.min), then 0..100 % across
// [holdLevel, maxLevel].
const up = document.getElementById('ps-mode-curve-up');
const down = document.getElementById('ps-mode-curve-down');
const downLabel = document.getElementById('ps-mode-curve-down-label');
// Runtime falls back to startLevel when inflowLevel is missing
// (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;
const upFoot = Number.isFinite(hold) && hold > start ? hold : start;
if (up) up.setAttribute('points', buildPath(start, upFoot, max));
// Shifted-DOWN curve (only when shift enabled): represents the
@@ -167,6 +169,7 @@
['dryRunLevel', dryRun],
['startLevel', start],
['stopLevel', stop],
['holdLevel', hold],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],

View File

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

View File

@@ -50,6 +50,15 @@
node.logCurveFactor = parseNum('node-input-logCurveFactor');
node.startLevel = parseNum('node-input-startLevel');
node.maxLevel = parseNum('node-input-maxLevel');
// Persist as numbers — Node-RED's auto-form-binding would store these as
// strings, and oneditprepare's setNumberField rejects non-Number values,
// so the input would blank out on reopen.
const stopLevelVal = parseNum('node-input-stopLevel');
node.stopLevel = Number.isFinite(stopLevelVal) ? stopLevelVal : null;
const holdLevelVal = parseNum('node-input-holdLevel');
if (Number.isFinite(holdLevelVal)) node.holdLevel = holdLevelVal;
const deadZoneVal = parseNum('node-input-deadZoneKeepAlivePercent');
if (Number.isFinite(deadZoneVal)) node.deadZoneKeepAlivePercent = deadZoneVal;
// minLevel is no longer a user input — it's the derived dryRunLevel
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
// uses node.minLevel as the unconditional STOP threshold; we set it

View File

@@ -57,6 +57,32 @@ class FlowAggregator {
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
}
// Pick the best-available variant for one side of the basin balance.
// Mirrors selectBestNetFlow's variant precedence (measured first, then
// predicted) but resolves each side independently — so a real measured
// upstream sensor + a predicted pump outflow both feed the integrator.
// Returns the summed flow at the requested positions. The first variant
// that has any registered measurement at one of those positions wins,
// even if its sum is 0 (a sensor that reads 0 is still data).
_pickFlowSum(positions, flowUnit = 'm3/s') {
const buckets = this.measurements.measurements?.flow;
if (!buckets) return { sum: 0, variant: null };
for (const variant of this.flowVariants) {
const variantBucket = buckets[variant];
if (!variantBucket) continue;
const hasAny = positions.some((pos) => {
const posBucket = variantBucket[pos];
return posBucket && Object.keys(posBucket).length > 0;
});
if (!hasAny) continue;
return {
sum: this.measurements.sum('flow', variant, positions, flowUnit) || 0,
variant,
};
}
return { sum: 0, variant: null };
}
update() {
const flowUnit = 'm3/s';
const now = Date.now();
@@ -64,8 +90,13 @@ class FlowAggregator {
// Synthetic spill flow lives at its OWN position ('overflow') —
// not as a child of 'out'. That keeps it out of the operational
// outflow sum here so no self-subtraction is needed.
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
// Inflow + outflow are resolved per-side: a real measured upstream
// sensor (variant=measured) + a predicted pump-curve outflow
// (variant=predicted) is the common realistic mix.
const inflowPick = this._pickFlowSum(this.flowPositions.inflow, flowUnit);
const outflowPick = this._pickFlowSum(this.flowPositions.outflow, flowUnit);
const inflow = inflowPick.sum;
const outflowReal = outflowPick.sum;
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };

View File

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

View File

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

View File

@@ -18,15 +18,21 @@ class PumpingStation extends BaseDomain {
static name = 'pumpingStation';
// Internal math runs in m3/s for flow and m for level so the volume
// integrator (flow × dt) is unit-consistent. Strict canonicals make
// unit drift in child-fed measurements an explicit error.
// integrator (flow × dt) is unit-consistent canonical stays m3/s, the
// platform-wide convention every cross-node consumer (MGC demand math,
// physics-sanity) assumes. Strict canonicals make unit drift in child-fed
// measurements an explicit error.
// Output flow / netFlowRate are emitted in m3/h so telemetry/dashboard
// series land on the same axis as the rest of the pump group (verified
// slice #47); the m3/s→m3/h presentation conversion happens at the output
// boundary only — it never touches the canonical integrator basis.
// overflowVolume / underflowVolume are listed in output so the
// MeasurementContainer keeps the integrator's m³ unit on those streams
// (FlowAggregator writes spill / underflow per tick).
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: {
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
flow: 'm3/h', netFlowRate: 'm3/h', level: 'm', volume: 'm3',
overflowVolume: 'm3', underflowVolume: 'm3',
},
requireUnitForTypes: [],
@@ -146,6 +152,7 @@ class PumpingStation extends BaseDomain {
levelVariants: this.levelVariants,
volVariants: this.volVariants,
flowThreshold: this.flowThreshold,
unitPolicy: this.unitPolicy,
host: this,
};
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
@@ -262,7 +269,7 @@ class PumpingStation extends BaseDomain {
};
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
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 manualPart = this.mode === 'manual' && Number.isFinite(this._manualDemand)
? `Qd=${this._manualDemand.toFixed(0)} m³/h` : null;
@@ -285,14 +292,32 @@ class PumpingStation extends BaseDomain {
const measurementType = child.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
child.measurements.emitter.on(eventName, (eventData = {}) => {
const handle = (eventData = {}) => {
this.logger.debug(
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
);
if (measurementType === 'level') {
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
return;
}
this.measurements.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
});
};
child.measurements.emitter.on(eventName, handle);
// Seed from the child's current value. The emitter only delivers FUTURE
// updates, so a parent that registers after the child already emitted
// (e.g. a once-only inject that fired during startup before this
// subscription existed) would otherwise never see that value. Replaying
// the last sample makes a late subscriber pick up the present state.
const series = child.measurements
.type(measurementType).variant('measured').position(position).get?.();
const sample = series?.getLaggedSample?.(0);
if (sample && sample.value != null) {
handle({ ...sample, childName: child.config.general.name });
}
}
_subscribePredictedFlow(child) {

View File

@@ -0,0 +1,85 @@
// Throwaway probe — exercises the exact path:
// measurement child writes flow.measured.upstream → pumpingStation parent
// subscribes → getOutput() (≡ what Port 0 emits).
// Run with: node --test test/basic/_probe_upstream_emit.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
const { MeasurementContainer, configManager } = require('generalFunctions');
const EventEmitter = require('node:events');
// Minimal PumpingStation config — matches the editor defaults shape.
function makePsConfig() {
const ui = {
name: 'PS', basinVolume: 50, basinHeight: 5,
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
minHeightBasedOn: 'outlet',
controlMode: 'levelbased',
minLevel: 1, startLevel: 2, maxLevel: 4,
levelCurveType: 'linear',
processOutputFormat: 'process', dbaseOutputFormat: 'influxdb',
};
const cm = new configManager();
// Use the same buildConfig pipeline the runtime uses.
return cm.buildConfig('pumpingStation', ui, 'ps-probe', {
basin: {
volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
},
hydraulics: { minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
},
safety: {},
});
}
// Fake measurement child that looks exactly like the real one to the router:
// - softwareType 'measurement'
// - config.asset.type = 'flow'
// - config.functionality.positionVsParent = 'upstream'
// - .measurements is a real MeasurementContainer with a real emitter
function makeMeasurementChild(id = 'meas-probe') {
const measurements = new MeasurementContainer({
autoConvert: true,
preferredUnits: { flow: 'm3/s' },
});
// Real container ships an emitter; sanity check.
assert.ok(measurements.emitter instanceof EventEmitter || typeof measurements.emitter?.on === 'function');
return {
id,
source: {
config: {
general: { id, name: id },
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
asset: { type: 'flow' },
},
measurements,
},
};
}
test('PROBE: measurement child writes flow.measured.upstream — parent surfaces it on getOutput()', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeMeasurementChild();
// Register the child the same way the runtime does.
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
// Drive a value through the child's MeasurementContainer the way Channel
// does — type/variant/position chain then .value().
child.source.measurements
.type('flow').variant('measured').position('upstream')
.value(12, Date.now(), 'm3/h'); // 12 m³/h ≈ 0.00333 m³/s
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
console.log('flow.measured.upstream.* keys in Port 0 payload:', upstreamKeys);
for (const k of upstreamKeys) console.log(` ${k} = ${out[k]}`);
// The contract: the parent should surface the upstream measurement.
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* on Port 0');
});

View File

@@ -24,9 +24,10 @@ function makeMeasurements(levelMeters) {
}
function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 };
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
return {
config: { general: { name } },
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
handleInput: async (...args) => { calls.handleInput.push(args); },
turnOffAllMachines: () => { calls.turnOff += 1; },
_calls: calls,
@@ -59,31 +60,38 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
assert.equal(state.percControl, 0);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone');
}
});
// basin-docs behavior: between minLevel and the active ramp foot, demand
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
// only the explicit minLevel hard-stop path skips handleInput.
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
}
});
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => {
const ctx = makeCtx(2);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
// Critical: at startLevel pumps are engaged at min flow, NOT turned off.
// The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps
// at this boundary even though the hysteresis was armed.
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel');
assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC');
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
}
});
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
@@ -101,19 +109,65 @@ test('level above maxLevel → percControl clamped at 100 (interpolation limit_i
assert.equal(state.percControl, 100);
});
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
test('percControl forwarded to every group via setDemand(pct, "%")', async () => {
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 50);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
assert.equal(g._calls.setDemand.length, 1, 'one forward per group');
assert.deepEqual(g._calls.setDemand[0], [50, '%']);
assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand');
assert.equal(g._calls.turnOff, 0);
}
});
test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => {
// startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between
// startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now
// the ramp is anchored at startLevel so level=2.5 → 25 %.
const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } });
ctx.basin = { inflowLevel: 3 };
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.ok(Math.abs(state.percControl - 25) < 1e-9,
`expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`);
});
test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => {
// Same geometry but operator raises holdLevel to 3 so the ramp's 0 %
// foot moves up. Level=2.5 should now sit in the hold band: pumps are
// engaged but emit 0 % (= MGC's flow.min, NOT turn-off).
const ctx = makeCtx(2.5, {
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 },
});
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0, '0 % in the configurable hold band');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band');
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
}
});
test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => {
// stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the
// band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %).
const ctx = makeCtx(1.5, {
levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 },
});
// Pre-arm: simulate that level previously crossed startLevel.
ctx.host = { _stopHystRunning: true };
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.deepEqual(g._calls.setDemand[0], [1, '%']);
}
});
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
const ctx = makeCtx(NaN);
let warned = false;
@@ -128,3 +182,51 @@ test('no valid level → warns and returns without mutating percControl or calli
assert.equal(g._calls.handleInput.length, 0);
}
});
// Regression: a station engaged above startLevel but with no machine group
// registered (e.g. the Port 2 parent↔group registration was dropped by a
// partial redeploy) computes a real demand that goes nowhere. The strategy
// must surface this once, not fail silently. See the 2026-05-27 "PS not
// reacting to level" trace.
test('engaged with NO machine group registered → warns once (throttled via host)', async () => {
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged
ctx.machineGroups = {}; // registration lost
ctx.host = {};
const warns = [];
ctx.logger.warn = (m) => warns.push(m);
const state = { percControl: 0 };
await levelBased.run(ctx, state);
assert.ok(state.percControl > 0, 'demand is computed even though there is no group');
assert.equal(warns.length, 1, 'warns exactly once');
assert.match(warns[0], /no machine group is registered/i);
assert.equal(ctx.host._warnedNoMachineGroup, true);
// Subsequent ticks while still group-less stay quiet (no log spam).
await levelBased.run(ctx, state);
assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick');
});
test('warning re-arms after a group reappears then disappears again', async () => {
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } });
ctx.host = {};
const warns = [];
ctx.logger.warn = (m) => warns.push(m);
const state = { percControl: 0 };
ctx.machineGroups = {};
await levelBased.run(ctx, state);
assert.equal(warns.length, 1);
// Group registers again → flag clears, no new warning.
ctx.machineGroups = { a: makeGroup('A') };
await levelBased.run(ctx, state);
assert.equal(warns.length, 1);
assert.equal(ctx.host._warnedNoMachineGroup, false);
// Group lost again → warns once more.
ctx.machineGroups = {};
await levelBased.run(ctx, state);
assert.equal(warns.length, 2, 're-armed after recovery');
});

View File

@@ -4,8 +4,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { UnitPolicy } = require('generalFunctions');
const manual = require('../../src/control/manual');
const unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s' },
output: { flow: 'm3/s' },
requireUnitForTypes: [],
});
function makeGroup(name) {
const calls = { handleInput: [] };
return {
@@ -28,15 +35,15 @@ function makeLogger() {
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
}
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
test('forwardDemand calls handleInput("parent", canonical m3/s demand) on every machine group', async () => {
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.forwardDemand(ctx, 50);
await manual.forwardDemand(ctx, 360);
for (const g of Object.values(groups)) {
assert.equal(g._calls.handleInput.length, 1);
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
}
});
@@ -54,7 +61,7 @@ test('forwardDemand with no machineGroups but direct machines splits demand even
test('run() is a no-op (manual mode is event-driven)', async () => {
const groups = { a: makeGroup('A') };
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
await manual.run(ctx, { percControl: 0 });
assert.equal(groups.a._calls.handleInput.length, 0);
});

View File

@@ -58,6 +58,48 @@ test('FlowAggregator.update integrates inflow-outflow over delta-t', async () =>
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
});
test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => {
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
// (the measurement node hard-codes variant='measured'), but the integrator
// used to read variant='predicted' only — so level stayed flat while the
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
// variant precedence per side.
const { fa, measurements } = makeAggregator();
const t0 = Date.now() - 10_000;
// Measured inflow at 'upstream' (one of the inflow position aliases),
// no outflow side at all.
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
.value(0.01, t0, 'm3/s');
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3.
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
});
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
// (predicted). The picker resolves each side independently, so the net
// balance uses both.
const { fa, measurements } = makeAggregator();
const t0 = Date.now() - 10_000;
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
.value(0.01, t0, 'm3/s');
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
.value(0.004, t0, 'm3/s');
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
fa.update();
const vol = measurements.type('volume').variant('predicted').position('atequipment')
.getCurrentValue('m3');
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`);
});
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
const { fa, measurements } = makeAggregator();
measurements.type('flow').variant('measured').position('in').child('m')

View File

@@ -0,0 +1,81 @@
// Late-subscriber replay: a measurement child that already holds a value when
// the pumpingStation registers it (e.g. a once-only inject that fired during
// startup before the parent subscribed) must still surface on Port 0. The
// emitter only delivers future updates, so _subscribeMeasurement seeds from the
// child's current sample.
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('node:events');
const PumpingStation = require('../../src/specificClass');
const { MeasurementContainer, configManager } = require('generalFunctions');
function makePsConfig() {
const cm = new configManager();
return cm.buildConfig('pumpingStation', { name: 'PS' }, 'ps-replay', {
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear' },
},
safety: {},
});
}
function makeFlowMeasurementChild(id = 'meas-replay') {
const measurements = new MeasurementContainer({ autoConvert: true, preferredUnits: { flow: 'm3/s' } });
assert.ok(typeof measurements.emitter?.on === 'function');
return {
id,
source: {
config: {
general: { id, name: id },
functionality: { softwareType: 'measurement', positionVsParent: 'upstream' },
asset: { type: 'flow' },
},
measurements,
},
};
}
test('value written BEFORE registration is replayed on subscribe (once-inject timing)', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeFlowMeasurementChild();
// Child already holds a value — emitted into the void before the parent existed.
child.source.measurements
.type('flow').variant('measured').position('upstream')
.value(50, Date.now(), 'm3/h');
// Parent registers AFTER the value is present. Without replay it would only
// catch future emits and surface nothing.
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
assert.ok(upstreamKeys.length > 0, 'parent must surface flow.measured.upstream.* after late subscribe');
});
test('no stored value → nothing replayed, no crash', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeFlowMeasurementChild('empty-child');
// Register with an empty child container; replay must be a safe no-op.
assert.doesNotThrow(() => ps.childRegistrationUtils.registerChild(child.source, 'upstream'));
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
assert.equal(upstreamKeys.length, 0, 'no upstream key when child has no value');
});
test('future emits still delivered after subscribe (listener intact)', () => {
const ps = new PumpingStation(makePsConfig());
const child = makeFlowMeasurementChild('streaming-child');
ps.childRegistrationUtils.registerChild(child.source, 'upstream');
// Emit AFTER registration — the normal streaming-sensor path.
child.source.measurements.type('flow').variant('measured').position('upstream').value(30, Date.now(), 'm3/h');
const out = ps.getOutput();
const upstreamKeys = Object.keys(out).filter((k) => k.startsWith('flow.measured.upstream'));
assert.ok(upstreamKeys.length > 0, 'normal post-subscribe emit still surfaces');
});

View File

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

View File

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

View File

@@ -37,7 +37,11 @@ function makeConfig() {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4,
// holdLevel pins the ramp foot at 3 to preserve the original geometry
// (up curve 0 %@3 → 100 %@4). New default would put the foot at
// startLevel=2; this test specifically exercises shifted-ramp arming
// behaviour, not the ramp-foot semantic itself.
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4,
curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},

View File

@@ -19,11 +19,11 @@ The **Unit** column reflects each descriptor's `units: { measure, default }` dec
|---|---|---|---|---|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | `any` | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | `any` | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
| `set.inflow` | `q_in` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
| `set.outflow` | `q_out` | `any` | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
| `set.demand` | `Qd` | `any` | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
| `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
| `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
| `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
| `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
<!-- END AUTOGEN: topic-contract -->