Compare commits
84 Commits
f01b0bcb19
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
177a37e15c | ||
|
|
089a7fa2c4 | ||
|
|
e47de87adb | ||
|
|
fc6491dc23 | ||
|
|
2fb083da63 | ||
|
|
4889fdaaf0 | ||
|
|
a83a85e958 | ||
|
|
e041877ae4 | ||
|
|
8216480950 | ||
|
|
dfaa0c3ae8 | ||
|
|
6e727d929b | ||
| ef07f2a5b2 | |||
|
|
2d68a4f504 | ||
|
|
a3536b7b7f | ||
|
|
f5c6282478 | ||
|
|
df18e97b8b | ||
|
|
2e4ad8d3f1 | ||
|
|
d4de3cf5c5 | ||
|
|
304df7f135 | ||
|
|
03440e1e6c | ||
|
|
2c7fe1792f | ||
|
|
6e89e4916f | ||
|
|
285fd01a5d | ||
|
|
fe5fa3577b | ||
|
|
8507ee4e02 | ||
|
|
b825ac1d6d | ||
|
|
530f84ae5b | ||
|
|
5f1c9ae2ff | ||
|
|
ef81013e96 | ||
|
|
e991ea64ef | ||
|
|
ed22f01932 | ||
|
|
d2384b1a2d | ||
|
|
52d3889fbc | ||
|
|
7afcd6e54a | ||
|
|
e2ebb31816 | ||
|
|
6ab585bcc2 | ||
|
|
d8490aa949 | ||
|
|
6b46a8a8f0 | ||
|
|
62bc73f2f9 | ||
|
|
de9a79b888 | ||
|
|
8a6ca1baeb | ||
|
|
da50403c76 | ||
|
|
ab0d4ed285 | ||
|
|
2dd419dbf4 | ||
|
|
785d036dc6 | ||
|
|
65fe68b87f | ||
|
|
d641d2248d | ||
|
|
12904b4902 | ||
|
|
1ebbcb62cc | ||
|
|
3e13512a83 | ||
|
|
66fd3feff8 | ||
|
|
016433abe6 | ||
|
|
a2189457f6 | ||
|
|
4637448c49 | ||
|
|
61e0688f73 | ||
|
|
0ff55f5e9c | ||
|
|
5e2ebe4d96 | ||
|
|
e8dd657b4f | ||
|
|
c62d8bc275 | ||
|
|
f869296832 | ||
|
|
9f430cebb5 | ||
|
|
7d05d37678 | ||
|
|
762770a063 | ||
|
|
3ff76228eb | ||
|
|
7efd3b0a07 | ||
|
|
c81ee1b470 | ||
|
|
955c17a466 | ||
|
|
052ded7b6e | ||
|
|
321ea33bf7 | ||
|
|
288bd244dd | ||
|
|
d91609b3a4 | ||
|
|
5a575a29fe | ||
|
|
0a6c7ee2e1 | ||
|
|
4cc529b1c2 | ||
|
|
fbfcec4b47 | ||
|
|
43eb97407f | ||
|
|
9e4b149b64 | ||
|
|
1848486f1c | ||
|
|
d44cbc978b | ||
|
|
f243761f00 | ||
|
|
2a31c7ec69 | ||
|
|
69f68adffe | ||
|
|
5a1eff37d7 | ||
|
|
e8f9207a92 |
10
.gitignore
vendored
Normal 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
@@ -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
|
||||||
40
CLAUDE.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# pumpingStation — Claude Code context
|
||||||
|
|
||||||
|
Wet-well basin model and pump orchestration.
|
||||||
|
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
## S88 classification
|
||||||
|
|
||||||
|
| Level | Colour | Placement lane |
|
||||||
|
|---|---|---|
|
||||||
|
| **Process Cell** | `#0c99d9` | L5 |
|
||||||
|
|
||||||
|
## Flow layout rules
|
||||||
|
|
||||||
|
When wiring this node into a multi-node demo or production flow, follow the
|
||||||
|
placement rule set in the **EVOLV superproject**:
|
||||||
|
|
||||||
|
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||||
|
|
||||||
|
Key points for this node:
|
||||||
|
- Place on lane **L5** (x-position per the lane table in the rule).
|
||||||
|
- Stack same-level siblings vertically.
|
||||||
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
|
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
|
||||||
|
|
||||||
|
## Folder & File Layout
|
||||||
|
|
||||||
|
Every per-node file MUST use the folder name (`pumpingStation`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
|
||||||
|
|
||||||
|
| Path | Required name |
|
||||||
|
|---|---|
|
||||||
|
| Entry file | `pumpingStation.js` |
|
||||||
|
| Editor HTML | `pumpingStation.html` |
|
||||||
|
| Node adapter | `src/nodeClass.js` |
|
||||||
|
| Domain logic | `src/specificClass.js` |
|
||||||
|
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
|
||||||
|
| Tests | `test/{basic,integration,edge}/*.test.js` |
|
||||||
|
| Example flows | `examples/*.flow.json` |
|
||||||
|
|
||||||
|
|
||||||
|
When adding new files, read the rule above first to avoid drift.
|
||||||
59
CONTRACT.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# pumpingStation — Contract
|
||||||
|
|
||||||
|
Hand-maintained for Phase 2; the `## Inputs` table is generated from
|
||||||
|
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
|
||||||
|
|
||||||
|
## Inputs (msg.topic on Port 0)
|
||||||
|
|
||||||
|
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `set.mode` | `changemode` | `string` — one of `manual`, `levelbased`, `flowbased`, `none` | Switches the control strategy. |
|
||||||
|
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils` at the supplied `msg.positionVsParent`. |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
|
||||||
|
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
|
||||||
|
| `set.outflow` | `q_out` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a measured outflow value into the basin balance. Same payload conventions as `set.inflow`. |
|
||||||
|
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
|
||||||
|
|
||||||
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|
||||||
|
## Outputs (msg.topic on Port 0/1/2)
|
||||||
|
|
||||||
|
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
||||||
|
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
|
||||||
|
(only changed fields are emitted).
|
||||||
|
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
||||||
|
`'influxdb'` formatter.
|
||||||
|
- **Port 2 (registration):** at startup the node sends one
|
||||||
|
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
|
||||||
|
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`
|
||||||
|
|
||||||
|
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
|
||||||
|
the corresponding series receives a new value. Parents subscribe via the
|
||||||
|
generic `child.measurements.emitter.on(eventName, ...)` handshake.
|
||||||
|
pumpingStation publishes:
|
||||||
|
|
||||||
|
- `volume.predicted.atequipment` — basin volume integrator output (m³).
|
||||||
|
- `level.predicted.atequipment` — basin level (m), recomputed from volume.
|
||||||
|
- `flow.predicted.in` (childed `manual-qin`) — manual inflow injections.
|
||||||
|
- `volume.measured.atequipment`, `level.measured.<position>`,
|
||||||
|
`pressure.measured.<position>`, `temperature.measured.atequipment`,
|
||||||
|
`flow.predicted.<in|out>` (childed by upstream child id) — when a
|
||||||
|
matching child measurement arrives.
|
||||||
|
|
||||||
|
The exact set is data-driven by which children register and what they
|
||||||
|
publish; downstream consumers should subscribe by event name, not assume
|
||||||
|
a fixed catalogue.
|
||||||
|
|
||||||
|
## Children registered by this node
|
||||||
|
|
||||||
|
pumpingStation acts as a parent for `measurement`, `machine`, `machinegroup`,
|
||||||
|
and `pumpingstation` software types. Position labels accepted from
|
||||||
|
children are `upstream`, `downstream`, `atequipment` (and the synonyms
|
||||||
|
`in` / `out` for predicted-flow children). Child-registration plumbing is
|
||||||
|
documented in `MODULE_SPLIT.md`; this node does not receive children
|
||||||
|
through Port 0 input — registration arrives on Port 2 from the child via
|
||||||
|
the standard `childRegistrationUtils` handshake.
|
||||||
11
README.md
@@ -1 +1,10 @@
|
|||||||
# rotating machine
|
# pumpingStation
|
||||||
|
|
||||||
|
Wet-well basin model and pump orchestration node for EVOLV.
|
||||||
|
|
||||||
|
The detailed documentation lives in [`wiki/`](wiki/):
|
||||||
|
|
||||||
|
- [`wiki/functional-description.md`](wiki/functional-description.md) defines the shared basin model, pipe reference semantics, safety points, net-flow selection, and child registration behaviour.
|
||||||
|
- [`wiki/modes/`](wiki/modes/) documents control-mode-specific behaviour. For v1.0 the editor exposes `levelbased` and `manual`; levelbased supports linear and log curves with separate rising/falling ramp semantics.
|
||||||
|
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.
|
||||||
|
- [`examples/basic-dashboard.flow.json`](examples/basic-dashboard.flow.json) provides a simple Node-RED Dashboard 2 flow with level, volume, demand, net-flow, and safety-state trends.
|
||||||
|
|||||||
360
examples/01-Basic.json
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ps_basic_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "PumpingStation - Basic",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "PumpingStation - Basic\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nA 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\noverflow at 3.2 m). controlMode = levelbased, manual demand allowed\nonly when set.mode = manual.\n\nHOW TO USE:\n 1. Deploy the flow.\n 2. Click \"set.mode = manual\" so set.demand is honoured.\n 3. Click \"set.inflow = 60 m3/h\" to push wastewater into the basin.\n 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n 5. Click \"calibrate volume 25 m3\" to jump straight to half-full.\n\nAliases (changemode, q_in, Qd, …) still work but log a deprecation\nwarning - fresh flows use the canonical names.",
|
||||||
|
"info": "",
|
||||||
|
"x": 600,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_mode",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.mode = manual",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "manual",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.mode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_mode_lvl",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.mode = levelbased",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "levelbased",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.mode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_inflow",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.inflow = 60 m3/h",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "60",
|
||||||
|
"vt": "num"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "unit",
|
||||||
|
"v": "m3/h",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.inflow",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 260,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_demand",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.demand = 40 m3/h",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "40",
|
||||||
|
"vt": "num"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "unit",
|
||||||
|
"v": "m3/h",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.demand",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_calvol",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "calibrate volume 25 m3",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "25",
|
||||||
|
"vt": "num"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "unit",
|
||||||
|
"v": "m3",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "cmd.calibrate.volume",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_callvl",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "calibrate level 1.5 m",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "1.5",
|
||||||
|
"vt": "num"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "unit",
|
||||||
|
"v": "m",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "cmd.calibrate.level",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 400,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_node",
|
||||||
|
"type": "pumpingStation",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Pumping Station",
|
||||||
|
"simulator": false,
|
||||||
|
"basinVolume": 50,
|
||||||
|
"basinHeight": 3.5,
|
||||||
|
"inflowLevel": 3,
|
||||||
|
"outflowLevel": 0.2,
|
||||||
|
"overflowLevel": 3.2,
|
||||||
|
"defaultFluid": "wastewater",
|
||||||
|
"inletPipeDiameter": 0.3,
|
||||||
|
"outletPipeDiameter": 0.3,
|
||||||
|
"pipelineLength": 80,
|
||||||
|
"maxDischargeHead": 24,
|
||||||
|
"staticHead": 12,
|
||||||
|
"maxInflowRate": 200,
|
||||||
|
"temperatureReferenceDegC": 15,
|
||||||
|
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
||||||
|
"enableDryRunProtection": true,
|
||||||
|
"enableOverfillProtection": true,
|
||||||
|
"dryRunThresholdPercent": 2,
|
||||||
|
"overfillThresholdPercent": 98,
|
||||||
|
"minHeightBasedOn": "outlet",
|
||||||
|
"processOutputFormat": "process",
|
||||||
|
"dbaseOutputFormat": "influxdb",
|
||||||
|
"refHeight": "NAP",
|
||||||
|
"basinBottomRef": 1,
|
||||||
|
"uuid": "example-ps-001",
|
||||||
|
"supplier": "WBD-RD",
|
||||||
|
"category": "station",
|
||||||
|
"assetType": "pumpingstation",
|
||||||
|
"model": "demo-50m3",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"controlMode": "levelbased",
|
||||||
|
"startLevel": 1.2,
|
||||||
|
"minLevel": 0.4,
|
||||||
|
"maxLevel": 2.8,
|
||||||
|
"flowSetpoint": null,
|
||||||
|
"flowDeadband": null,
|
||||||
|
"x": 1320,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_format"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ps_basic_dbg_influx"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ps_basic_dbg_parent"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_format",
|
||||||
|
"type": "function",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Merge deltas + format",
|
||||||
|
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 1560,
|
||||||
|
"y": 280,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_dbg_process"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_dbg_process",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Port 0: Process",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 240,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_dbg_influx",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Port 1: InfluxDB",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 320,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_dbg_parent",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Port 2: Parent reg",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 380,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_ps_basic",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Pumping Station (PC)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#0c99d9",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"ps_basic_node",
|
||||||
|
"ps_basic_format"
|
||||||
|
],
|
||||||
|
"x": 1290,
|
||||||
|
"y": 230,
|
||||||
|
"w": 500,
|
||||||
|
"h": 140
|
||||||
|
}
|
||||||
|
]
|
||||||
1136
examples/02-Dashboard.json
Normal file
90
examples/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# pumpingStation - Example Flows
|
||||||
|
|
||||||
|
Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
||||||
|
canonical topic API (`set.mode`, `set.inflow`, `set.outflow`, `set.demand`,
|
||||||
|
`cmd.calibrate.volume`, `cmd.calibrate.level`). Legacy aliases
|
||||||
|
(`changemode`, `q_in`, `q_out`, `Qd`, `calibratePredictedVolume`,
|
||||||
|
`calibratePredictedLevel`, `registerChild`) still work but log a
|
||||||
|
one-time deprecation warning; these fresh flows use the canonical names only.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Tier | Tabs | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `01-Basic.json` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
|
||||||
|
| `02-Dashboard.json` | 2 | Process Plant + Dashboard UI | Same command surface as Basic, but driven by FlowFuse Dashboard 2.0 widgets — `ui-button` controls + `ui-text` live status panel. |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node-RED with the EVOLV package installed (so the `pumpingStation`,
|
||||||
|
`measurement`, `machineGroupControl`, and `rotatingMachine` node
|
||||||
|
types are registered).
|
||||||
|
- For `02-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
|
||||||
|
|
||||||
|
## How to load
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Drop a file into a running Node-RED instance using its Admin API.
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \
|
||||||
|
http://localhost:1880/flows
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
|
||||||
|
import into their own tabs and can be deployed immediately.
|
||||||
|
|
||||||
|
## 01-Basic - what to try
|
||||||
|
|
||||||
|
1. Deploy.
|
||||||
|
2. Inject `set.mode = manual`.
|
||||||
|
3. Inject `set.inflow = 60 m3/h` - the basin starts filling. Watch the
|
||||||
|
formatted Port 0 payload in the debug sidebar.
|
||||||
|
4. Inject `set.demand = 40 %` - in manual mode this would feed any
|
||||||
|
registered children; here there are no pump children so it is logged
|
||||||
|
and shown on Port 0.
|
||||||
|
5. Inject `cmd.calibrate.volume = 25 m3` to jump the predicted-volume
|
||||||
|
integrator to half-full.
|
||||||
|
|
||||||
|
## 02-Dashboard - what to try
|
||||||
|
|
||||||
|
1. Deploy.
|
||||||
|
2. Open the dashboard at `http://localhost:1880/dashboard/pumpingstation-basic`.
|
||||||
|
3. Click **Mode: Manual** or **Mode: Levelbased** in the Controls panel.
|
||||||
|
4. Click **Inflow 60 m³/h** to push wastewater into the basin — the Status
|
||||||
|
panel on the right shows level / volume / volume % rising.
|
||||||
|
5. In manual mode, click **Demand 40 m³/h** — the value surfaces as
|
||||||
|
`Manual demand` in the Status panel and in the node's status badge.
|
||||||
|
6. Use **Calibrate V = 25 m³** or **Calibrate L = 1.5 m** to snap the
|
||||||
|
predicted-volume integrator.
|
||||||
|
|
||||||
|
All buttons fire the same canonical `msg.topic` as the Basic flow's inject
|
||||||
|
nodes; the only difference is the trigger. The Live status panel is fed by
|
||||||
|
Port 0 via a small fan-out function that caches last-known values so
|
||||||
|
delta-only updates never blank a row.
|
||||||
|
|
||||||
|
## Layout conventions
|
||||||
|
|
||||||
|
These flows follow the EVOLV layout rule set in
|
||||||
|
`.claude/rules/node-red-flow-layout.md`:
|
||||||
|
|
||||||
|
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
|
||||||
|
(`ui-*` widgets) / Setup (once-true injects).
|
||||||
|
- Cross-tab wiring via **named link out / link in channels**:
|
||||||
|
`setup:to-ps-mode`, `setup:to-ps-inflow`, `setup:to-mgc-mode`,
|
||||||
|
`cmd:ps-mode`, `cmd:ps-demand`, `evt:flow`, `evt:level`,
|
||||||
|
`evt:volpct`, `evt:state`, `evt:perc`, `evt:dir`, `evt:tempty`.
|
||||||
|
- **Lane positions** L0-L7 = `[120, 360, 600, 840, 1080, 1320, 1560, 1800]`,
|
||||||
|
driven by each node's S88 level (Process Cell on L5, Unit on L4,
|
||||||
|
Equipment on L3, Control Module on L2).
|
||||||
|
- **Group boxes** wrap each parent + its direct children, coloured by the
|
||||||
|
parent's S88 level.
|
||||||
|
|
||||||
|
## Maintaining
|
||||||
|
|
||||||
|
These example flows are **hand-authored one-offs** — edit the JSON directly.
|
||||||
|
There is intentionally no generator: examples are illustrative, not produced in
|
||||||
|
bulk. Validate any change with `flow-lint`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ../../../tools/flow-lint/bin/flow-lint.js 01-Basic.json 02-Dashboard.json
|
||||||
|
```
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
"description": "Control module",
|
"description": "Control module",
|
||||||
"main": "pumpingStation.js",
|
"main": "pumpingStation.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node pumpingStation.js"
|
"test": "node --test test/",
|
||||||
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Reference-Contracts.md",
|
||||||
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Reference-Contracts.md",
|
||||||
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -10,20 +10,48 @@
|
|||||||
-->
|
-->
|
||||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||||
|
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
|
||||||
|
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
|
||||||
|
<script src="/pumpingStation/editor/index.js"></script>
|
||||||
|
<script src="/pumpingStation/editor/bounds.js"></script>
|
||||||
|
<script src="/pumpingStation/editor/basin-diagram.js"></script>
|
||||||
|
<script src="/pumpingStation/editor/mode-preview.js"></script>
|
||||||
|
<script src="/pumpingStation/editor/hover-couple.js"></script>
|
||||||
|
<script src="/pumpingStation/editor/oneditprepare.js"></script>
|
||||||
|
<script src="/pumpingStation/editor/oneditsave.js"></script>
|
||||||
|
|
||||||
<script>//test
|
<script>//test
|
||||||
RED.nodes.registerType("pumpingStation", {
|
RED.nodes.registerType("pumpingStation", {
|
||||||
category: "EVOLV",
|
category: "EVOLV",
|
||||||
color: "#0c99d9", // color for the node based on the S88 schema
|
color: "#8B4513",
|
||||||
defaults: {
|
defaults: {
|
||||||
|
name: { value: "" },
|
||||||
|
|
||||||
// Define station-specific properties
|
// Define station-specific properties
|
||||||
simulator: { value: false },
|
simulator: { value: false },
|
||||||
basinVolume: { value: 1 }, // m³, total empty basin
|
basinVolume: { value: 50 }, // m³, total empty basin
|
||||||
basinHeight: { value: 1 }, // m, floor to top
|
basinHeight: { value: 4 }, // m, floor to top
|
||||||
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
|
inflowLevel: { value: 1.5 }, // m, bottom/invert of inlet pipe above floor
|
||||||
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
|
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
|
||||||
heightOverflow: { value: 0.9 }, // m, overflow elevation
|
overflowLevel: { value: 3.8 }, // m, overflow elevation
|
||||||
|
defaultFluid: { value: "wastewater" },
|
||||||
|
inletPipeDiameter: { value: 0.3 }, // m
|
||||||
|
outletPipeDiameter: { value: 0.3 }, // m
|
||||||
|
pipelineLength: { value: 80 }, // m
|
||||||
|
maxDischargeHead: { value: 24 }, // m
|
||||||
|
staticHead: { value: 12 }, // m
|
||||||
|
maxInflowRate: { value: 200 }, // m³/h
|
||||||
|
temperatureReferenceDegC: { value: 15 },
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
|
||||||
|
enableDryRunProtection: { value: true },
|
||||||
|
enableHighVolumeSafety: { value: true },
|
||||||
|
enableOverfillProtection: { value: true }, // deprecated alias
|
||||||
|
dryRunThresholdPercent: { value: 2 },
|
||||||
|
highVolumeSafetyThresholdPercent: { value: 98 },
|
||||||
|
overfillThresholdPercent: { value: 98 }, // deprecated alias
|
||||||
|
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
|
||||||
|
processOutputFormat: { value: "process" },
|
||||||
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
// Advanced reference information
|
// Advanced reference information
|
||||||
refHeight: { value: "NAP" }, // reference height
|
refHeight: { value: "NAP" }, // reference height
|
||||||
@@ -47,7 +75,23 @@
|
|||||||
hasDistance: { value: false },
|
hasDistance: { value: false },
|
||||||
distance: { value: 0 },
|
distance: { value: 0 },
|
||||||
distanceUnit: { value: "m" },
|
distanceUnit: { value: "m" },
|
||||||
distanceDescription: { value: "" }
|
distanceDescription: { value: "" },
|
||||||
|
|
||||||
|
// control strategy
|
||||||
|
controlMode: { value: "levelbased" },
|
||||||
|
levelCurveType: { value: "linear" },
|
||||||
|
logCurveFactor: { value: 9 },
|
||||||
|
enableShiftedRamp: { value: false },
|
||||||
|
shiftLevel: { value: 0 },
|
||||||
|
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 },
|
||||||
|
flowDeadband: { value: null }
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -61,51 +105,11 @@
|
|||||||
return this.positionIcon + " PumpingStation";
|
return this.positionIcon + " PumpingStation";
|
||||||
},
|
},
|
||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function () {
|
||||||
const waitForMenuData = () => {
|
window.PSEditor.oneditprepare.call(this);
|
||||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
|
||||||
window.EVOLV.nodes.pumpingStation.initEditor(this);
|
|
||||||
} else {
|
|
||||||
setTimeout(waitForMenuData, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Wait for the menu data to be ready before initializing the editor
|
|
||||||
waitForMenuData();
|
|
||||||
|
|
||||||
// NODE SPECIFIC
|
|
||||||
document.getElementById("node-input-basinVolume");
|
|
||||||
document.getElementById("node-input-basinHeight");
|
|
||||||
document.getElementById("node-input-heightInlet");
|
|
||||||
document.getElementById("node-input-heightOutlet");
|
|
||||||
document.getElementById("node-input-heightOverflow");
|
|
||||||
document.getElementById("node-input-refHeight");
|
|
||||||
document.getElementById("node-input-basinBottomRef");
|
|
||||||
|
|
||||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
|
||||||
if (refHeightEl) {
|
|
||||||
refHeightEl.value = this.refHeight || "NAP";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
|
||||||
},
|
},
|
||||||
oneditsave: function () {
|
oneditsave: function () {
|
||||||
const node = this;
|
window.PSEditor.oneditsave.call(this);
|
||||||
|
|
||||||
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
|
|
||||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
|
||||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
|
||||||
|
|
||||||
//node specific
|
|
||||||
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
|
|
||||||
node.simulator = document.getElementById("node-input-simulator").checked;
|
|
||||||
|
|
||||||
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef"]
|
|
||||||
.forEach(field => {
|
|
||||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -115,7 +119,7 @@
|
|||||||
|
|
||||||
<script type="text/html" data-template-name="pumpingStation">
|
<script type="text/html" data-template-name="pumpingStation">
|
||||||
|
|
||||||
<!-- Simulator toggle -->
|
<h4>Simulation</h4>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label>
|
<label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label>
|
||||||
<input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" />
|
<input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" />
|
||||||
@@ -124,33 +128,399 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- Basin geometry -->
|
<h4>Basin parameters</h4>
|
||||||
<div class="form-row">
|
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Each input on the left controls a line in the diagram on the right — hover an input to highlight its line.</p>
|
||||||
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
|
<div id="ps-basin-validation" style="display:none;color:#C0392B;font-size:11px;margin:0 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Two-column layout: stacked colour-coded inputs on the left,
|
||||||
|
SVG on the right. Hover an input row → its paired SVG line
|
||||||
|
(referenced by data-couples-line) gets a thicker stroke. */
|
||||||
|
.ps-diag { display:flex; gap:28px; align-items:flex-start; margin:0 0 14px 0; }
|
||||||
|
.ps-diag-side { width: 220px; flex: 0 0 220px; display:flex; flex-direction:column; gap:6px; }
|
||||||
|
.ps-diag-side .ps-row {
|
||||||
|
display:grid; grid-template-columns: minmax(0,1fr) 70px 16px; align-items:center;
|
||||||
|
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
|
||||||
|
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer;
|
||||||
|
min-width:0;
|
||||||
|
}
|
||||||
|
.ps-diag-side .ps-row:hover { background:#f0f0f0; }
|
||||||
|
.ps-diag-side .ps-row.ps-readonly { background:#fff; cursor:default; opacity:0.85; }
|
||||||
|
.ps-diag-side .ps-row label { font-weight:600; margin:0; line-height:1.2; }
|
||||||
|
.ps-diag-side .ps-row .ps-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
|
||||||
|
.ps-diag-side .ps-row input[type=number] {
|
||||||
|
width:100%; height:22px; box-sizing:border-box; font-size:11px;
|
||||||
|
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px;
|
||||||
|
background:#fff;
|
||||||
|
}
|
||||||
|
.ps-diag-side .ps-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
|
||||||
|
.ps-diag-side .ps-row .ps-readonly-val {
|
||||||
|
font-family:monospace; font-size:11px; color:#666; text-align:right;
|
||||||
|
padding-right:4px;
|
||||||
|
}
|
||||||
|
.ps-diag-side .ps-row .ps-unit { color:#888; font-size:10px; }
|
||||||
|
.ps-diag-svg { flex:1; min-width:0; }
|
||||||
|
/* Border colours matched to each SVG line stroke. */
|
||||||
|
.ps-row[data-stroke="#333"] { border-left-color:#333; }
|
||||||
|
.ps-row[data-stroke="#C0392B"] { border-left-color:#C0392B; }
|
||||||
|
.ps-row[data-stroke="#1E8449"] { border-left-color:#1E8449; }
|
||||||
|
.ps-row[data-stroke="#1F4E79"] { border-left-color:#1F4E79; }
|
||||||
|
.ps-row[data-stroke="#D68910"] { border-left-color:#D68910; }
|
||||||
|
.ps-row[data-stroke="#888"] { border-left-color:#888; }
|
||||||
|
.ps-row[data-stroke="#333"] label { color:#333; }
|
||||||
|
.ps-row[data-stroke="#C0392B"] label { color:#C0392B; }
|
||||||
|
.ps-row[data-stroke="#1E8449"] label { color:#1E8449; }
|
||||||
|
.ps-row[data-stroke="#1F4E79"] label { color:#1F4E79; }
|
||||||
|
.ps-row[data-stroke="#D68910"] label { color:#D68910; }
|
||||||
|
.ps-row[data-stroke="#888"] label { color:#888; }
|
||||||
|
/* Highlight class applied to the SVG line during input row hover. */
|
||||||
|
.ps-line-highlight { stroke-width:3.5 !important; opacity:1 !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============================================================
|
||||||
|
BASIN DIAGRAM (ps-basin-diagram)
|
||||||
|
============================================================
|
||||||
|
Coordinate system: SVG viewBox is 520 (wide) × 430 (tall).
|
||||||
|
Origin (0,0) is top-left. +x goes right. +y goes DOWN.
|
||||||
|
Bigger y = lower on screen.
|
||||||
|
|
||||||
|
X-LANES (all viewBox units, edit any of these to shift a column):
|
||||||
|
x ≈ 5..75 left input column (inlet number input)
|
||||||
|
x = 80 inlet unit "m"
|
||||||
|
x = 135 inlet text labels (right-aligned, anchor at x)
|
||||||
|
x = 140..200 inlet arrow (line + arrow head into tank)
|
||||||
|
x = 200..320 tank body (rect.x=200 width=120) — interior 201..319
|
||||||
|
x = 195/325 threshold tick lines (extend 5 px outside tank)
|
||||||
|
x = 260 mid-tank zone labels (centered)
|
||||||
|
x = 320..360 outlet arrow
|
||||||
|
x = 330 right-side label column ("overflowLevel", "Outlet", …)
|
||||||
|
x = 365 outlet sub-text column
|
||||||
|
x = 425..495 right input column (foreignObject inputs, width=70)
|
||||||
|
x = 500 right unit column ("m", "m³")
|
||||||
|
|
||||||
|
Y-COORDINATES:
|
||||||
|
y = 40 tank rim (basinHeight line)
|
||||||
|
y = 380 tank floor / datum
|
||||||
|
y = 410 ordering warning ribbon
|
||||||
|
y = 19,44 "basin volume" / "basinHeight" labels (static)
|
||||||
|
Threshold rows (overflowLevel, highVolumeSafetyLevel, inflowLevelGuide,
|
||||||
|
dryRunLevel, outflowLevel, basinHeight tick) get y assigned
|
||||||
|
DYNAMICALLY by the redraw() function around line 250-340 below.
|
||||||
|
Their input row may be NUDGED off ideal-y to avoid overlap; a leader
|
||||||
|
line (ps-leader-*) is then drawn between threshold y and input y.
|
||||||
|
Zone-label rows (ps-zone-*) get y assigned dynamically to the midpoint
|
||||||
|
between adjacent thresholds; they hide if the gap is too small.
|
||||||
|
|
||||||
|
HOW TO NUDGE OVERLAPPING LABELS:
|
||||||
|
- For STATIC y values (hardcoded below): edit the inline y attribute.
|
||||||
|
- For DYNAMIC y values: search redraw() for the element id and adjust
|
||||||
|
the layout math (e.g. NUDGE_PX or the threshold-stack ordering).
|
||||||
|
- For x: every label column above can be shifted by editing the inline
|
||||||
|
x attribute on the relevant <text>/<line>/<foreignObject>.
|
||||||
|
|
||||||
|
Note: dynamic line/label positioning lives in oneditprepare → redraw()
|
||||||
|
further up in this file. Changing only the inline y here will be
|
||||||
|
overridden on first redraw for any element whose id appears in redraw().
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<div class="ps-diag" id="ps-basin-wrap">
|
||||||
|
<!-- LEFT: stacked colour-coded inputs. Hover a row → its paired SVG
|
||||||
|
line (data-couples-line) is highlighted in the diagram. -->
|
||||||
|
<div class="ps-diag-side">
|
||||||
|
<div class="ps-row" data-stroke="#333" style="cursor:default;">
|
||||||
|
<div><label>basinVolume</label><div class="ps-sub">total empty volume (no marker)</div></div>
|
||||||
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||||
|
<span class="ps-unit">m³</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="ps-row" data-stroke="#333" data-couples-line="ps-line-basinHeight">
|
||||||
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
|
<div><label>basinHeight</label><div class="ps-sub">floor → rim</div></div>
|
||||||
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#C0392B" data-couples-line="ps-line-overflowLevel">
|
||||||
|
<div><label>overflowLevel</label><div class="ps-sub">spill height</div></div>
|
||||||
|
<input type="number" id="node-input-overflowLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row ps-readonly" data-stroke="#D68910" data-couples-line="ps-line-highVolumeSafetyLevel">
|
||||||
|
<div><label>highVolumeSafety</label><div class="ps-sub">derived (overflow × %)</div></div>
|
||||||
|
<span id="derived-highVolumeSafetyLevel" class="ps-readonly-val">— m</span>
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#1F4E79" data-couples-line="ps-line-inflowLevel">
|
||||||
|
<div><label>inflowLevel</label><div class="ps-sub">bottom of inlet pipe</div></div>
|
||||||
|
<input type="number" id="node-input-inflowLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-line-dryRunLevel">
|
||||||
|
<div><label>dryRunLevel</label><div class="ps-sub">derived (outflow × dry%)</div></div>
|
||||||
|
<span id="derived-dryRunLevel" class="ps-readonly-val">— m</span>
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#1F4E79" data-couples-line="ps-line-outflowLevel">
|
||||||
|
<div><label>outflowLevel</label><div class="ps-sub">top of outlet pipe</div></div>
|
||||||
|
<input type="number" id="node-input-outflowLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row ps-readonly" data-stroke="#888" style="cursor:default;">
|
||||||
|
<div><label>basinBottomRef</label><div class="ps-sub">floor above NAP (no marker)</div></div>
|
||||||
|
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- RIGHT: SVG. The viewBox is now narrower (320 wide) since the right
|
||||||
|
input column is gone — labels render inside the tank's right margin. -->
|
||||||
|
<svg id="ps-basin-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 430"
|
||||||
|
style="display:block;width:100%;max-width:360px;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
|
||||||
|
font-family="Arial,sans-serif" font-size="11">
|
||||||
|
<defs>
|
||||||
|
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<!-- Tank body — shifted right (x=145, width=110) to give the inlet
|
||||||
|
sub-label "bottom of pipe" room on the left without clipping.
|
||||||
|
Threshold tick lines extend 5 px outside the tank walls. -->
|
||||||
|
<rect x="145" y="40" width="110" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
|
||||||
|
<rect id="ps-deadvol" x="146" width="108" fill="#AACCE0" />
|
||||||
|
|
||||||
|
<!-- Mid-tank zone labels — centred at x=200 (tank centre). -->
|
||||||
|
<text id="ps-zone-spare" x="200" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare</text>
|
||||||
|
<text id="ps-zone-sewage" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + buffer</text>
|
||||||
|
<text id="ps-zone-buffer1" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Buffer</text>
|
||||||
|
<text id="ps-zone-buffer2" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Buffer</text>
|
||||||
|
<text id="ps-zone-dead" x="200" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead vol</text>
|
||||||
|
|
||||||
|
<!-- basinHeight tick at tank rim (y=40, static). -->
|
||||||
|
<line id="ps-line-basinHeight" x1="140" y1="40" x2="260" y2="40" stroke="#333" stroke-width="1.5" />
|
||||||
|
<text id="ps-label-basinHeight" x="265" y="44" fill="#333">basinHeight</text>
|
||||||
|
|
||||||
|
<line id="ps-line-overflowLevel" x1="140" x2="260" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||||
|
<text id="ps-label-overflowLevel" x="265" fill="#C0392B">overflowLevel</text>
|
||||||
|
|
||||||
|
<line id="ps-line-highVolumeSafetyLevel" x1="140" x2="260" stroke="#D68910" stroke-dasharray="1 2" stroke-width="1" opacity="0.7" />
|
||||||
|
<text id="ps-label-highVolumeSafetyLevel" x="265" fill="#D68910" font-size="10" font-style="italic">highVolSafety</text>
|
||||||
|
|
||||||
|
<line id="ps-line-inflowLevelGuide" x1="145" x2="255" stroke="#1F4E79" stroke-dasharray="2 3" stroke-width="1" opacity="0.55" />
|
||||||
|
<text id="ps-label-inflowLevelGuide" x="265" fill="#1F4E79" font-size="10" font-style="italic">inlet invert</text>
|
||||||
|
|
||||||
|
<line id="ps-line-inflowLevel" x1="85" x2="145" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||||
|
<text id="ps-label-inflowLevel" x="80" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
|
||||||
|
<text id="ps-sub-inflowLevel" x="80" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
|
||||||
|
|
||||||
|
<line id="ps-line-dryRunLevel" x1="140" x2="260" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
|
||||||
|
<text id="ps-label-dryRunLevel" x="265" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel</text>
|
||||||
|
|
||||||
|
<line id="ps-line-outflowLevel" x1="255" x2="295" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||||
|
<text id="ps-label-outflowLevel" x="300" fill="#1F4E79" font-weight="bold">Outlet</text>
|
||||||
|
<text id="ps-sub-outflowLevel" x="300" fill="#777" font-size="9">top of pipe</text>
|
||||||
|
|
||||||
|
<!-- Floor / datum — datum label sits BELOW the tank (y=395) so it
|
||||||
|
never collides with the Outlet / top-of-pipe sub-label when
|
||||||
|
outflowLevel is near the floor. -->
|
||||||
|
<line x1="140" y1="380" x2="260" y2="380" stroke="#000" stroke-width="2" />
|
||||||
|
<text x="200" y="395" text-anchor="middle" fill="#000" font-size="10">0 m (datum)</text>
|
||||||
|
|
||||||
|
<!-- Ordering-warning ribbon -->
|
||||||
|
<text id="ps-warning" x="200" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inlet/Outlet elevations -->
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h4>Control Strategy</h4>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
|
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
|
||||||
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
|
<select id="node-input-controlMode">
|
||||||
|
<option value="levelbased">Level-based</option>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ps-mode-levelbased" class="ps-mode-section">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-levelCurveType">Curve</label>
|
||||||
|
<select id="node-input-levelCurveType" style="width:60%;">
|
||||||
|
<option value="linear">Linear</option>
|
||||||
|
<option value="log">Log - fast early response</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="ps-log-factor-row" style="display:none;">
|
||||||
|
<label for="node-input-logCurveFactor">Log shape factor</label>
|
||||||
|
<input type="number" id="node-input-logCurveFactor" min="0.001" step="0.1" style="width:100px;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
|
<label for="node-input-enableShiftedRamp" style="width:auto;">
|
||||||
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
|
<input type="checkbox" id="node-input-enableShiftedRamp" style="width:auto;vertical-align:middle;margin-right:6px;" />
|
||||||
|
Enable shifted ramp (hysteresis)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div id="ps-mode-validation" style="display:none;color:#C0392B;font-size:11px;margin:4px 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
|
||||||
<label for="node-input-heightOverflow"><i class="fa fa-tint"></i> Overflow Level (m)</label>
|
<!--
|
||||||
<input type="number" id="node-input-heightOverflow" min="0" step="0.01" />
|
============================================================
|
||||||
|
LEVEL-BASED MODE PREVIEW (ps-levelbased-mode-diagram)
|
||||||
|
============================================================
|
||||||
|
Coordinate system: SVG viewBox is 430 (wide) × 185 (tall).
|
||||||
|
Origin (0,0) top-left. +x right. +y DOWN (so y=24 is HIGH on screen,
|
||||||
|
y=158 is at the baseline).
|
||||||
|
|
||||||
|
X-AXIS (level, in viewBox px) — controlled by redrawModeDiagram() in
|
||||||
|
the oneditprepare script above. The function maps the user's
|
||||||
|
startLevel/inflowLevel/maxLevel/shiftLevel onto the px window
|
||||||
|
x0=52 (left axis) → x1=390 (right end of plot).
|
||||||
|
DO NOT hardcode x for ps-mode-line-* / ps-mode-label-*; they're
|
||||||
|
rewritten on every input change.
|
||||||
|
|
||||||
|
Y-AXIS (process demand %):
|
||||||
|
y=24 100% (top of plot)
|
||||||
|
y=140 0% (baseline / x-axis)
|
||||||
|
y=160 OFF baseline (pink dashed)
|
||||||
|
y=180 axis labels under the plot ("dry run","start","inlet","max","overflow","shift")
|
||||||
|
y=205 legend captions (one row, BELOW axis labels — moved here to stop
|
||||||
|
colliding with the title row at y=14)
|
||||||
|
y=14 curve-type title only ("linear curve" / "log curve"), centered.
|
||||||
|
|
||||||
|
WHAT IS STATIC vs DYNAMIC:
|
||||||
|
STATIC (edit inline below): viewBox bounds, axis lines, "0%"/"100%"
|
||||||
|
tick labels, in-plot caption x/y, axis-label y=176.
|
||||||
|
DYNAMIC (edit in JS): every ps-mode-line-*, ps-mode-label-* x;
|
||||||
|
ps-mode-curve-up/down points; visibility of shift elements.
|
||||||
|
|
||||||
|
HOW TO NUDGE OVERLAPPING TEXT:
|
||||||
|
- Move the curve-type caption: edit the x="220" y="18" on
|
||||||
|
#ps-mode-curve-label.
|
||||||
|
- Move axis labels (start/inlet/max/shift) UP or DOWN: edit y="176".
|
||||||
|
(To move them left/right relative to the line, edit redrawModeDiagram
|
||||||
|
in the script — the x is set there.)
|
||||||
|
- Move the legend captions: edit x="280" y="54" / y="72" on
|
||||||
|
#ps-mode-curve-up-label / #ps-mode-curve-down-label.
|
||||||
|
- To resize the plot box, change viewBox + the x0/x1/y0/y1 constants
|
||||||
|
in redrawModeDiagram() to match.
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<div class="ps-diag" id="ps-mode-wrap">
|
||||||
|
<!-- LEFT side-panel: only the level-based mode's editable inputs +
|
||||||
|
read-only displays for derived/related levels (so user has all
|
||||||
|
level context in one column). Hover-coupled to the SVG markers. -->
|
||||||
|
<div class="ps-diag-side">
|
||||||
|
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-dryRunLevel">
|
||||||
|
<div><label>dryRunLevel</label><div class="ps-sub">derived</div></div>
|
||||||
|
<span id="ps-mode-readout-dryRun" class="ps-readonly-val">— m</span>
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#1E8449" data-couples-line="ps-mode-line-startLevel">
|
||||||
|
<div><label>startLevel</label><div class="ps-sub">pump-on threshold</div></div>
|
||||||
|
<input type="number" id="node-input-startLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#7D3C98" data-couples-line="ps-mode-line-stopLevel">
|
||||||
|
<div><label>stopLevel</label><div class="ps-sub">pump-off threshold (optional, ≤ startLevel)</div></div>
|
||||||
|
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#27AE60" data-couples-line="ps-mode-line-holdLevel">
|
||||||
|
<div><label>holdLevel</label><div class="ps-sub">0 % ramp foot — leave at startLevel for no hold band</div></div>
|
||||||
|
<input type="number" id="node-input-holdLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
|
||||||
|
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||||
|
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" data-stroke="#D68910" data-couples-line="ps-mode-line-maxLevel">
|
||||||
|
<div><label>maxLevel</label><div class="ps-sub">100% saturation</div></div>
|
||||||
|
<input type="number" id="node-input-maxLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" id="ps-shiftLevel-row" data-stroke="#D68910" data-couples-line="ps-mode-line-shiftLevel" style="display:none;">
|
||||||
|
<div><label>shiftLevel</label><div class="ps-sub">held output drops here</div></div>
|
||||||
|
<input type="number" id="node-input-shiftLevel" min="0" step="0.01" />
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row" id="ps-shiftArmPercent-row" data-stroke="#D68910" data-couples-line="ps-mode-line-armPercent" style="display:none;">
|
||||||
|
<div><label>shiftArmPercent</label><div class="ps-sub">arms when output % crosses this</div></div>
|
||||||
|
<input type="number" id="node-input-shiftArmPercent" min="0" max="100" step="1" />
|
||||||
|
<span class="ps-unit">%</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-overflowLevel">
|
||||||
|
<div><label>overflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||||
|
<span id="ps-mode-readout-overflow" class="ps-readonly-val">— m</span>
|
||||||
|
<span class="ps-unit">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg id="ps-levelbased-mode-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 215"
|
||||||
|
style="display:block;width:100%;max-width:540px;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
|
||||||
|
font-family="Arial,sans-serif" font-size="11">
|
||||||
|
<!-- ZONE BANDS — drawn FIRST so they sit behind axes and curves.
|
||||||
|
x is set DYNAMICALLY by redrawModeDiagram(); y/height span the full plot (24..160).
|
||||||
|
Order from leftmost to rightmost: dryRun (red) | safetyLow (orange) | safe (green) |
|
||||||
|
safetyHigh (orange) | overflow (red).
|
||||||
|
-->
|
||||||
|
<rect id="ps-zone-dryRun" y="24" height="136" fill="#fdecea" />
|
||||||
|
<rect id="ps-zone-safetyLow" y="24" height="136" fill="#fef5e7" />
|
||||||
|
<rect id="ps-zone-safe" y="24" height="136" fill="#eafaf1" />
|
||||||
|
<rect id="ps-zone-safetyHigh" y="24" height="136" fill="#fef5e7" />
|
||||||
|
<rect id="ps-zone-overflow" y="24" height="136" fill="#fdecea" />
|
||||||
|
<!-- X-axis (0% baseline) at y=140; y axis at x=52 (top y=24). Plot range: y=24..140. -->
|
||||||
|
<line x1="52" y1="140" x2="402" y2="140" stroke="#333" />
|
||||||
|
<line x1="52" y1="140" x2="52" y2="24" stroke="#333" />
|
||||||
|
<!-- OFF tier baseline at y=160 (20px below 0% baseline). pink line drawn dynamically by curve. -->
|
||||||
|
<line x1="52" y1="160" x2="402" y2="160" stroke="#E08080" stroke-dasharray="2 3" />
|
||||||
|
<!-- Y-axis tick labels (x=4, right-aligned via text-anchor="end" at x=50 for tighter alignment). -->
|
||||||
|
<text x="50" y="27" text-anchor="end" fill="#333">100%</text>
|
||||||
|
<text x="50" y="143" text-anchor="end" fill="#333">0%</text>
|
||||||
|
<text x="50" y="163" text-anchor="end" fill="#E08080">OFF</text>
|
||||||
|
<!-- Plot title above 100% line. -->
|
||||||
|
<text id="ps-mode-curve-label" x="220" y="14" text-anchor="middle" fill="#555">linear curve</text>
|
||||||
|
<!-- Curves drawn dynamically. Up curve foot=inlet→top=max. Down curve foot=start→top=shiftLevel (visible when shift enabled). -->
|
||||||
|
<polyline id="ps-mode-curve-up" fill="none" stroke="#1E8449" stroke-width="2.5" points="" />
|
||||||
|
<polyline id="ps-mode-curve-down" fill="none" stroke="#D68910" stroke-width="2" stroke-dasharray="5 3" points="" style="display:none;" />
|
||||||
|
<!-- Vertical level-marker lines — span y=24..140 (top to baseline only, NOT into OFF tier). x set dynamically. -->
|
||||||
|
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-holdLevel" y1="24" y2="140" stroke="#27AE60" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||||
|
<line id="ps-mode-line-shiftLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" style="display:none;" />
|
||||||
|
<!-- Horizontal arming-% line — y is set DYNAMICALLY by the JS to the
|
||||||
|
shiftArmPercent value (in plot-y space). Spans full plot width. -->
|
||||||
|
<line id="ps-mode-line-armPercent" x1="52" x2="392" stroke="#D68910" stroke-dasharray="4 3" stroke-width="1" opacity="0.7" style="display:none;" />
|
||||||
|
<text id="ps-mode-label-armPercent" x="394" text-anchor="start" fill="#D68910" font-size="9" style="display:none;">arm%</text>
|
||||||
|
<!-- Axis labels under the plot were removed — they crowded each other
|
||||||
|
when levels were close. Identification comes from the line colour
|
||||||
|
(matched to the side-panel input row) and hover-coupling. -->
|
||||||
|
<!-- Empty <text> stubs kept for the redraw loop's getElementById calls
|
||||||
|
(cheaper than guarding each one). They're hidden via display:none. -->
|
||||||
|
<text id="ps-mode-label-dryRunLevel" style="display:none;"></text>
|
||||||
|
<text id="ps-mode-label-startLevel" style="display:none;"></text>
|
||||||
|
<text id="ps-mode-label-stopLevel" style="display:none;"></text>
|
||||||
|
<text id="ps-mode-label-inflowLevel" style="display:none;"></text>
|
||||||
|
<text id="ps-mode-label-maxLevel" style="display:none;"></text>
|
||||||
|
<text id="ps-mode-label-overflowLevel" style="display:none;"></text>
|
||||||
|
<text id="ps-mode-label-shiftLevel" style="display:none;"></text>
|
||||||
|
<!-- Legend captions — placed BELOW the axis labels (y=200) on their own row,
|
||||||
|
so they never collide with the title (y=14). Up-caption left-aligned at
|
||||||
|
x=60; down-caption to its right at x=210. Both font-size 10. -->
|
||||||
|
<text id="ps-mode-curve-up-label" x="60" y="205" fill="#1E8449" font-size="10">— ramp inlet→max</text>
|
||||||
|
<text id="ps-mode-curve-down-label" x="210" y="205" fill="#D68910" font-size="10" style="display:none;">— shifted (held @100% then ramp shift→start)</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ps-mode-manual" class="ps-mode-section" style="display:none">
|
||||||
|
<p style="font-size:12px;color:#777;margin:0;">Manual mode accepts external <code>Qd</code> demand commands and does not compute demand from basin level.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- Reference data -->
|
<h4>Reference</h4>
|
||||||
|
|
||||||
|
<!-- Reference data — basinBottomRef moved into basin side-panel above. -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
||||||
<select id="node-input-refHeight" style="width:60%;">
|
<select id="node-input-refHeight" style="width:60%;">
|
||||||
@@ -158,9 +528,55 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h4>Safety</h4>
|
||||||
|
|
||||||
|
<!-- Safety settings -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
|
<label for="node-input-enableDryRunProtection">
|
||||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
<i class="fa fa-shield"></i> Dry-run Protection
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="node-input-enableDryRunProtection" style="width:20px;vertical-align:baseline;" />
|
||||||
|
<span>Prevent pumps from running on low volume</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-dryRunThresholdPercent" style="padding-left:20px;">Low Volume Threshold (%)</label>
|
||||||
|
<input type="number" id="node-input-dryRunThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||||
|
<span id="derived-dryRunLevel" style="margin-left:8px;color:#777;font-size:12px;">→ dryRunLevel ≈ — m</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-enableHighVolumeSafety">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i> High-volume Safety
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="node-input-enableHighVolumeSafety" style="width:20px;vertical-align:baseline;" />
|
||||||
|
<span>Act before physical overflow</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-highVolumeSafetyThresholdPercent" style="padding-left:20px;">High-volume Safety (%)</label>
|
||||||
|
<input type="number" id="node-input-highVolumeSafetyThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||||
|
<span id="derived-highVolumeSafetyLevel" style="margin-left:8px;color:#777;font-size:12px;">→ highVolumeSafetyLevel ≈ — m</span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h3>Output Formats</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
|
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||||
|
<option value="process">process</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared asset/logger/position menus -->
|
<!-- Shared asset/logger/position menus -->
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const nameOfNode = 'pumpingStation'; // this is the name of the node, it should match the file name and the node type in Node-RED
|
const nameOfNode = 'pumpingStation'; // this is the name of the node, it should match the file name and the node type in Node-RED
|
||||||
|
const path = require('path');
|
||||||
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
|
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
|
||||||
const { MenuManager, configManager } = require('generalFunctions');
|
const { MenuManager, configManager } = require('generalFunctions');
|
||||||
|
|
||||||
@@ -37,4 +38,16 @@ module.exports = function(RED) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Editor JS modules — loaded by pumpingStation.html via <script src=...> tags.
|
||||||
|
// Files live in src/editor/. Filename is restricted to a safe charset to
|
||||||
|
// prevent path-traversal.
|
||||||
|
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
|
||||||
|
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
|
||||||
|
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
|
||||||
|
res.type('application/javascript');
|
||||||
|
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
|
||||||
|
if (err && !res.headersSent) res.status(404).send('// editor module not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
124
simulations/README.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Evaluation harness
|
||||||
|
|
||||||
|
Scenario-based evaluation for pumpingStation. Each scenario scripts a stream of inputs against a configured station, ticks the simulator at 1 s resolution, records every state, and prints a summary + event log + expectation check. Separate from unit tests (`test/`) — those verify individual pieces of logic in isolation; scenarios check end-to-end behaviour over time with realistic input trajectories.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One scenario
|
||||||
|
node simulations/run.js levelbased-steady
|
||||||
|
|
||||||
|
# All scenarios at once
|
||||||
|
node simulations/run.js --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-tick records are written to `simulations/logs/<scenario>.jsonl` for post-hoc analysis (e.g. streaming into InfluxDB for Grafana, or pandas / jq for one-off exploration).
|
||||||
|
|
||||||
|
## Scenario file shape
|
||||||
|
|
||||||
|
```js
|
||||||
|
// simulations/scenarios/<name>.js
|
||||||
|
module.exports = {
|
||||||
|
name: 'scenario-identifier',
|
||||||
|
description: 'one sentence — what the scenario is testing',
|
||||||
|
durationSec: 1200,
|
||||||
|
|
||||||
|
config: { /* PumpingStation config, same shape as nodeClass builds */ },
|
||||||
|
|
||||||
|
setup: async (ps) => {
|
||||||
|
// Optional. Wire fake MGCs, calibrate initial level, etc.
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: (t, ps) => {
|
||||||
|
// Called every tick (t in seconds). Drive inflow, mode changes,
|
||||||
|
// operator actions, etc.
|
||||||
|
ps.setManualInflow(0.005, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
|
||||||
|
expectations: [
|
||||||
|
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||||
|
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported expectation types
|
||||||
|
|
||||||
|
| Type | Semantics |
|
||||||
|
|---|---|
|
||||||
|
| `max_level_bounded` | max level across the run must be `≤ value` |
|
||||||
|
| `min_level_bounded` | min level across the run must be `≥ value` |
|
||||||
|
| `max_demand_bounded` | max percControl must be `≤ value` |
|
||||||
|
| `max_demand_gt` | max percControl must be `> value` |
|
||||||
|
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
|
||||||
|
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
|
||||||
|
| `end_state_eq` | final record's `field` must equal `value` |
|
||||||
|
| `threshold_issues_eq` | startup guardrail issue count must equal `value` |
|
||||||
|
|
||||||
|
Add new expectation types in `run.js` (`evalExpectation`).
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Example run:
|
||||||
|
|
||||||
|
```
|
||||||
|
═══ Scenario: levelbased-steady ═══
|
||||||
|
Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.
|
||||||
|
Duration: 1200s, 1s ticks
|
||||||
|
|
||||||
|
─── Samples (every 10%) ───
|
||||||
|
t(s) level(m) vol(m3) dir netFlow(m3/s) src demand safe
|
||||||
|
────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
0 2.00 20.00 steady 0 — 0% ·
|
||||||
|
120 2.64 26.40 draining -0.0026 predicted 62% ·
|
||||||
|
240 2.30 23.00 draining -0.0004 predicted 68% ·
|
||||||
|
...
|
||||||
|
|
||||||
|
─── Events (3) ───
|
||||||
|
t= 15s direction steady → filling
|
||||||
|
t= 134s direction filling → draining
|
||||||
|
|
||||||
|
─── Metrics ───
|
||||||
|
level min=2.00 max=2.73 end=2.33 m
|
||||||
|
percControl min=0% max=73% end=66%
|
||||||
|
safety trips=0 ticks
|
||||||
|
threshold issues=0 at startup
|
||||||
|
|
||||||
|
─── Expectations ───
|
||||||
|
✓ no safety trips: 0 ticks with safetyActive (expected 0)
|
||||||
|
✓ level stays below overflow: max level = 2.73 m (bound: ≤ 4.5)
|
||||||
|
✓ level stays above outflow: min level = 2.00 m (bound: ≥ 0.2)
|
||||||
|
✓ no threshold issues on init: 0 threshold issues at startup (expected 0)
|
||||||
|
|
||||||
|
Log: simulations/logs/levelbased-steady.jsonl (1200 records)
|
||||||
|
✅ PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why separate from `test/`?
|
||||||
|
|
||||||
|
| | `test/` | `simulations/` |
|
||||||
|
|---|---|---|
|
||||||
|
| runner | `node --test` | `node simulations/run.js` |
|
||||||
|
| scope | one function / small behaviour | end-to-end scenario over time |
|
||||||
|
| duration | milliseconds | seconds to minutes (simulated) |
|
||||||
|
| assertion style | tight, exact (`assert.equal`) | tolerance / bounds / event counts |
|
||||||
|
| output | TAP | summary table + JSONL for analysis |
|
||||||
|
| purpose | catch regressions | analyse how the system responds to input |
|
||||||
|
|
||||||
|
Unit tests live under `test/basic/`, `test/integration/`, `test/edge/`. Scenarios live here under `simulations/scenarios/`.
|
||||||
|
|
||||||
|
## Sending logs to Grafana (optional)
|
||||||
|
|
||||||
|
The JSONL output has one record per tick. To stream into InfluxDB for Grafana viewing, adapt a small consumer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq -c '{
|
||||||
|
measurement: "pumping_station_eval",
|
||||||
|
tags: { scenario: "'$SCENARIO'" },
|
||||||
|
fields: { level: .level, volume: .volume, demand: .percControl, safety: (.safetyActive|if . then 1 else 0 end) },
|
||||||
|
timestamp: (.t | tonumber | . * 1000000000)
|
||||||
|
}' simulations/logs/$SCENARIO.jsonl \
|
||||||
|
| influx write --bucket=telemetry ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `t` field is seconds from the scenario start (not wall-clock), so point the Grafana time range at `now() - $duration` after running.
|
||||||
40
simulations/formatters/table.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// ASCII table summary of scenario samples.
|
||||||
|
// Used by simulations/run.js.
|
||||||
|
|
||||||
|
function pad(s, n, left = false) {
|
||||||
|
s = String(s ?? '');
|
||||||
|
if (s.length >= n) return s.slice(0, n);
|
||||||
|
return left ? s.padStart(n) : s.padEnd(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(x, digits = 2) {
|
||||||
|
return Number.isFinite(x) ? x.toFixed(digits) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTable(records, sampleEvery = 1) {
|
||||||
|
if (!records.length) return ' (no records)';
|
||||||
|
const header = ['t(s)', 'level(m)', 'vol(m3)', 'dir', 'netFlow(m3/s)', 'src', 'demand', 'safe'];
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < records.length; i += sampleEvery) rows.push(records[i]);
|
||||||
|
if (rows[rows.length - 1] !== records[records.length - 1]) rows.push(records[records.length - 1]);
|
||||||
|
|
||||||
|
const widths = [6, 9, 9, 10, 14, 14, 8, 5];
|
||||||
|
const lines = [];
|
||||||
|
lines.push(header.map((h, i) => pad(h, widths[i], true)).join(' '));
|
||||||
|
lines.push(widths.map((w) => '─'.repeat(w)).join(' '));
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push([
|
||||||
|
pad(r.t, widths[0], true),
|
||||||
|
pad(num(r.level, 2), widths[1], true),
|
||||||
|
pad(num(r.volume, 2), widths[2], true),
|
||||||
|
pad(r.direction ?? '—', widths[3], true),
|
||||||
|
pad(num(r.netFlow, 5), widths[4], true),
|
||||||
|
pad(r.flowSource ?? '—', widths[5], true),
|
||||||
|
pad(num(r.percControl, 0) + '%', widths[6], true),
|
||||||
|
pad(r.safetyActive ? '⚠' : '·', widths[7], true),
|
||||||
|
].join(' '));
|
||||||
|
}
|
||||||
|
return lines.map((l) => ' ' + l).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { formatTable };
|
||||||
2
simulations/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*.jsonl
|
||||||
|
!.gitignore
|
||||||
201
simulations/run.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Scenario runner for pumpingStation. Usage:
|
||||||
|
//
|
||||||
|
// node simulations/run.js <scenario> # run one
|
||||||
|
// node simulations/run.js --all # run all scenarios
|
||||||
|
//
|
||||||
|
// Each scenario lives in simulations/scenarios/<name>.js and exports:
|
||||||
|
// { name, description, durationSec, config, setup?, inputs, expectations? }
|
||||||
|
//
|
||||||
|
// The runner ticks the station once per simulated second, records every
|
||||||
|
// state into simulations/logs/<name>.jsonl, prints a summary table + event log,
|
||||||
|
// and checks expectations.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const PumpingStation = require('../src/specificClass');
|
||||||
|
const { formatTable } = require('./formatters/table');
|
||||||
|
|
||||||
|
function loadScenario(name) {
|
||||||
|
return require(path.join(__dirname, 'scenarios', name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(t, ps) {
|
||||||
|
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
level: lvl,
|
||||||
|
volume: vol,
|
||||||
|
direction: ps.state?.direction ?? null,
|
||||||
|
netFlow: ps.state?.netFlow ?? null,
|
||||||
|
flowSource: ps.state?.flowSource ?? null,
|
||||||
|
timeleft: ps.state?.seconds ?? null,
|
||||||
|
percControl: ps.percControl,
|
||||||
|
mode: ps.mode,
|
||||||
|
safetyActive: !!ps.safetyControllerActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalExpectation(ex, records) {
|
||||||
|
const levels = records.map((r) => r.level).filter(Number.isFinite);
|
||||||
|
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
|
||||||
|
const last = records[records.length - 1] || {};
|
||||||
|
switch (ex.type) {
|
||||||
|
case 'max_level_bounded': {
|
||||||
|
const v = Math.max(...levels);
|
||||||
|
return { ok: v <= ex.value, msg: `max level = ${v.toFixed(2)} m (bound: ≤ ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'min_level_bounded': {
|
||||||
|
const v = Math.min(...levels);
|
||||||
|
return { ok: v >= ex.value, msg: `min level = ${v.toFixed(2)} m (bound: ≥ ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'max_demand_bounded': {
|
||||||
|
const v = Math.max(...demands);
|
||||||
|
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'max_demand_gt': {
|
||||||
|
const v = Math.max(...demands);
|
||||||
|
return { ok: v > ex.value, msg: `max demand = ${v.toFixed(0)} % (expected > ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'safety_trips_eq': {
|
||||||
|
const n = records.filter((r) => r.safetyActive).length;
|
||||||
|
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'safety_trips_gt': {
|
||||||
|
const n = records.filter((r) => r.safetyActive).length;
|
||||||
|
return { ok: n > ex.value, msg: `${n} ticks with safetyActive (expected > ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'end_state_eq': {
|
||||||
|
return { ok: last[ex.field] === ex.value, msg: `end ${ex.field} = ${last[ex.field]} (expected ${ex.value})` };
|
||||||
|
}
|
||||||
|
case 'threshold_issues_eq': {
|
||||||
|
const n = (records[0] && records[0].thresholdIssues) || 0;
|
||||||
|
return { ok: n === ex.value, msg: `${n} threshold issues at startup (expected ${ex.value})` };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { ok: false, msg: `unknown expectation type: ${ex.type}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function events(records) {
|
||||||
|
const out = [];
|
||||||
|
let prev = null;
|
||||||
|
for (const r of records) {
|
||||||
|
if (!prev) { prev = r; continue; }
|
||||||
|
if (r.direction !== prev.direction) out.push({ t: r.t, kind: 'direction', from: prev.direction, to: r.direction });
|
||||||
|
if (r.safetyActive !== prev.safetyActive) out.push({ t: r.t, kind: 'safety', active: r.safetyActive });
|
||||||
|
if (r.mode !== prev.mode) out.push({ t: r.t, kind: 'mode', from: prev.mode, to: r.mode });
|
||||||
|
prev = r;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScenario(name) {
|
||||||
|
const scenario = loadScenario(name);
|
||||||
|
|
||||||
|
// Use simulated time so the volume integrator sees 1 s per tick.
|
||||||
|
// The class reads Date.now() internally; monkey-patching lets it
|
||||||
|
// advance at scenario pace rather than wall-clock.
|
||||||
|
const realNow = Date.now;
|
||||||
|
let simTime = realNow();
|
||||||
|
Date.now = () => simTime;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ps = new PumpingStation(scenario.config);
|
||||||
|
if (scenario.setup) await scenario.setup(ps);
|
||||||
|
|
||||||
|
const duration = scenario.durationSec ?? 600;
|
||||||
|
const logDir = path.join(__dirname, 'logs');
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
const logPath = path.join(logDir, `${scenario.name}.jsonl`);
|
||||||
|
const log = fs.createWriteStream(logPath);
|
||||||
|
|
||||||
|
const records = [];
|
||||||
|
for (let t = 0; t < duration; t += 1) {
|
||||||
|
simTime += 1000; // advance 1 simulated second
|
||||||
|
if (scenario.inputs) scenario.inputs(t, ps);
|
||||||
|
ps.tick();
|
||||||
|
const snap = snapshot(t, ps);
|
||||||
|
snap.thresholdIssues = ps.thresholdIssues?.length ?? 0;
|
||||||
|
records.push(snap);
|
||||||
|
log.write(JSON.stringify(snap) + '\n');
|
||||||
|
}
|
||||||
|
// Drain so the file is fully written before we return.
|
||||||
|
await new Promise((resolve, reject) => { log.end(); log.on('finish', resolve); log.on('error', reject); });
|
||||||
|
|
||||||
|
return { ps, records, scenario, duration, logPath };
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAndReport(name) {
|
||||||
|
const { ps, records, scenario, duration, logPath } = await runScenario(name);
|
||||||
|
|
||||||
|
// Output
|
||||||
|
console.log(`\n═══ Scenario: ${scenario.name} ═══`);
|
||||||
|
console.log(scenario.description);
|
||||||
|
console.log(`Duration: ${duration}s, 1s ticks`);
|
||||||
|
|
||||||
|
console.log('\n─── Samples (every 10%) ───');
|
||||||
|
console.log(formatTable(records, Math.max(1, Math.floor(duration / 10))));
|
||||||
|
|
||||||
|
const evts = events(records);
|
||||||
|
console.log(`\n─── Events (${evts.length}) ───`);
|
||||||
|
if (!evts.length) console.log(' (none)');
|
||||||
|
for (const e of evts) {
|
||||||
|
if (e.kind === 'direction') console.log(` t=${String(e.t).padStart(4)}s direction ${e.from} → ${e.to}`);
|
||||||
|
else if (e.kind === 'safety') console.log(` t=${String(e.t).padStart(4)}s safety ${e.active ? 'ACTIVE ⚠' : 'cleared'}`);
|
||||||
|
else if (e.kind === 'mode') console.log(` t=${String(e.t).padStart(4)}s mode ${e.from} → ${e.to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n─── Metrics ───');
|
||||||
|
const levels = records.map((r) => r.level).filter(Number.isFinite);
|
||||||
|
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
|
||||||
|
const trips = records.filter((r) => r.safetyActive).length;
|
||||||
|
if (levels.length) {
|
||||||
|
console.log(` level min=${Math.min(...levels).toFixed(2)} max=${Math.max(...levels).toFixed(2)} end=${levels[levels.length-1].toFixed(2)} m`);
|
||||||
|
}
|
||||||
|
if (demands.length) {
|
||||||
|
console.log(` percControl min=${Math.min(...demands).toFixed(0)}% max=${Math.max(...demands).toFixed(0)}% end=${demands[demands.length-1].toFixed(0)}%`);
|
||||||
|
}
|
||||||
|
console.log(` safety trips=${trips} ticks`);
|
||||||
|
console.log(` threshold issues=${ps.thresholdIssues?.length ?? 0} at startup`);
|
||||||
|
|
||||||
|
let allOk = true;
|
||||||
|
if (scenario.expectations?.length) {
|
||||||
|
console.log('\n─── Expectations ───');
|
||||||
|
for (const ex of scenario.expectations) {
|
||||||
|
const { ok, msg } = evalExpectation(ex, records);
|
||||||
|
allOk = allOk && ok;
|
||||||
|
console.log(` ${ok ? '✓' : '✗'} ${ex.name}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nLog: ${path.relative(process.cwd(), logPath)} (${records.length} records)`);
|
||||||
|
console.log(allOk ? '✅ PASS' : '❌ FAIL');
|
||||||
|
return allOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const arg = process.argv[2];
|
||||||
|
if (!arg) {
|
||||||
|
console.error('Usage: node simulations/run.js <scenario> | --all');
|
||||||
|
console.error('Available:', fs.readdirSync(path.join(__dirname, 'scenarios')).map((f) => f.replace(/\.js$/, '')).join(', '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (arg === '--all') {
|
||||||
|
const names = fs.readdirSync(path.join(__dirname, 'scenarios')).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, ''));
|
||||||
|
let allOk = true;
|
||||||
|
for (const name of names) {
|
||||||
|
try { allOk = (await runAndReport(name)) && allOk; }
|
||||||
|
catch (err) { console.error(`ERROR in ${name}:`, err.message); allOk = false; }
|
||||||
|
}
|
||||||
|
process.exit(allOk ? 0 : 1);
|
||||||
|
}
|
||||||
|
try { process.exit((await runAndReport(arg)) ? 0 : 1); }
|
||||||
|
catch (err) { console.error('ERROR:', err.message, '\n', err.stack); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
61
simulations/scenarios/levelbased-steady.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Steady sewer inflow, level-based control, pumps should settle.
|
||||||
|
//
|
||||||
|
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
|
||||||
|
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
|
||||||
|
// inflowLevel and maxLevel while filling) at roughly the point where demand matches
|
||||||
|
// inflow. No safety trips should fire.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'levelbased-steady',
|
||||||
|
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
|
||||||
|
durationSec: 3600,
|
||||||
|
|
||||||
|
config: {
|
||||||
|
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||||
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: true,
|
||||||
|
dryRunThresholdPercent: 2,
|
||||||
|
enableHighVolumeSafety: true,
|
||||||
|
highVolumeSafetyThresholdPercent: 98,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: async (ps) => {
|
||||||
|
// Stub MGC: its pumps collectively deliver (demand/100) × MAX_OUTFLOW.
|
||||||
|
const MAX_OUTFLOW = 0.012; // m³/s
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1' } },
|
||||||
|
turnOffAllMachines: () => {
|
||||||
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
handleInput: async (_source, demand) => {
|
||||||
|
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||||
|
const outflow = (d / 100) * MAX_OUTFLOW;
|
||||||
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(2.0); // start at the mode start level, below the rising ramp
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: (t, ps) => {
|
||||||
|
ps.setManualInflow(0.008, Date.now(), 'm3/s'); // ≈ 29 m³/h
|
||||||
|
},
|
||||||
|
|
||||||
|
expectations: [
|
||||||
|
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||||
|
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||||
|
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
|
||||||
|
{ name: 'rising ramp engages after inlet level', type: 'max_demand_gt', value: 0 },
|
||||||
|
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
60
simulations/scenarios/levelbased-storm.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Storm surge — inflow triples briefly, pumps should increase demand as
|
||||||
|
// the level enters the rising ramp.
|
||||||
|
//
|
||||||
|
// Expectation: during the surge (t=300..600), demand rises but remains
|
||||||
|
// bounded. High-volume safety should fire if the surge overwhelms pump
|
||||||
|
// capacity; dry-run should not fire.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'levelbased-storm',
|
||||||
|
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. High-volume safety may engage.',
|
||||||
|
durationSec: 1500,
|
||||||
|
|
||||||
|
config: {
|
||||||
|
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||||
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: true,
|
||||||
|
dryRunThresholdPercent: 2,
|
||||||
|
enableHighVolumeSafety: true,
|
||||||
|
highVolumeSafetyThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: async (ps) => {
|
||||||
|
const MAX_OUTFLOW = 0.012; // m³/s pumps cannot keep up with 3× surge
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1' } },
|
||||||
|
turnOffAllMachines: () => {
|
||||||
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
handleInput: async (_src, demand) => {
|
||||||
|
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||||
|
const outflow = (d / 100) * MAX_OUTFLOW;
|
||||||
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(2.5);
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: (t, ps) => {
|
||||||
|
const surge = (t >= 300 && t < 600) ? 0.024 : 0.008;
|
||||||
|
ps.setManualInflow(surge, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
|
||||||
|
expectations: [
|
||||||
|
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
|
||||||
|
// Level may exceed maxLevel transiently but must stay under basinHeight
|
||||||
|
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
|
||||||
|
{ name: 'demand remains bounded during surge', type: 'max_demand_bounded', value: 100 },
|
||||||
|
],
|
||||||
|
};
|
||||||
66
simulations/scenarios/safety-dry-run-trip.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Dry-run safety trip — manual mode, fixed high demand, zero inflow.
|
||||||
|
// Levelbased control would taper demand as the level drops (its ramp),
|
||||||
|
// stalling drainage before safety fires. Manual mode holds demand
|
||||||
|
// constant so the level actually reaches the dry-run threshold.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'safety-dry-run-trip',
|
||||||
|
description: 'Manual mode, constant 100 % demand, zero inflow; expect safety to force-stop downstream pumps when level reaches the dry-run threshold.',
|
||||||
|
durationSec: 600,
|
||||||
|
|
||||||
|
config: {
|
||||||
|
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||||
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'manual',
|
||||||
|
allowedModes: new Set(['levelbased', 'manual']),
|
||||||
|
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: true,
|
||||||
|
dryRunThresholdPercent: 50,
|
||||||
|
enableHighVolumeSafety: false,
|
||||||
|
highVolumeSafetyThresholdPercent: 98,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: async (ps) => {
|
||||||
|
const MAX_OUTFLOW = 0.04;
|
||||||
|
let mgcRunning = true; // gets toggled by safety's shutdown call
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1', id: 'mgc1' }, functionality: { positionVsParent: 'downstream' } },
|
||||||
|
turnOffAllMachines: () => {
|
||||||
|
mgcRunning = false;
|
||||||
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
handleInput: async (_src, demand) => {
|
||||||
|
if (!mgcRunning) return;
|
||||||
|
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||||
|
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value((d / 100) * MAX_OUTFLOW, Date.now(), 'm3/s');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Need a downstream machine for safety to shut down
|
||||||
|
ps.machines['pump1'] = {
|
||||||
|
config: { general: { name: 'pump1', id: 'pump1' }, functionality: { positionVsParent: 'downstream' } },
|
||||||
|
_isOperationalState: () => mgcRunning,
|
||||||
|
handleInput: async (_src, action) => {
|
||||||
|
if (action === 'execSequence') mgcRunning = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(2.5);
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: (t, ps) => {
|
||||||
|
ps.setManualInflow(0, Date.now(), 'm3/s');
|
||||||
|
if (ps.mode === 'manual') ps.forwardDemandToChildren(100);
|
||||||
|
},
|
||||||
|
|
||||||
|
expectations: [
|
||||||
|
{ name: 'safety engages at some point', type: 'safety_trips_gt', value: 0 },
|
||||||
|
{ name: 'level never goes below outflow pipe', type: 'min_level_bounded', value: 0.2 },
|
||||||
|
],
|
||||||
|
};
|
||||||
99
src/basin/BasinGeometry.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// Basin geometry for a wet-well pumping station.
|
||||||
|
//
|
||||||
|
// Models the basin as a rectangular prism (constant cross-section), so
|
||||||
|
// volume = level × surfaceArea. Owns the level↔volume conversions and the
|
||||||
|
// derived threshold volumes used by control + safety. Pure domain — no
|
||||||
|
// Node-RED, no logger, no side effects beyond construction.
|
||||||
|
|
||||||
|
class BasinGeometry {
|
||||||
|
/**
|
||||||
|
* @param {object} basinConfig - { volume, height, inflowLevel, outflowLevel, overflowLevel }
|
||||||
|
* @param {object} hydraulicsConfig - { minHeightBasedOn: 'inlet' | 'outlet' }
|
||||||
|
*/
|
||||||
|
constructor(basinConfig, hydraulicsConfig) {
|
||||||
|
const volEmptyBasin = basinConfig.volume;
|
||||||
|
const heightBasin = basinConfig.height;
|
||||||
|
const inflowLevel = basinConfig.inflowLevel;
|
||||||
|
const outflowLevel = basinConfig.outflowLevel;
|
||||||
|
const overflowLevel = basinConfig.overflowLevel;
|
||||||
|
const inletPipeDiameter = basinConfig.inletPipeDiameter;
|
||||||
|
const outletPipeDiameter = basinConfig.outletPipeDiameter;
|
||||||
|
const minHeightBasedOn = hydraulicsConfig?.minHeightBasedOn;
|
||||||
|
|
||||||
|
const surfaceArea = volEmptyBasin / heightBasin;
|
||||||
|
|
||||||
|
// maxVol ≡ volEmptyBasin under the constant cross-section assumption;
|
||||||
|
// kept as a separate field for naming symmetry with the trigger volumes.
|
||||||
|
const maxVol = heightBasin * surfaceArea;
|
||||||
|
const maxVolAtOverflow = overflowLevel * surfaceArea;
|
||||||
|
const minVolAtOutflow = outflowLevel * surfaceArea;
|
||||||
|
const minVolAtInflow = inflowLevel * surfaceArea;
|
||||||
|
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
|
||||||
|
|
||||||
|
this._volEmptyBasin = volEmptyBasin;
|
||||||
|
this._heightBasin = heightBasin;
|
||||||
|
this._inflowLevel = inflowLevel;
|
||||||
|
this._outflowLevel = outflowLevel;
|
||||||
|
this._overflowLevel = overflowLevel;
|
||||||
|
this._inletPipeDiameter = inletPipeDiameter;
|
||||||
|
this._outletPipeDiameter = outletPipeDiameter;
|
||||||
|
this._surfaceArea = surfaceArea;
|
||||||
|
this._maxVol = maxVol;
|
||||||
|
this._maxVolAtOverflow = maxVolAtOverflow;
|
||||||
|
this._minVolAtInflow = minVolAtInflow;
|
||||||
|
this._minVolAtOutflow = minVolAtOutflow;
|
||||||
|
this._minVol = minVol;
|
||||||
|
this._minHeightBasedOn = minHeightBasedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
get volEmptyBasin() { return this._volEmptyBasin; }
|
||||||
|
get heightBasin() { return this._heightBasin; }
|
||||||
|
get inflowLevel() { return this._inflowLevel; }
|
||||||
|
get outflowLevel() { return this._outflowLevel; }
|
||||||
|
get overflowLevel() { return this._overflowLevel; }
|
||||||
|
get inletPipeDiameter() { return this._inletPipeDiameter; }
|
||||||
|
get outletPipeDiameter() { return this._outletPipeDiameter; }
|
||||||
|
get surfaceArea() { return this._surfaceArea; }
|
||||||
|
get maxVol() { return this._maxVol; }
|
||||||
|
get maxVolAtOverflow() { return this._maxVolAtOverflow; }
|
||||||
|
get minVolAtInflow() { return this._minVolAtInflow; }
|
||||||
|
get minVolAtOutflow() { return this._minVolAtOutflow; }
|
||||||
|
get minVol() { return this._minVol; }
|
||||||
|
get minHeightBasedOn() { return this._minHeightBasedOn; }
|
||||||
|
|
||||||
|
/** Convert level (m from floor) → volume (m3). Negative levels clamp to 0. */
|
||||||
|
volumeFromLevel(level) {
|
||||||
|
return Math.max(level, 0) * this._surfaceArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert volume (m3) → level (m from floor). Negative volumes clamp to 0. */
|
||||||
|
levelFromVolume(volume) {
|
||||||
|
return Math.max(volume, 0) / this._surfaceArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-object snapshot mirroring the legacy `this.basin` shape so
|
||||||
|
* getOutput / status code can keep using the same field names without
|
||||||
|
* caring whether it's holding a class instance or a plain object.
|
||||||
|
*/
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
volEmptyBasin: this._volEmptyBasin,
|
||||||
|
heightBasin: this._heightBasin,
|
||||||
|
inflowLevel: this._inflowLevel,
|
||||||
|
outflowLevel: this._outflowLevel,
|
||||||
|
overflowLevel: this._overflowLevel,
|
||||||
|
inletPipeDiameter: this._inletPipeDiameter,
|
||||||
|
outletPipeDiameter: this._outletPipeDiameter,
|
||||||
|
surfaceArea: this._surfaceArea,
|
||||||
|
maxVol: this._maxVol,
|
||||||
|
maxVolAtOverflow: this._maxVolAtOverflow,
|
||||||
|
minVolAtInflow: this._minVolAtInflow,
|
||||||
|
minVolAtOutflow: this._minVolAtOutflow,
|
||||||
|
minVol: this._minVol,
|
||||||
|
minHeightBasedOn: this._minHeightBasedOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BasinGeometry;
|
||||||
107
src/basin/thresholdValidator.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// Threshold-ordering validator for the pumpingStation basin + control +
|
||||||
|
// safety config. Pure: returns the issues array, never logs or throws.
|
||||||
|
// The caller decides what to do (warn, surface to status badge, fail tests).
|
||||||
|
//
|
||||||
|
// Invariants enforced (level-space, bottom → top):
|
||||||
|
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
|
// dryRunLevel ≤ minLevel ≤ startLevel ≤ holdLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||||
|
//
|
||||||
|
// startLevel is INTENTIONALLY not constrained against inflowLevel: setting
|
||||||
|
// startLevel above the gravity-feed inlet is the "buffer in the sewer"
|
||||||
|
// configuration where the upstream pipe network is used as overflow storage
|
||||||
|
// before pumping engages. holdLevel (optional, defaults to startLevel when
|
||||||
|
// omitted) is the 0 % ramp foot — pumps engage at startLevel but hold at
|
||||||
|
// min flow until level rises through holdLevel.
|
||||||
|
//
|
||||||
|
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
|
||||||
|
// The validator recomputes them so a config that places minLevel below the
|
||||||
|
// effective dry-run trigger (a no-op control band) is caught here.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived safety thresholds + reference levels. Exposed so the editor /
|
||||||
|
* status badge / FlowAggregator can read the same values without
|
||||||
|
* recomputing them.
|
||||||
|
*/
|
||||||
|
function computeSafetyPoints(basin, safety = {}) {
|
||||||
|
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||||||
|
const rawHighPct = safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent;
|
||||||
|
// When neither high-volume nor overfill pct is supplied, use 100 % so
|
||||||
|
// the validator's `maxLevel <= highVolumeSafetyLevel` check is a no-op
|
||||||
|
// (the basin can't physically exceed overflow anyway). Tests pin this.
|
||||||
|
const highPct = Number(rawHighPct);
|
||||||
|
const effectiveHighPct = Number.isFinite(highPct) ? highPct : 100;
|
||||||
|
const minVol = Number(basin?.minVol) || 0;
|
||||||
|
const maxVolAtOverflow = Number(basin?.maxVolAtOverflow) || 0;
|
||||||
|
const dryRunSafetyVol = minVol * (1 + dryRunPct / 100);
|
||||||
|
const highVolumeSafetyVol = maxVolAtOverflow * (effectiveHighPct / 100);
|
||||||
|
const refLowLevel = basin?.minHeightBasedOn === 'inlet'
|
||||||
|
? Number(basin?.inflowLevel)
|
||||||
|
: Number(basin?.outflowLevel);
|
||||||
|
const dryRunLevel = Number.isFinite(refLowLevel)
|
||||||
|
? refLowLevel * (1 + dryRunPct / 100)
|
||||||
|
: Number.NaN;
|
||||||
|
const overflowLevel = Number(basin?.overflowLevel) || 0;
|
||||||
|
const highVolumeSafetyLevel = overflowLevel * (effectiveHighPct / 100);
|
||||||
|
return {
|
||||||
|
dryRunSafetyVol,
|
||||||
|
dryRunLevel,
|
||||||
|
highVolumeSafetyVol,
|
||||||
|
highVolumeSafetyLevel,
|
||||||
|
// Back-compat alias — pre-basin-docs name.
|
||||||
|
overfillVol: highVolumeSafetyVol,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} basin - BasinGeometry instance OR plain {inflowLevel, outflowLevel, overflowLevel, heightBasin, minHeightBasedOn}
|
||||||
|
* @param {object} levelbased - config.control.levelbased ({ minLevel, startLevel, maxLevel })
|
||||||
|
* @param {object} safety - config.safety ({ dryRunThresholdPercent, highVolumeSafetyThresholdPercent | overfillThresholdPercent })
|
||||||
|
* @returns {Array<{aName, a, op, bName, b, msg}>}
|
||||||
|
*/
|
||||||
|
function validateThresholdOrdering(basin, levelbased, safety) {
|
||||||
|
const lvl = levelbased || {};
|
||||||
|
const points = computeSafetyPoints(basin, safety);
|
||||||
|
const { dryRunLevel, highVolumeSafetyLevel } = points;
|
||||||
|
|
||||||
|
// holdLevel is optional — when omitted (null/undefined/NaN) it equals
|
||||||
|
// startLevel at runtime, so skip both holdLevel-related checks in that
|
||||||
|
// case (the canonical engine semantics still hold). Explicit null/undefined
|
||||||
|
// check first so `Number(null) === 0` doesn't accidentally flag a default
|
||||||
|
// schema value as a real operator-provided one.
|
||||||
|
const rawHold = lvl.holdLevel;
|
||||||
|
const holdLevelProvided = rawHold != null && Number.isFinite(Number(rawHold));
|
||||||
|
const holdLevel = holdLevelProvided ? Number(rawHold) : null;
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
...(holdLevelProvided ? [
|
||||||
|
['startLevel', lvl.startLevel, '<=', 'holdLevel', holdLevel],
|
||||||
|
['holdLevel', holdLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
] : []),
|
||||||
|
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
for (const [aName, a, op, bName, b] of checks) {
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
|
||||||
|
const ok = op === '<' ? a < b : a <= b;
|
||||||
|
if (!ok) {
|
||||||
|
issues.push({
|
||||||
|
aName,
|
||||||
|
a,
|
||||||
|
op,
|
||||||
|
bName,
|
||||||
|
b,
|
||||||
|
msg: `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateThresholdOrdering, computeSafetyPoints };
|
||||||
98
src/commands/handlers.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Handler functions for pumpingStation commands. Each handler receives:
|
||||||
|
// source: the domain (specificClass) instance — has the public methods
|
||||||
|
// (changeMode, calibratePredicted*, setManualInflow, ...).
|
||||||
|
// msg: the Node-RED input message.
|
||||||
|
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||||
|
//
|
||||||
|
// Handlers are pure functions: they don't keep state. Validation that goes
|
||||||
|
// beyond the registry's typeof-check ladder lives here.
|
||||||
|
|
||||||
|
function _logger(source, ctx) {
|
||||||
|
return ctx?.logger || source?.logger || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.setMode = (source, msg) => {
|
||||||
|
source.changeMode(msg.payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.registerChild = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const childId = msg.payload;
|
||||||
|
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
|
||||||
|
if (!childObj || !childObj.source) {
|
||||||
|
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.calibrateVolume = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const v = parseFloat(msg.payload);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
log?.warn?.(`cmd.calibrate.volume: non-numeric payload '${msg.payload}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.calibratePredictedVolume(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.calibrateLevel = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const v = parseFloat(msg.payload);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
log?.warn?.(`cmd.calibrate.level: non-numeric payload '${msg.payload}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.calibratePredictedLevel(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The registry has already normalised any accepted shape (number, numeric
|
||||||
|
// string, or { value, unit } object) to a number in the descriptor unit
|
||||||
|
// (m3/h) and tagged msg.unit. Handlers just read the normalised scalar.
|
||||||
|
exports.setInflow = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const value = Number(msg.payload);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
log?.warn?.(`set.inflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.setManualInflow(value, msg.timestamp, msg.unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setOutflow = (source, msg, ctx) => {
|
||||||
|
// Manual q_out — basin-docs dashboard injects a drain rate without wiring a
|
||||||
|
// real pump. Same normalised shape as set.inflow.
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const value = Number(msg.payload);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
log?.warn?.(`set.outflow: non-numeric payload '${JSON.stringify(msg.payload)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.setManualOutflow(value, msg.timestamp, msg.unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDemand = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
// generalFunctions/commandRegistry's _normaliseUnits has already converted
|
||||||
|
// msg.payload to m3/h (the descriptor's units.default — see
|
||||||
|
// commands/index.js). Accepts {value, unit} objects upstream; we just read
|
||||||
|
// the normalized number here. _manualDemand is stored in m3/h, no further
|
||||||
|
// conversion needed.
|
||||||
|
const demand = Number(msg?.payload);
|
||||||
|
if (!Number.isFinite(demand)) {
|
||||||
|
log?.warn?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source.mode !== 'manual') {
|
||||||
|
log?.debug?.(
|
||||||
|
`set.demand ignored in '${source.mode}' mode; switch to manual to use the demand slider`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// forwardDemandToChildren returns a promise — surface failures via logger.
|
||||||
|
Promise.resolve(source.forwardDemandToChildren(demand)).catch((err) => {
|
||||||
|
log?.error?.(`set.demand: failed to forward demand: ${err && err.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
68
src/commands/index.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// pumpingStation command registry. Consumed by BaseNodeAdapter via
|
||||||
|
// `static commands = require('./commands')`. Each descriptor maps a
|
||||||
|
// canonical msg.topic to its handler; legacy names are listed under
|
||||||
|
// `aliases` and emit a one-time deprecation warning at runtime.
|
||||||
|
|
||||||
|
const handlers = require('./handlers');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'set.mode',
|
||||||
|
aliases: ['changemode'],
|
||||||
|
payloadSchema: { type: 'string' },
|
||||||
|
description: 'Switch the station between auto / manual control modes.',
|
||||||
|
handler: handlers.setMode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'child.register',
|
||||||
|
aliases: ['registerChild'],
|
||||||
|
// payload is the Node-RED id (string) of the child node.
|
||||||
|
payloadSchema: { type: 'string' },
|
||||||
|
description: 'Register a child node (machine group, measurement, …) with this station.',
|
||||||
|
handler: handlers.registerChild,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'cmd.calibrate.volume',
|
||||||
|
aliases: ['calibratePredictedVolume'],
|
||||||
|
// any: payload may be a number, numeric string, or { value, unit } object —
|
||||||
|
// the registry normalises all of them to a number in `unit` before the handler.
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
unit: 'm3',
|
||||||
|
description: 'Calibrate the predicted-volume integrator to a known basin volume.',
|
||||||
|
handler: handlers.calibrateVolume,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'cmd.calibrate.level',
|
||||||
|
aliases: ['calibratePredictedLevel'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
unit: 'm',
|
||||||
|
description: 'Calibrate the predicted-volume integrator to a known basin level.',
|
||||||
|
handler: handlers.calibrateLevel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.inflow',
|
||||||
|
aliases: ['q_in'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
unit: 'm3/h',
|
||||||
|
description: 'Push a measured inflow value into the basin balance.',
|
||||||
|
handler: handlers.setInflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.outflow',
|
||||||
|
aliases: ['q_out'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
unit: 'm3/h',
|
||||||
|
description: 'Push a measured outflow value into the basin balance.',
|
||||||
|
handler: handlers.setOutflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.demand',
|
||||||
|
aliases: ['Qd'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
unit: 'm3/h',
|
||||||
|
description: 'Operator outflow demand setpoint for the station.',
|
||||||
|
handler: handlers.setDemand,
|
||||||
|
},
|
||||||
|
];
|
||||||
11
src/control/flowBased.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Placeholder — flow-based control mode is not yet implemented.
|
||||||
|
// The dispatcher routes here when config.control.mode === 'flowbased',
|
||||||
|
// at which point a real implementation should land in this file.
|
||||||
|
async function run(ctx) {
|
||||||
|
ctx?.logger?.debug?.('flow-based mode not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'flowbased',
|
||||||
|
run,
|
||||||
|
};
|
||||||
20
src/control/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const levelBased = require('./levelBased');
|
||||||
|
const flowBased = require('./flowBased');
|
||||||
|
const manual = require('./manual');
|
||||||
|
|
||||||
|
const strategies = {
|
||||||
|
[levelBased.name]: levelBased,
|
||||||
|
[flowBased.name]: flowBased,
|
||||||
|
[manual.name]: manual,
|
||||||
|
};
|
||||||
|
|
||||||
|
function dispatch(mode, ctx, controlState, direction) {
|
||||||
|
const s = strategies[mode];
|
||||||
|
if (!s) {
|
||||||
|
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return s.run(ctx, controlState, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { strategies, dispatch, manual };
|
||||||
286
src/control/levelBased.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// Level-based control strategy.
|
||||||
|
//
|
||||||
|
// Ported from basin-docs `_controlLevelBased` into the refactored
|
||||||
|
// strategy module. Concerns kept here:
|
||||||
|
// 1. minLevel hard-stop (unconditional MGC shutdown).
|
||||||
|
// 2. stopLevel Schmitt-trigger hysteresis — pumps stay engaged
|
||||||
|
// through the dead band [stopLevel, startLevel] emitting a small
|
||||||
|
// keep-alive demand so MGC keeps a single pump draining the basin.
|
||||||
|
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
||||||
|
// [max(startLevel, inflowLevel), maxLevel] using linear or log shape.
|
||||||
|
// Foot at startLevel when startLevel > inflowLevel allows buffering
|
||||||
|
// in the upstream sewer above the gravity-feed point.
|
||||||
|
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
||||||
|
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
||||||
|
// flip it captures the up-curve value as `hold`; while draining
|
||||||
|
// the output stays at `hold` until level falls to shiftLevel, then
|
||||||
|
// ramps `hold → 0 %` over [shiftLevel, startLevel]. Disarms when
|
||||||
|
// level reaches startLevel.
|
||||||
|
//
|
||||||
|
// Hysteresis flags live on the host (specificClass instance) — the
|
||||||
|
// strategy reads/writes via ctx.host so the same flags survive across
|
||||||
|
// ticks regardless of how often the context view is rebuilt.
|
||||||
|
|
||||||
|
// Apply the configured curve shape to a normalized x in [0, 1].
|
||||||
|
// Linear by default; log when curveType is 'log'.
|
||||||
|
function _curveShape(x, levelbased) {
|
||||||
|
const { curveType = 'linear', logCurveFactor = 9 } = levelbased || {};
|
||||||
|
const clamped = Math.max(0, Math.min(1, x));
|
||||||
|
if (curveType === 'log') {
|
||||||
|
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
|
||||||
|
? Number(logCurveFactor) : 9;
|
||||||
|
return Math.log1p(factor * clamped) / Math.log1p(factor);
|
||||||
|
}
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map level to demand % across [rampFoot, rampTop]. Returns 0 below the
|
||||||
|
// foot, 100 above the top. Curve type controlled by levelbased.curveType.
|
||||||
|
function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
|
||||||
|
if (!Number.isFinite(level) || !Number.isFinite(rampFoot) || !Number.isFinite(rampTop)) return 0;
|
||||||
|
if (rampTop <= rampFoot) return level >= rampTop ? 100 : 0;
|
||||||
|
if (level <= rampFoot) return 0;
|
||||||
|
if (level >= rampTop) return 100;
|
||||||
|
const x = (level - rampFoot) / (rampTop - rampFoot);
|
||||||
|
return 100 * _curveShape(x, levelbased);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||||
|
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||||
|
// The caller (run() below) already gated turn-off via the minLevel
|
||||||
|
// hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
|
||||||
|
// By the time we get here, pumps should be running — `0 %` is the engaged
|
||||||
|
// "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a
|
||||||
|
// soft turn-off. Forward unconditionally.
|
||||||
|
const forward = (group) => {
|
||||||
|
if (typeof group.setDemand !== 'function') {
|
||||||
|
logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await Promise.all(Object.values(machineGroups).map(forward));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||||
|
const filtered = Object.values(machines).filter((machine) => {
|
||||||
|
const pos = machine?.config?.functionality?.positionVsParent;
|
||||||
|
return (pos === 'downstream' || pos === 'atequipment');
|
||||||
|
});
|
||||||
|
if (!filtered.length) return;
|
||||||
|
|
||||||
|
const perMachine = percentControl / filtered.length;
|
||||||
|
for (const machine of filtered) {
|
||||||
|
try {
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error?.(`Failed to start machine "${machine.config?.general?.name}": ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pickVariant(measurements, type, variants, position, unit) {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const val = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||||
|
if (!Number.isFinite(val)) continue;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(ctx, controlState, direction) {
|
||||||
|
const { measurements, config, logger, machineGroups, basin, levelVariants, host } = ctx;
|
||||||
|
const cfg = config.control.levelbased || {};
|
||||||
|
const { startLevel, minLevel, maxLevel } = cfg;
|
||||||
|
const levelUnit = measurements.getUnit('level');
|
||||||
|
|
||||||
|
const variants = levelVariants || ['measured', 'predicted'];
|
||||||
|
const level = _pickVariant(measurements, 'level', variants, 'atequipment', levelUnit);
|
||||||
|
if (level == null) {
|
||||||
|
logger?.warn?.('No valid level found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. minLevel hard-stop — unconditional MGC shutdown.
|
||||||
|
if (level < minLevel) {
|
||||||
|
controlState.percControl = 0;
|
||||||
|
if (host) {
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._stopHystRunning = false;
|
||||||
|
host._lastDirection = direction;
|
||||||
|
}
|
||||||
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. stopLevel hysteresis (Schmitt trigger).
|
||||||
|
// Requires an explicit positive stopLevel — configManager merges null
|
||||||
|
// defaults to 0 otherwise, which would activate the hysteresis on every
|
||||||
|
// config that omitted it.
|
||||||
|
const stopLvl = Number(cfg.stopLevel);
|
||||||
|
const stopThresholdActive = cfg.stopLevel != null && Number.isFinite(stopLvl)
|
||||||
|
&& stopLvl > 0 && stopLvl < maxLevel;
|
||||||
|
if (stopThresholdActive && level <= stopLvl) {
|
||||||
|
controlState.percControl = 0;
|
||||||
|
if (host) {
|
||||||
|
host._stopHystRunning = false;
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
host._lastDirection = direction;
|
||||||
|
}
|
||||||
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (host) {
|
||||||
|
if (stopThresholdActive) {
|
||||||
|
if (!host._stopHystRunning && level >= startLevel) host._stopHystRunning = true;
|
||||||
|
} else {
|
||||||
|
host._stopHystRunning = level >= startLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Engagement gate. Pumps stay OFF until level rises through startLevel
|
||||||
|
// for the first time (rising-edge); once engaged they stay on until
|
||||||
|
// level drops through stopLevel (falling-edge — handled by case 2).
|
||||||
|
// Without an explicit stopLevel the gate collapses to `level >= startLevel`.
|
||||||
|
// Moved out of the percentControl path so 0 % can mean "engaged at
|
||||||
|
// min flow" instead of "stopped". Disengagement also clears the
|
||||||
|
// shifted-ramp hysteresis so it doesn't survive a stop/start cycle.
|
||||||
|
const isEngaged = host ? host._stopHystRunning : (level >= startLevel);
|
||||||
|
if (!isEngaged) {
|
||||||
|
controlState.percControl = 0;
|
||||||
|
if (host) {
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
host._lastDirection = direction;
|
||||||
|
}
|
||||||
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators
|
||||||
|
// can raise it to introduce a hold band [startLevel, holdLevel] where
|
||||||
|
// pumps run at min flow before the ramp begins). `inflowLevel` does NOT
|
||||||
|
// shape the curve — it's basin geometry, not a control setpoint.
|
||||||
|
// Explicit null/undefined check first so `Number(null) === 0` doesn't
|
||||||
|
// silently put the ramp foot at the basin floor.
|
||||||
|
const rawHold = cfg.holdLevel;
|
||||||
|
const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold)))
|
||||||
|
? Number(rawHold) : startLevel;
|
||||||
|
const rampFoot = Math.max(startLevel, holdLevel);
|
||||||
|
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
||||||
|
|
||||||
|
// 5. Shifted-ramp arming.
|
||||||
|
if (host) {
|
||||||
|
if (cfg.enableShiftedRamp) {
|
||||||
|
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||||
|
if (!host._shiftArmed && upPct >= armPct) {
|
||||||
|
host._shiftArmed = true;
|
||||||
|
logger?.debug?.(`Shift armed: upPct=${upPct} >= ${armPct}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
host._shiftArmed = false;
|
||||||
|
}
|
||||||
|
if (level <= startLevel) {
|
||||||
|
host._shiftArmed = false;
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
}
|
||||||
|
// Capture hold on filling→draining transition while armed.
|
||||||
|
if (cfg.enableShiftedRamp && host._shiftArmed) {
|
||||||
|
if (host._lastDirection !== 'draining' && direction === 'draining') {
|
||||||
|
host._shiftHoldValue = upPct;
|
||||||
|
logger?.debug?.(`Shift hold captured: ${upPct} % at level=${level}`);
|
||||||
|
} else if (direction === 'filling') {
|
||||||
|
// Returning to filling clears any captured hold; the next drain
|
||||||
|
// transition will recapture from the up curve.
|
||||||
|
host._shiftHoldValue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (direction === 'filling' || direction === 'draining') {
|
||||||
|
host._lastDirection = direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute output.
|
||||||
|
const shiftArmed = !!host?._shiftArmed;
|
||||||
|
const shiftHold = host?._shiftHoldValue;
|
||||||
|
const inDrainingHold = cfg.enableShiftedRamp && shiftArmed
|
||||||
|
&& direction === 'draining' && shiftHold != null;
|
||||||
|
|
||||||
|
let percControl;
|
||||||
|
if (!inDrainingHold) {
|
||||||
|
if (level < rampFoot) {
|
||||||
|
// Engaged (we passed the gate above) but below the ramp foot. Two
|
||||||
|
// sub-cases:
|
||||||
|
// (a) Inside the configurable hold band [startLevel, holdLevel] —
|
||||||
|
// emit 0 %, which MGC's setDemand interpolates to flow.min.
|
||||||
|
// (b) Inside the falling-edge keep-alive band [stopLevel, startLevel]
|
||||||
|
// — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps
|
||||||
|
// at least one pump turning rather than dispatching a clean min.
|
||||||
|
if (stopThresholdActive && level < startLevel) {
|
||||||
|
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
||||||
|
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||||
|
percControl = Math.max(0, keepAlive);
|
||||||
|
} else {
|
||||||
|
percControl = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
percControl = Math.max(0, upPct);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hold = shiftHold;
|
||||||
|
const shift = cfg.shiftLevel;
|
||||||
|
if (!Number.isFinite(shift) || shift <= startLevel) {
|
||||||
|
// Bad config — fall back to up curve.
|
||||||
|
percControl = Math.max(0, upPct);
|
||||||
|
} else if (level >= shift) {
|
||||||
|
percControl = hold;
|
||||||
|
} else if (level > startLevel) {
|
||||||
|
// Ramp [shift, hold] → [start, 0] using the same curve shape.
|
||||||
|
const x = (level - startLevel) / (shift - startLevel);
|
||||||
|
percControl = Math.max(0, hold * _curveShape(x, cfg));
|
||||||
|
} else {
|
||||||
|
percControl = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controlState.percControl = percControl;
|
||||||
|
logger?.debug?.(
|
||||||
|
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// We are past every off-gate, so the station is engaged and the computed
|
||||||
|
// demand is meant to drive pumps. If no machine group is registered the
|
||||||
|
// demand has nowhere to go and the pumps stay silent — the signature of a
|
||||||
|
// dropped Port 2 parent↔group registration (e.g. after a partial redeploy
|
||||||
|
// that recreated this node). Warn once until a group reappears so the
|
||||||
|
// failure isn't invisible.
|
||||||
|
const groupCount = machineGroups ? Object.keys(machineGroups).length : 0;
|
||||||
|
if (groupCount === 0) {
|
||||||
|
if (host && !host._warnedNoMachineGroup) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — `
|
||||||
|
+ `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; `
|
||||||
|
+ `redeploy/restart fully to re-run the Port 2 registration handshake.`
|
||||||
|
);
|
||||||
|
host._warnedNoMachineGroup = true;
|
||||||
|
}
|
||||||
|
} else if (host) {
|
||||||
|
host._warnedNoMachineGroup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'levelbased',
|
||||||
|
run,
|
||||||
|
_scaleLevelToFlowPercent,
|
||||||
|
_curveShape,
|
||||||
|
_applyMachineGroupLevelControl,
|
||||||
|
_applyMachineLevelControl,
|
||||||
|
};
|
||||||
49
src/control/manual.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
async function run() {
|
||||||
|
// No-op: manual mode is event-driven via set.demand → forwardDemand,
|
||||||
|
// not tick-driven.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardDemand(ctx, demand) {
|
||||||
|
const { machineGroups, machines, unitPolicy, logger } = ctx;
|
||||||
|
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||||
|
|
||||||
|
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||||
|
const groupDemand = unitPolicy.convert(demand, 'm3/h', 'm3/s', 'manual demand to machineGroups');
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(machineGroups).map((group) =>
|
||||||
|
group.handleInput('parent', groupDemand).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (machines && Object.keys(machines).length > 0) {
|
||||||
|
const perMachine = demand / Object.keys(machines).length;
|
||||||
|
for (const machine of Object.values(machines)) {
|
||||||
|
try {
|
||||||
|
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error?.(`Failed to forward demand to machine: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither a group nor a direct machine is registered, so the operator's
|
||||||
|
// demand silently goes nowhere. Surface it — the usual cause is a dropped
|
||||||
|
// Port 2 parent↔child registration after a partial redeploy.
|
||||||
|
const noGroups = !machineGroups || Object.keys(machineGroups).length === 0;
|
||||||
|
const noMachines = !machines || Object.keys(machines).length === 0;
|
||||||
|
if (noGroups && noMachines) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`Manual demand ${demand} not forwarded — no machine group or machine is registered to this pumping station. `
|
||||||
|
+ `Check the parent↔child Port 2 registration (redeploy/restart fully to restore it).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'manual',
|
||||||
|
run,
|
||||||
|
forwardDemand,
|
||||||
|
};
|
||||||
196
src/editor/basin-diagram.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// PumpingStation editor — interactive basin SVG (top of the editor).
|
||||||
|
// Places threshold lines, derived safety levels, zone labels, dead-volume
|
||||||
|
// band, and ordering warnings. Same formulas as
|
||||||
|
// specificClass._validateThresholdOrdering.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
const fNum = (id) => ns.fNum(id);
|
||||||
|
|
||||||
|
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
|
||||||
|
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
|
||||||
|
const DIAG = { topY: 40, botY: 380 };
|
||||||
|
|
||||||
|
const yForLevel = (val, basinH) => {
|
||||||
|
if (val == null || !basinH) return null;
|
||||||
|
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
||||||
|
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Place a row — line, label, input, unit all share the same y.
|
||||||
|
const placeItem = (id, y) => {
|
||||||
|
const line = document.getElementById(`ps-line-${id}`);
|
||||||
|
const label = document.getElementById(`ps-label-${id}`);
|
||||||
|
const unit = document.getElementById(`ps-unit-${id}`);
|
||||||
|
const fo = document.getElementById(`ps-fo-${id}`);
|
||||||
|
const sub = document.getElementById(`ps-sub-${id}`);
|
||||||
|
const lead = document.getElementById(`ps-leader-${id}`);
|
||||||
|
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
||||||
|
if (label) label.setAttribute('y', y + 4);
|
||||||
|
if (unit) unit.setAttribute('y', y + 4);
|
||||||
|
if (fo) fo.setAttribute('y', y - 11);
|
||||||
|
if (sub) sub.setAttribute('y', y + 15);
|
||||||
|
if (lead) lead.setAttribute('visibility', 'hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
ns.basinDiagram = {
|
||||||
|
redraw() {
|
||||||
|
const basinH = fNum('basinHeight') || 5;
|
||||||
|
|
||||||
|
const refLow = fNum('outflowLevel');
|
||||||
|
const dryPct = fNum('dryRunThresholdPercent');
|
||||||
|
const highPct = fNum('highVolumeSafetyThresholdPercent');
|
||||||
|
const ovf = fNum('overflowLevel');
|
||||||
|
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||||
|
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
|
||||||
|
|
||||||
|
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
|
||||||
|
// outflowLevel pinned at its proportional y. Two passes (top-down +
|
||||||
|
// bottom-up) maintain a minimum vertical gap.
|
||||||
|
const items = [
|
||||||
|
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||||
|
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||||
|
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
|
||||||
|
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), basinH) },
|
||||||
|
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
||||||
|
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
||||||
|
].filter(it => it.yIdeal != null);
|
||||||
|
|
||||||
|
const GAP = 36;
|
||||||
|
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
||||||
|
for (const it of items) it.y = it.yIdeal;
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
if (items[i].pinned) continue;
|
||||||
|
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
||||||
|
}
|
||||||
|
for (let i = items.length - 2; i >= 0; i--) {
|
||||||
|
if (items[i].pinned) continue;
|
||||||
|
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
||||||
|
}
|
||||||
|
for (const it of items) placeItem(it.id, it.y);
|
||||||
|
|
||||||
|
// Zone labels show only when the gap between the bracketing
|
||||||
|
// thresholds is at least MIN_ZONE_GAP px high — otherwise the label
|
||||||
|
// collides with one of the threshold labels (which sit at threshold
|
||||||
|
// y ±6 px text-height). 28 px keeps a 6 px clear gap above and
|
||||||
|
// below the zone label.
|
||||||
|
const MIN_ZONE_GAP = 28;
|
||||||
|
const placeZone = (zoneId, topId, botId) => {
|
||||||
|
const el = document.getElementById(`ps-zone-${zoneId}`);
|
||||||
|
if (!el) return;
|
||||||
|
const top = items.find(it => it.id === topId);
|
||||||
|
const bot = items.find(it => it.id === botId);
|
||||||
|
if (!top || !bot || (bot.y - top.y) < MIN_ZONE_GAP) {
|
||||||
|
el.setAttribute('visibility', 'hidden'); return;
|
||||||
|
}
|
||||||
|
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||||
|
el.setAttribute('visibility', 'visible');
|
||||||
|
};
|
||||||
|
placeZone('spare', 'overflowLevel', 'highVolumeSafetyLevel');
|
||||||
|
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
|
||||||
|
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
|
||||||
|
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
|
||||||
|
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
||||||
|
const deadLbl = document.getElementById('ps-zone-dead');
|
||||||
|
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
||||||
|
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
||||||
|
deadLbl.setAttribute('visibility', 'visible');
|
||||||
|
} else if (deadLbl) {
|
||||||
|
deadLbl.setAttribute('visibility', 'hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||||
|
if (inflowY != null) {
|
||||||
|
const line = document.getElementById('ps-line-inflowLevel');
|
||||||
|
const lbl = document.getElementById('ps-label-inflowLevel');
|
||||||
|
const sub = document.getElementById('ps-sub-inflowLevel');
|
||||||
|
const fo = document.getElementById('ps-fo-inflowLevel');
|
||||||
|
const unit = document.getElementById('ps-unit-inflowLevel');
|
||||||
|
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
||||||
|
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
||||||
|
if (sub) sub.setAttribute('y', inflowY + 8);
|
||||||
|
if (fo) fo.setAttribute('y', inflowY - 11);
|
||||||
|
if (unit) unit.setAttribute('y', inflowY + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
||||||
|
const deadvol = document.getElementById('ps-deadvol');
|
||||||
|
if (deadvol && outflowItem) {
|
||||||
|
deadvol.setAttribute('y', outflowItem.y);
|
||||||
|
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG labels — keep them short, side panel shows the numeric value.
|
||||||
|
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||||
|
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
|
||||||
|
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
|
||||||
|
if (highLbl) highLbl.textContent = 'highVolumeSafety';
|
||||||
|
|
||||||
|
// Side-panel read-only displays — number only ("m" is shown in the unit span).
|
||||||
|
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||||
|
const d1 = document.getElementById('derived-dryRunLevel');
|
||||||
|
if (d1) d1.textContent = fmt(dryLvl);
|
||||||
|
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
|
||||||
|
if (d2) d2.textContent = fmt(highLvl);
|
||||||
|
|
||||||
|
// Hierarchy validation. Soft '≤' relations follow the user's choice:
|
||||||
|
// start ≤ inflow, max ≤ overflow, overflow ≤ basinHeight (equality OK).
|
||||||
|
// dryRunLevel must be < startLevel strictly (otherwise the runtime
|
||||||
|
// would trip dry-run before it could ramp).
|
||||||
|
// Re-read the raw value (basinH falls back to 5 for diagram scaling;
|
||||||
|
// here we want null when the user hasn't entered anything so the
|
||||||
|
// ≤-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');
|
||||||
|
const issues = [];
|
||||||
|
const ok = (a, b, op) => {
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
|
||||||
|
return op === '<' ? a < b : a <= b;
|
||||||
|
};
|
||||||
|
if (Number.isFinite(refLow) && refLow <= 0)
|
||||||
|
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, 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, '<='))
|
||||||
|
issues.push('maxLevel must be ≤ overflowLevel');
|
||||||
|
if (!ok(ovfl, basinHraw, '<='))
|
||||||
|
issues.push('overflowLevel must be ≤ basinHeight');
|
||||||
|
|
||||||
|
// Visible ribbon above the basin diagram.
|
||||||
|
const warnDiv = document.getElementById('ps-basin-validation');
|
||||||
|
if (warnDiv) {
|
||||||
|
if (issues.length) {
|
||||||
|
warnDiv.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||||
|
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||||
|
warnDiv.style.display = '';
|
||||||
|
} else {
|
||||||
|
warnDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy in-SVG warning text — kept for the small reminder inside
|
||||||
|
// the diagram. Only shows the count.
|
||||||
|
const warn = document.getElementById('ps-warning');
|
||||||
|
if (warn) {
|
||||||
|
if (issues.length) {
|
||||||
|
warn.setAttribute('visibility', 'visible');
|
||||||
|
warn.textContent = `⚠ ${issues.length} ordering issue${issues.length > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
warn.setAttribute('visibility', 'hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._psBasinValidationIssues = issues;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
110
src/editor/bounds.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// PumpingStation editor — dynamic input bounds.
|
||||||
|
// Sets HTML5 min/max attributes on every level and percent input based on
|
||||||
|
// the current values of related inputs, so the up/down arrows stop at
|
||||||
|
// values that respect the basin hierarchy:
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// basin-diagram.js and mode-preview.js catch typed violations and the
|
||||||
|
// oneditsave handler blocks Deploy until they're resolved.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
const fNum = (id) => ns.fNum(id);
|
||||||
|
const EPS = 0.001; // smallest meaningful step (mm-precision)
|
||||||
|
|
||||||
|
const setBounds = (id, min, max) => {
|
||||||
|
const el = document.getElementById(`node-input-${id}`);
|
||||||
|
if (!el) return;
|
||||||
|
if (Number.isFinite(min)) el.setAttribute('min', String(min));
|
||||||
|
else el.removeAttribute('min');
|
||||||
|
if (Number.isFinite(max)) el.setAttribute('max', String(max));
|
||||||
|
else el.removeAttribute('max');
|
||||||
|
};
|
||||||
|
|
||||||
|
ns.bounds = {
|
||||||
|
apply() {
|
||||||
|
const basinHeight = fNum('basinHeight');
|
||||||
|
const outflow = fNum('outflowLevel');
|
||||||
|
const dryPct = fNum('dryRunThresholdPercent');
|
||||||
|
const start = fNum('startLevel');
|
||||||
|
const inlet = fNum('inflowLevel');
|
||||||
|
const max = fNum('maxLevel');
|
||||||
|
const overflow = fNum('overflowLevel');
|
||||||
|
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||||
|
|
||||||
|
// Derived dryRunLevel (lower bound for startLevel).
|
||||||
|
const dryRun = (Number.isFinite(outflow) && Number.isFinite(dryPct))
|
||||||
|
? outflow * (1 + dryPct / 100) : null;
|
||||||
|
|
||||||
|
// Geometry — basin envelope.
|
||||||
|
setBounds('basinHeight', EPS, undefined);
|
||||||
|
setBounds('basinVolume', EPS, undefined);
|
||||||
|
|
||||||
|
// Levels (each capped by the next-higher level if defined).
|
||||||
|
setBounds('outflowLevel', EPS,
|
||||||
|
Number.isFinite(start) && Number.isFinite(dryPct)
|
||||||
|
? start / (1 + dryPct / 100) - EPS // keep dryRun < start
|
||||||
|
: (start ?? inlet ?? max ?? overflow ?? basinHeight));
|
||||||
|
|
||||||
|
setBounds('startLevel',
|
||||||
|
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||||
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
|
setBounds('inflowLevel',
|
||||||
|
EPS,
|
||||||
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
|
setBounds('maxLevel',
|
||||||
|
inlet ?? start ?? EPS,
|
||||||
|
overflow ?? basinHeight);
|
||||||
|
|
||||||
|
setBounds('overflowLevel',
|
||||||
|
max ?? inlet ?? start ?? EPS,
|
||||||
|
basinHeight);
|
||||||
|
|
||||||
|
// stopLevel — explicit pump-off threshold. Must sit between
|
||||||
|
// dryRunLevel and startLevel (so it can be reached during draining
|
||||||
|
// before pumps re-engage).
|
||||||
|
setBounds('stopLevel',
|
||||||
|
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||||
|
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
|
// holdLevel — 0 % ramp foot. Defaults to startLevel (no hold band);
|
||||||
|
// when raised above startLevel, pumps engage at startLevel but emit
|
||||||
|
// 0 % across [startLevel, holdLevel] before the ramp begins. Bounds:
|
||||||
|
// startLevel ≤ holdLevel < maxLevel.
|
||||||
|
setBounds('holdLevel',
|
||||||
|
Number.isFinite(start) ? start : EPS,
|
||||||
|
max ?? overflow ?? basinHeight);
|
||||||
|
|
||||||
|
// Shift inputs (only relevant when shifted ramp enabled).
|
||||||
|
if (shiftEnabled) {
|
||||||
|
setBounds('shiftLevel',
|
||||||
|
Number.isFinite(start) ? start : EPS,
|
||||||
|
max ?? overflow ?? basinHeight);
|
||||||
|
setBounds('shiftArmPercent', 1, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentages.
|
||||||
|
// dryRun% capped so dryRunLevel ≤ startLevel.
|
||||||
|
let dryMax = 99;
|
||||||
|
if (Number.isFinite(start) && Number.isFinite(outflow) && outflow > 0) {
|
||||||
|
dryMax = Math.max(0, Math.min(99, ((start / outflow) - 1) * 100));
|
||||||
|
}
|
||||||
|
setBounds('dryRunThresholdPercent', 0, dryMax);
|
||||||
|
|
||||||
|
// highVol% bounded (1, 100). Equal to 100 means no margin to overflow.
|
||||||
|
setBounds('highVolumeSafetyThresholdPercent', 1, 100);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
29
src/editor/hover-couple.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// PumpingStation editor — hover-coupling between side-panel input rows
|
||||||
|
// and the SVG markers they control. Each .ps-row that carries
|
||||||
|
// data-couples-line="<svg-element-id>" highlights that SVG line on
|
||||||
|
// mouseenter and clears the highlight on mouseleave.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
|
||||||
|
ns.hoverCouple = {
|
||||||
|
init() {
|
||||||
|
document.querySelectorAll('.ps-diag-side .ps-row[data-couples-line]').forEach((row) => {
|
||||||
|
const targetId = row.getAttribute('data-couples-line');
|
||||||
|
const target = document.getElementById(targetId);
|
||||||
|
if (!target) return;
|
||||||
|
const enter = () => target.classList.add('ps-line-highlight');
|
||||||
|
const leave = () => target.classList.remove('ps-line-highlight');
|
||||||
|
row.addEventListener('mouseenter', enter);
|
||||||
|
row.addEventListener('mouseleave', leave);
|
||||||
|
// Also highlight while the input inside the row has focus, so
|
||||||
|
// the user keeps the visual feedback while typing.
|
||||||
|
const input = row.querySelector('input');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('focus', enter);
|
||||||
|
input.addEventListener('blur', leave);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
33
src/editor/index.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// PumpingStation editor — shared namespace + helpers.
|
||||||
|
// Loaded first by pumpingStation.html via /pumpingStation/editor/index.js.
|
||||||
|
// Each sibling module attaches additional members to window.PSEditor.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
|
||||||
|
// Read a numeric value from an input by node-input-<id>; null if blank/NaN.
|
||||||
|
ns.fNum = (id) => {
|
||||||
|
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) 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.
|
||||||
|
ns.bindRedraw = (ids, handler) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const el = document.getElementById(`node-input-${id}`);
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('input', handler);
|
||||||
|
el.addEventListener('change', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
295
src/editor/mode-preview.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
// PumpingStation editor — level-based mode preview SVG.
|
||||||
|
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
|
||||||
|
// the optional shifted-down curve (startLevel→shiftLevel). Computes
|
||||||
|
// validation issues and stashes them on window._psModeValidationIssues
|
||||||
|
// for oneditsave to read.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
const fNum = (id) => ns.fNum(id);
|
||||||
|
|
||||||
|
// Derive dryRunLevel the same way the basin diagram does.
|
||||||
|
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
|
||||||
|
// Returns null if either input is missing.
|
||||||
|
ns.deriveDryRunLevel = () => {
|
||||||
|
const refLow = fNum('outflowLevel');
|
||||||
|
const dryPct = fNum('dryRunThresholdPercent');
|
||||||
|
if (refLow == null || dryPct == null) return null;
|
||||||
|
return refLow * (1 + dryPct / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
ns.modePreview = {
|
||||||
|
redraw() {
|
||||||
|
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||||
|
if (!svg) return;
|
||||||
|
const start = fNum('startLevel');
|
||||||
|
const hold = fNum('holdLevel');
|
||||||
|
const inlet = fNum('inflowLevel');
|
||||||
|
const max = fNum('maxLevel');
|
||||||
|
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
||||||
|
// own marker line; does NOT shift the ramp foot. Renders as long as
|
||||||
|
// the typed value is a non-negative number — the start-vs-stop
|
||||||
|
// ordering check belongs to the validation ribbon, not the visual
|
||||||
|
// marker (otherwise the line vanishes while the user is mid-edit).
|
||||||
|
const stopRaw = fNum('stopLevel');
|
||||||
|
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 ? stopRaw : null;
|
||||||
|
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
||||||
|
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||||
|
// we draw it as the leftmost vertical marker so the user sees
|
||||||
|
// exactly where it lands.
|
||||||
|
const dryRun = ns.deriveDryRunLevel();
|
||||||
|
const overflow = fNum('overflowLevel');
|
||||||
|
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||||
|
const shiftRaw = fNum('shiftLevel');
|
||||||
|
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
|
||||||
|
const armRaw = fNum('shiftArmPercent');
|
||||||
|
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
|
||||||
|
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||||
|
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
|
||||||
|
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
|
||||||
|
|
||||||
|
// Plot window is FIXED relative to basin geometry so that moving any
|
||||||
|
// single level slides only that line, not all the others. Lower bound
|
||||||
|
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
|
||||||
|
// if overflow isn't set) plus a small margin.
|
||||||
|
const upperRefs = [max, overflow].filter(Number.isFinite);
|
||||||
|
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
|
||||||
|
const pad = Math.max(upperBase * 0.05, 0.1);
|
||||||
|
const levelMin = 0;
|
||||||
|
const levelMax = upperBase + pad;
|
||||||
|
|
||||||
|
// Plot rectangle (viewBox px).
|
||||||
|
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
|
||||||
|
const yOffPx = 160;
|
||||||
|
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
|
||||||
|
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
|
||||||
|
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
|
||||||
|
const scale = (x) => {
|
||||||
|
const clamped = Math.max(0, Math.min(1, x));
|
||||||
|
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
|
||||||
|
return clamped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Path with three flat regions and a ramp:
|
||||||
|
// [levelMin..startX] OFF (pump off; below startLevel)
|
||||||
|
// [startX..footX] 0 % (system armed but not yet ramping)
|
||||||
|
// [footX..topX] ramp (linear or log scaled 0..100 %)
|
||||||
|
// [topX..levelMax] 100 % (saturated)
|
||||||
|
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
|
||||||
|
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
|
||||||
|
const buildPath = (startX, footX, topX) => {
|
||||||
|
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
|
||||||
|
const pts = [];
|
||||||
|
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||||
|
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
|
||||||
|
pts.push(`${xFor(startX)},${yForPct(0)}`);
|
||||||
|
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
const t = i / 24;
|
||||||
|
const level = footX + t * (topX - footX);
|
||||||
|
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
|
||||||
|
}
|
||||||
|
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||||
|
return pts.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Up curve. Engagement edge is startLevel (pump-on threshold); the
|
||||||
|
// ramp foot is holdLevel, with a Math.max(startLevel, …) safety
|
||||||
|
// floor — matching the runtime in levelBased.run.
|
||||||
|
// - holdLevel == startLevel (default): no hold band, 0..100 % across
|
||||||
|
// [startLevel, maxLevel].
|
||||||
|
// - holdLevel > startLevel: pumps engaged across [startLevel,
|
||||||
|
// holdLevel] at 0 % (= MGC flow.min), then 0..100 % across
|
||||||
|
// [holdLevel, maxLevel].
|
||||||
|
const up = document.getElementById('ps-mode-curve-up');
|
||||||
|
const down = document.getElementById('ps-mode-curve-down');
|
||||||
|
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||||
|
const upFoot = Number.isFinite(hold) && hold > start ? hold : start;
|
||||||
|
if (up) up.setAttribute('points', buildPath(start, upFoot, max));
|
||||||
|
|
||||||
|
// Shifted-DOWN curve (only when shift enabled): represents the
|
||||||
|
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
|
||||||
|
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
|
||||||
|
// then linear/log ramp from (shiftLevel, 100 %) down to
|
||||||
|
// (startLevel, 0 %), then OFF below startLevel.
|
||||||
|
// Real runtime hold value depends on where direction flips, so the
|
||||||
|
// preview shows the maximum extent.
|
||||||
|
const buildShiftedDown = () => {
|
||||||
|
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
|
||||||
|
const pts = [];
|
||||||
|
// OFF baseline far-left to startLevel
|
||||||
|
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||||
|
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
|
||||||
|
// Jump 0 % at startLevel
|
||||||
|
pts.push(`${xFor(start)},${yForPct(0)}`);
|
||||||
|
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
const t = i / 24;
|
||||||
|
const lvl = start + t * (shift - start);
|
||||||
|
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
|
||||||
|
}
|
||||||
|
// Held at 100 % from shift → far-right
|
||||||
|
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||||
|
return pts.join(' ');
|
||||||
|
};
|
||||||
|
if (down) {
|
||||||
|
if (shiftEnabled) {
|
||||||
|
down.setAttribute('points', buildShiftedDown());
|
||||||
|
down.style.display = '';
|
||||||
|
if (downLabel) downLabel.style.display = '';
|
||||||
|
} else {
|
||||||
|
down.setAttribute('points', '');
|
||||||
|
down.style.display = 'none';
|
||||||
|
if (downLabel) downLabel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal arming-% line — only meaningful when shift enabled.
|
||||||
|
const armLine = document.getElementById('ps-mode-line-armPercent');
|
||||||
|
const armLabel = document.getElementById('ps-mode-label-armPercent');
|
||||||
|
if (armLine && armLabel) {
|
||||||
|
if (shiftEnabled) {
|
||||||
|
const yArm = yForPct(armPct);
|
||||||
|
armLine.setAttribute('y1', yArm);
|
||||||
|
armLine.setAttribute('y2', yArm);
|
||||||
|
armLabel.setAttribute('y', yArm - 2);
|
||||||
|
armLabel.textContent = `arm ${Math.round(armPct)}%`;
|
||||||
|
armLine.style.display = '';
|
||||||
|
armLabel.style.display = '';
|
||||||
|
} else {
|
||||||
|
armLine.style.display = 'none';
|
||||||
|
armLabel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical level markers — line only. Axis labels were removed;
|
||||||
|
// identification comes from line colour + side-panel labels +
|
||||||
|
// hover coupling.
|
||||||
|
[
|
||||||
|
['dryRunLevel', dryRun],
|
||||||
|
['startLevel', start],
|
||||||
|
['stopLevel', stop],
|
||||||
|
['holdLevel', hold],
|
||||||
|
['inflowLevel', inlet],
|
||||||
|
['maxLevel', max],
|
||||||
|
['overflowLevel', overflow],
|
||||||
|
].forEach(([id, level]) => {
|
||||||
|
const line = document.getElementById(`ps-mode-line-${id}`);
|
||||||
|
if (!line) return;
|
||||||
|
if (!Number.isFinite(level)) {
|
||||||
|
line.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = xFor(level);
|
||||||
|
line.style.display = '';
|
||||||
|
line.setAttribute('x1', x); line.setAttribute('x2', x);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background zone bands.
|
||||||
|
const plotL = xFor(levelMin);
|
||||||
|
const plotR = xFor(levelMax);
|
||||||
|
const setBand = (id, a, b) => {
|
||||||
|
const r = document.getElementById(id);
|
||||||
|
if (!r) return;
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
|
||||||
|
r.setAttribute('x', 0); r.setAttribute('width', 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.setAttribute('x', a);
|
||||||
|
r.setAttribute('width', b - a);
|
||||||
|
};
|
||||||
|
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
|
||||||
|
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
|
||||||
|
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
|
||||||
|
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
|
||||||
|
setBand('ps-zone-dryRun', plotL, xMin);
|
||||||
|
setBand('ps-zone-safetyLow', xMin, xStart);
|
||||||
|
setBand('ps-zone-safe', xStart, xMax);
|
||||||
|
setBand('ps-zone-safetyHigh', xMax, xOvf);
|
||||||
|
setBand('ps-zone-overflow', xOvf, plotR);
|
||||||
|
|
||||||
|
// Shift level marker (line only).
|
||||||
|
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
|
||||||
|
if (shiftLine) {
|
||||||
|
if (shiftEnabled && Number.isFinite(shift)) {
|
||||||
|
const x = xFor(shift);
|
||||||
|
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
|
||||||
|
shiftLine.style.display = '';
|
||||||
|
} else {
|
||||||
|
shiftLine.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title + row visibility.
|
||||||
|
const curveLabel = document.getElementById('ps-mode-curve-label');
|
||||||
|
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
|
||||||
|
const shiftRow = document.getElementById('ps-shiftLevel-row');
|
||||||
|
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
|
||||||
|
const armRow = document.getElementById('ps-shiftArmPercent-row');
|
||||||
|
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
|
||||||
|
const logRow = document.getElementById('ps-log-factor-row');
|
||||||
|
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
|
||||||
|
|
||||||
|
// Auto-default shiftLevel when shift is enabled and current value
|
||||||
|
// is missing/out-of-range. Visible default avoids a hidden ramp.
|
||||||
|
const shiftInput = document.getElementById('node-input-shiftLevel');
|
||||||
|
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
|
||||||
|
const cur = parseFloat(shiftInput.value);
|
||||||
|
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
|
||||||
|
shiftInput.value = (max * 0.9).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
|
||||||
|
// current value is missing / out of [0, 100].
|
||||||
|
const armInput = document.getElementById('node-input-shiftArmPercent');
|
||||||
|
if (shiftEnabled && armInput) {
|
||||||
|
const cur = parseFloat(armInput.value);
|
||||||
|
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
|
||||||
|
armInput.value = 95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation: only mode-specific (shift) ordering. Basin-level
|
||||||
|
// hierarchy (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
|
||||||
|
// dryRun < start) is owned by basin-diagram.js so it shows in the
|
||||||
|
// basin section near the offending inputs.
|
||||||
|
const issues = [];
|
||||||
|
if (shiftEnabled) {
|
||||||
|
const shiftVal = Number(shiftInput?.value);
|
||||||
|
if (Number.isFinite(shiftVal)) {
|
||||||
|
if (Number.isFinite(start) && shiftVal <= start)
|
||||||
|
issues.push('shiftLevel must be > startLevel');
|
||||||
|
if (Number.isFinite(max) && shiftVal > max)
|
||||||
|
issues.push('shiftLevel must be ≤ maxLevel');
|
||||||
|
} else {
|
||||||
|
issues.push('shiftLevel is required when shifted ramp is enabled');
|
||||||
|
}
|
||||||
|
const armVal = Number(armInput?.value);
|
||||||
|
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
|
||||||
|
issues.push('shiftArmPercent must be in (0, 100]');
|
||||||
|
}
|
||||||
|
const warnBox = document.getElementById('ps-mode-validation');
|
||||||
|
if (warnBox) {
|
||||||
|
if (issues.length) {
|
||||||
|
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||||
|
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||||
|
warnBox.style.display = '';
|
||||||
|
} else {
|
||||||
|
warnBox.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._psModeValidationIssues = issues;
|
||||||
|
|
||||||
|
// Read-only readouts in the side panel — number only; the row's
|
||||||
|
// .ps-unit span already shows "m".
|
||||||
|
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||||
|
const setText = (id, val) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = fmt(val);
|
||||||
|
};
|
||||||
|
setText('ps-mode-readout-dryRun', dryRun);
|
||||||
|
setText('ps-mode-readout-inflow', inlet);
|
||||||
|
setText('ps-mode-readout-overflow', overflow);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
131
src/editor/oneditprepare.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// PumpingStation editor — oneditprepare entry. Wires up form-field
|
||||||
|
// initialization, control-mode toggle, safety toggles, and binds
|
||||||
|
// redraws for the basin diagram + level-based mode preview.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
|
||||||
|
ns.oneditprepare = function () {
|
||||||
|
const node = this;
|
||||||
|
|
||||||
|
// Wait for menu data (asset/logger/position dropdowns) before init.
|
||||||
|
const waitForMenuData = () => {
|
||||||
|
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||||
|
window.EVOLV.nodes.pumpingStation.initEditor(node);
|
||||||
|
} else {
|
||||||
|
setTimeout(waitForMenuData, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForMenuData();
|
||||||
|
|
||||||
|
const refHeightEl = document.getElementById('node-input-refHeight');
|
||||||
|
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
|
||||||
|
|
||||||
|
// Safety toggle pairs — each toggle enables/disables its threshold input.
|
||||||
|
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
|
||||||
|
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
|
||||||
|
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
|
||||||
|
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
|
||||||
|
|
||||||
|
const toggleInput = (toggleEl, inputEl) => {
|
||||||
|
if (!toggleEl || !inputEl) return;
|
||||||
|
inputEl.disabled = !toggleEl.checked;
|
||||||
|
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dryRunToggle && dryRunPercent) {
|
||||||
|
dryRunToggle.checked = !!node.enableDryRunProtection;
|
||||||
|
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
||||||
|
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||||
|
toggleInput(dryRunToggle, dryRunPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highVolumeToggle && highVolumePercent) {
|
||||||
|
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
|
||||||
|
? !!node.enableHighVolumeSafety
|
||||||
|
: !!node.enableOverfillProtection;
|
||||||
|
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
|
||||||
|
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
|
||||||
|
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
|
||||||
|
toggleInput(highVolumeToggle, highVolumePercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control-mode section toggle (levelbased / manual).
|
||||||
|
const toggleModeSections = (val) => {
|
||||||
|
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||||
|
const active = document.getElementById(`ps-mode-${val}`);
|
||||||
|
if (active) active.style.display = '';
|
||||||
|
};
|
||||||
|
const modeSelect = document.getElementById('node-input-controlMode');
|
||||||
|
if (modeSelect) {
|
||||||
|
modeSelect.value = node.controlMode === 'manual' ? 'manual' : 'levelbased';
|
||||||
|
toggleModeSections(modeSelect.value);
|
||||||
|
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
|
||||||
|
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||||
|
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||||
|
|
||||||
|
const curveSelect = document.getElementById('node-input-levelCurveType');
|
||||||
|
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
|
||||||
|
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
||||||
|
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
||||||
|
|
||||||
|
// 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', 'stopLevel', 'holdLevel', 'maxLevel',
|
||||||
|
'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||||
|
'dryRunThresholdPercent',
|
||||||
|
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||||
|
'shiftArmPercent'],
|
||||||
|
ns.modePreview.redraw
|
||||||
|
);
|
||||||
|
|
||||||
|
// Whenever any level/percent input changes, refresh the bounds first
|
||||||
|
// so the next redraw + validation sees the correct min/max attrs.
|
||||||
|
ns.bindRedraw(
|
||||||
|
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
||||||
|
'inflowLevel', 'startLevel', 'stopLevel', 'holdLevel', 'outflowLevel',
|
||||||
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||||
|
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
||||||
|
() => ns.bounds?.apply()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial render + hover-couple wiring once the DOM is settled.
|
||||||
|
setTimeout(() => {
|
||||||
|
ns.bounds?.apply();
|
||||||
|
ns.basinDiagram.redraw();
|
||||||
|
ns.modePreview.redraw();
|
||||||
|
ns.hoverCouple?.init();
|
||||||
|
}, 60);
|
||||||
|
};
|
||||||
|
})();
|
||||||
78
src/editor/oneditsave.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// PumpingStation editor — oneditsave handler. Validates, saves shared
|
||||||
|
// menu sections (logger/position), then persists pumpingStation-specific
|
||||||
|
// fields onto the node. Throws if validation fails to keep the editor open.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const ns = window.PSEditor = window.PSEditor || {};
|
||||||
|
|
||||||
|
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||||
|
|
||||||
|
ns.oneditsave = function () {
|
||||||
|
const node = this;
|
||||||
|
|
||||||
|
// Block save if EITHER validator surfaced any issues. basin-diagram
|
||||||
|
// owns hierarchy issues (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
|
||||||
|
// dryRun < start). mode-preview owns shift-specific issues.
|
||||||
|
const basinIssues = window._psBasinValidationIssues || [];
|
||||||
|
const modeIssues = window._psModeValidationIssues || [];
|
||||||
|
const issues = [...basinIssues, ...modeIssues];
|
||||||
|
if (issues.length) {
|
||||||
|
if (typeof RED !== 'undefined' && RED.notify) {
|
||||||
|
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),
|
||||||
|
{ type: 'error', timeout: 6000 });
|
||||||
|
}
|
||||||
|
throw new Error('PumpingStation: invalid config — ' + issues.join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||||
|
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||||
|
|
||||||
|
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
|
||||||
|
node.simulator = document.getElementById('node-input-simulator').checked;
|
||||||
|
|
||||||
|
[
|
||||||
|
'basinVolume', 'basinHeight', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||||
|
'basinBottomRef',
|
||||||
|
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||||
|
].forEach((field) => {
|
||||||
|
const el = document.getElementById(`node-input-${field}`);
|
||||||
|
if (el) node[field] = parseFloat(el.value) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
|
||||||
|
node.enableHighVolumeSafety = document.getElementById('node-input-enableHighVolumeSafety').checked;
|
||||||
|
// Deprecated aliases kept for existing runtime/schema compatibility.
|
||||||
|
node.enableOverfillProtection = node.enableHighVolumeSafety;
|
||||||
|
node.overfillThresholdPercent = node.highVolumeSafetyThresholdPercent;
|
||||||
|
|
||||||
|
node.controlMode = document.getElementById('node-input-controlMode').value || 'levelbased';
|
||||||
|
node.levelCurveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||||
|
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
|
||||||
|
// here so that semantic survives the UI change.
|
||||||
|
const _dryRun = ns.deriveDryRunLevel?.();
|
||||||
|
if (Number.isFinite(_dryRun)) node.minLevel = _dryRun;
|
||||||
|
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||||
|
const shiftLevelVal = parseNum('node-input-shiftLevel');
|
||||||
|
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
|
||||||
|
const armPctVal = parseNum('node-input-shiftArmPercent');
|
||||||
|
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
|
||||||
|
const flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||||
|
const flowDeadband = parseNum('node-input-flowDeadband');
|
||||||
|
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
|
||||||
|
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
|
||||||
|
};
|
||||||
|
})();
|
||||||
91
src/measurement/calibration.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Calibration helpers for the pumping-station predicted volume / level
|
||||||
|
// streams. Pure functions over a context bag holding the live
|
||||||
|
// MeasurementContainer + basin geometry. After every calibration the
|
||||||
|
// integrator state is reset so the next tick starts from the new anchor.
|
||||||
|
|
||||||
|
function _resetFlowState(ctx, timestamp) {
|
||||||
|
if (ctx.flowAggregator?.resetState) {
|
||||||
|
ctx.flowAggregator.resetState(timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearSeries(measurements, type) {
|
||||||
|
const series = measurements.type(type).variant('predicted').position('atequipment');
|
||||||
|
if (series.exists()) {
|
||||||
|
const m = series.get();
|
||||||
|
if (m) {
|
||||||
|
m.values = [];
|
||||||
|
m.timestamps = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _levelFromVolume(basin, volume) {
|
||||||
|
const area = basin.surfaceArea;
|
||||||
|
return area > 0 ? Math.max(volume, 0) / area : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _volumeFromLevel(basin, level) {
|
||||||
|
const area = basin.surfaceArea;
|
||||||
|
return area > 0 ? Math.max(level, 0) * area : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calibratePredictedVolume(ctx, calibratedVol, timestamp = Date.now()) {
|
||||||
|
if (!ctx?.measurements || !ctx.basin) {
|
||||||
|
throw new Error('calibratePredictedVolume: ctx.measurements and ctx.basin required');
|
||||||
|
}
|
||||||
|
const { measurements, basin } = ctx;
|
||||||
|
|
||||||
|
_clearSeries(measurements, 'volume');
|
||||||
|
_clearSeries(measurements, 'level');
|
||||||
|
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(calibratedVol, timestamp, 'm3').unit('m3');
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(_levelFromVolume(basin, calibratedVol), timestamp, 'm');
|
||||||
|
|
||||||
|
_resetFlowState(ctx, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calibratePredictedLevel(ctx, level, timestamp = Date.now(), unit = 'm') {
|
||||||
|
if (!ctx?.measurements || !ctx.basin) {
|
||||||
|
throw new Error('calibratePredictedLevel: ctx.measurements and ctx.basin required');
|
||||||
|
}
|
||||||
|
const { measurements, basin } = ctx;
|
||||||
|
|
||||||
|
_clearSeries(measurements, 'volume');
|
||||||
|
_clearSeries(measurements, 'level');
|
||||||
|
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(level, timestamp, unit);
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(_volumeFromLevel(basin, level), timestamp, 'm3');
|
||||||
|
|
||||||
|
_resetFlowState(ctx, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManualInflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
||||||
|
if (!ctx?.measurements) throw new Error('setManualInflow: ctx.measurements required');
|
||||||
|
const num = Number(value);
|
||||||
|
ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin')
|
||||||
|
.value(num, timestamp, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual outflow injection mirroring setManualInflow — basin-docs adds this
|
||||||
|
// for the dashboard's q_out topic so tests can drive a drain stroke without
|
||||||
|
// instantiating a real pump.
|
||||||
|
function setManualOutflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
||||||
|
if (!ctx?.measurements) throw new Error('setManualOutflow: ctx.measurements required');
|
||||||
|
const num = Number(value);
|
||||||
|
ctx.measurements.type('flow').variant('predicted').position('out').child('manual-qout')
|
||||||
|
.value(num, timestamp, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calibratePredictedVolume,
|
||||||
|
calibratePredictedLevel,
|
||||||
|
setManualInflow,
|
||||||
|
setManualOutflow,
|
||||||
|
};
|
||||||
296
src/measurement/flowAggregator.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
// FlowAggregator — owns the predicted-volume integrator + net-flow selection
|
||||||
|
// + remaining-time projection for the pumping-station basin.
|
||||||
|
//
|
||||||
|
// Pure domain. Takes a context bag with the live MeasurementContainer, the
|
||||||
|
// basin geometry, and the merged config; mutates measurements in place and
|
||||||
|
// keeps a tiny piece of integrator state internally.
|
||||||
|
//
|
||||||
|
// Ports from basin-docs:
|
||||||
|
// - Predicted-volume integrator clamped to [dryRunSafetyVol, maxVolAtOverflow]
|
||||||
|
// with hard physical floor at 0 (predicted volume can never go negative).
|
||||||
|
// - Synthetic spill flow at position 'overflow' so net-flow balance
|
||||||
|
// reads ~0 while pinned at overflow.
|
||||||
|
// - Cumulative overflowVolume + underflowVolume streams for compliance /
|
||||||
|
// diagnostic reporting via InfluxDB.
|
||||||
|
|
||||||
|
const { interpolation } = require('generalFunctions');
|
||||||
|
|
||||||
|
const DEFAULT_FLOW_THRESHOLD = 1e-4;
|
||||||
|
const DEFAULT_FLOW_VARIANTS = ['measured', 'predicted'];
|
||||||
|
const DEFAULT_LEVEL_VARIANTS = ['measured', 'predicted'];
|
||||||
|
const DEFAULT_FLOW_POSITIONS = {
|
||||||
|
inflow: ['in', 'upstream'],
|
||||||
|
outflow: ['out', 'downstream'],
|
||||||
|
};
|
||||||
|
|
||||||
|
class FlowAggregator {
|
||||||
|
constructor(ctx = {}) {
|
||||||
|
if (!ctx.measurements) throw new Error('FlowAggregator: ctx.measurements is required');
|
||||||
|
if (!ctx.basin) throw new Error('FlowAggregator: ctx.basin is required');
|
||||||
|
|
||||||
|
this.measurements = ctx.measurements;
|
||||||
|
this.basin = ctx.basin;
|
||||||
|
this.config = ctx.config || {};
|
||||||
|
this.logger = ctx.logger || null;
|
||||||
|
this._interp = ctx.interpolation || new interpolation();
|
||||||
|
|
||||||
|
this.flowVariants = ctx.flowVariants || DEFAULT_FLOW_VARIANTS;
|
||||||
|
this.levelVariants = ctx.levelVariants || DEFAULT_LEVEL_VARIANTS;
|
||||||
|
this.flowPositions = ctx.flowPositions || DEFAULT_FLOW_POSITIONS;
|
||||||
|
|
||||||
|
const cfgThresh = Number(this.config?.general?.flowThreshold);
|
||||||
|
this.flowThreshold = Number.isFinite(ctx.flowThreshold)
|
||||||
|
? ctx.flowThreshold
|
||||||
|
: (Number.isFinite(cfgThresh) ? cfgThresh : DEFAULT_FLOW_THRESHOLD);
|
||||||
|
|
||||||
|
// Optional callback so the host can supply derived safety thresholds
|
||||||
|
// without us re-importing the validator. Returns { dryRunSafetyVol, ... }.
|
||||||
|
this._computeSafetyPoints = ctx.computeSafetyPoints || (() => ({ dryRunSafetyVol: 0 }));
|
||||||
|
|
||||||
|
this._predictedFlowState = null;
|
||||||
|
this._lastNetFlow = { value: 0, source: null, direction: 'steady' };
|
||||||
|
this._lastRemaining = { seconds: null, source: null };
|
||||||
|
this._lastLevelRateNetFlow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetState(timestamp = Date.now()) {
|
||||||
|
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the best-available variant for one side of the basin balance.
|
||||||
|
// Mirrors selectBestNetFlow's variant precedence (measured first, then
|
||||||
|
// predicted) but resolves each side independently — so a real measured
|
||||||
|
// upstream sensor + a predicted pump outflow both feed the integrator.
|
||||||
|
// Returns the summed flow at the requested positions. The first variant
|
||||||
|
// that has any registered measurement at one of those positions wins,
|
||||||
|
// even if its sum is 0 (a sensor that reads 0 is still data).
|
||||||
|
_pickFlowSum(positions, flowUnit = 'm3/s') {
|
||||||
|
const buckets = this.measurements.measurements?.flow;
|
||||||
|
if (!buckets) return { sum: 0, variant: null };
|
||||||
|
for (const variant of this.flowVariants) {
|
||||||
|
const variantBucket = buckets[variant];
|
||||||
|
if (!variantBucket) continue;
|
||||||
|
const hasAny = positions.some((pos) => {
|
||||||
|
const posBucket = variantBucket[pos];
|
||||||
|
return posBucket && Object.keys(posBucket).length > 0;
|
||||||
|
});
|
||||||
|
if (!hasAny) continue;
|
||||||
|
return {
|
||||||
|
sum: this.measurements.sum('flow', variant, positions, flowUnit) || 0,
|
||||||
|
variant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { sum: 0, variant: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const flowUnit = 'm3/s';
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Synthetic spill flow lives at its OWN position ('overflow') —
|
||||||
|
// not as a child of 'out'. That keeps it out of the operational
|
||||||
|
// outflow sum here so no self-subtraction is needed.
|
||||||
|
// Inflow + outflow are resolved per-side: a real measured upstream
|
||||||
|
// sensor (variant=measured) + a predicted pump-curve outflow
|
||||||
|
// (variant=predicted) is the common realistic mix.
|
||||||
|
const inflowPick = this._pickFlowSum(this.flowPositions.inflow, flowUnit);
|
||||||
|
const outflowPick = this._pickFlowSum(this.flowPositions.outflow, flowUnit);
|
||||||
|
const inflow = inflowPick.sum;
|
||||||
|
const outflowReal = outflowPick.sum;
|
||||||
|
|
||||||
|
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||||
|
|
||||||
|
const tPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||||||
|
const dt = Math.max((now - tPrev) / 1000, 0);
|
||||||
|
const dV = dt > 0 ? (inflow - outflowReal) * dt : 0;
|
||||||
|
|
||||||
|
const currentVol = this.measurements
|
||||||
|
.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? this.basin.minVol ?? 0;
|
||||||
|
const writeTs = tPrev + dt * 1000;
|
||||||
|
|
||||||
|
// Bounds.
|
||||||
|
// Upper (hard physical): maxVolAtOverflow — past this the basin
|
||||||
|
// spills; predicted level pins at overflowLevel and the excess
|
||||||
|
// becomes cumulative overflowVolume + synthetic spill flow.
|
||||||
|
// Lower (operational): dryRunSafetyVol — clamps ON TRANSITION
|
||||||
|
// from above so the integrator can't drop into the unphysical
|
||||||
|
// band. A basin seeded BELOW it is left alone (startup from empty).
|
||||||
|
// Lower (hard physical): 0 — basin cannot hold negative water.
|
||||||
|
// Any negative excess is tracked as underflowVolume (diagnostic).
|
||||||
|
const safety = this._computeSafetyPoints();
|
||||||
|
const upperClamp = this.basin.maxVolAtOverflow;
|
||||||
|
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
|
||||||
|
|
||||||
|
const proposedVolume = currentVol + dV;
|
||||||
|
let nextVolume = proposedVolume;
|
||||||
|
let overflowIncrement = 0;
|
||||||
|
let underflowIncrement = 0;
|
||||||
|
if (proposedVolume > upperClamp) {
|
||||||
|
overflowIncrement = proposedVolume - upperClamp;
|
||||||
|
nextVolume = upperClamp;
|
||||||
|
} else if (proposedVolume < lowerClamp && currentVol >= lowerClamp) {
|
||||||
|
nextVolume = lowerClamp;
|
||||||
|
}
|
||||||
|
if (nextVolume < 0) {
|
||||||
|
underflowIncrement = -nextVolume;
|
||||||
|
nextVolume = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic spill flow at position 'overflow'.
|
||||||
|
let spillRate = 0;
|
||||||
|
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
|
||||||
|
spillRate = inflow - outflowReal;
|
||||||
|
}
|
||||||
|
this.measurements
|
||||||
|
.type('flow').variant('predicted').position('overflow')
|
||||||
|
.value(spillRate, writeTs, 'm3/s').unit('m3/s');
|
||||||
|
|
||||||
|
if (overflowIncrement > 0) {
|
||||||
|
const prev = this.measurements
|
||||||
|
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||||
|
this.measurements
|
||||||
|
.type('overflowVolume').variant('predicted').position('atequipment')
|
||||||
|
.value(prev + overflowIncrement, writeTs, 'm3').unit('m3');
|
||||||
|
}
|
||||||
|
if (underflowIncrement > 0) {
|
||||||
|
const prev = this.measurements
|
||||||
|
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||||
|
this.measurements
|
||||||
|
.type('underflowVolume').variant('predicted').position('atequipment')
|
||||||
|
.value(prev + underflowIncrement, writeTs, 'm3').unit('m3');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(nextVolume, writeTs, 'm3').unit('m3');
|
||||||
|
|
||||||
|
const surfaceArea = this.basin.surfaceArea;
|
||||||
|
const nextLevel = surfaceArea > 0 ? Math.max(nextVolume, 0) / surfaceArea : 0;
|
||||||
|
this.measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(nextLevel, writeTs, 'm').unit('m');
|
||||||
|
|
||||||
|
const percent = this._interp.interpolate_lin_single_point(
|
||||||
|
nextVolume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||||
|
);
|
||||||
|
this.measurements.type('volumePercent').variant('predicted').position('atequipment')
|
||||||
|
.value(percent, writeTs, '%');
|
||||||
|
|
||||||
|
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTs };
|
||||||
|
}
|
||||||
|
|
||||||
|
selectBestNetFlow() {
|
||||||
|
const type = 'flow';
|
||||||
|
const unit = this.measurements.getUnit(type) || 'm3/s';
|
||||||
|
|
||||||
|
for (const variant of this.flowVariants) {
|
||||||
|
const bucket = this.measurements.measurements?.[type]?.[variant];
|
||||||
|
if (!bucket || Object.keys(bucket).length === 0) continue;
|
||||||
|
|
||||||
|
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
|
||||||
|
const outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
|
||||||
|
// Fold synthetic spill (position 'overflow') into the outflow side
|
||||||
|
// so net-flow balance reads ~0 while pinned at the overflow level.
|
||||||
|
const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0;
|
||||||
|
const outflow = outflowReal + spill;
|
||||||
|
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
|
||||||
|
|
||||||
|
const net = inflow - outflow;
|
||||||
|
this.measurements.type('netFlowRate').variant(variant).position('atequipment')
|
||||||
|
.value(net, Date.now(), unit);
|
||||||
|
const result = { value: net, source: variant, direction: this.deriveDirection(net) };
|
||||||
|
this._lastNetFlow = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of this.levelVariants) {
|
||||||
|
const rate = this._levelRate(variant);
|
||||||
|
if (!Number.isFinite(rate)) continue;
|
||||||
|
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
|
||||||
|
const pinnedAtOverflow = Number.isFinite(lvl)
|
||||||
|
&& Number.isFinite(this.basin.overflowLevel)
|
||||||
|
&& lvl >= this.basin.overflowLevel - 1e-9;
|
||||||
|
const rateNearZero = Math.abs(rate) < 1e-9;
|
||||||
|
|
||||||
|
let netFlow = rate * this.basin.surfaceArea;
|
||||||
|
// Pinned at overflow — dL/dt collapses to 0 but flow IS still
|
||||||
|
// moving (in → spill). Hold the last known non-zero net-flow.
|
||||||
|
if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) {
|
||||||
|
netFlow = this._lastLevelRateNetFlow;
|
||||||
|
} else if (!rateNearZero) {
|
||||||
|
this._lastLevelRateNetFlow = netFlow;
|
||||||
|
}
|
||||||
|
const result = { value: netFlow, source: `level:${variant}`, direction: this.deriveDirection(netFlow) };
|
||||||
|
this._lastNetFlow = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.logger) this.logger.warn('No usable measurements to compute net flow; assuming steady.');
|
||||||
|
const result = { value: 0, source: null, direction: 'steady' };
|
||||||
|
this._lastNetFlow = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeRemainingTime(netFlow) {
|
||||||
|
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) {
|
||||||
|
this._lastRemaining = { seconds: null, source: null };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
|
||||||
|
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) {
|
||||||
|
this._lastRemaining = { seconds: null, source: null };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of this.levelVariants) {
|
||||||
|
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
|
||||||
|
if (!Number.isFinite(lvl)) continue;
|
||||||
|
|
||||||
|
const remainingHeight = netFlow.value > 0
|
||||||
|
? Math.max(overflowLevel - lvl, 0)
|
||||||
|
: Math.max(lvl - outflowLevel, 0);
|
||||||
|
const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value);
|
||||||
|
if (!Number.isFinite(seconds)) continue;
|
||||||
|
|
||||||
|
this._lastRemaining = { seconds, source: `${netFlow.source}/${variant}` };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastRemaining = { seconds: null, source: netFlow.source };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
deriveDirection(netFlow) {
|
||||||
|
if (netFlow > this.flowThreshold) return 'filling';
|
||||||
|
if (netFlow < -this.flowThreshold) return 'draining';
|
||||||
|
return 'steady';
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
this.update();
|
||||||
|
const netFlow = this.selectBestNetFlow();
|
||||||
|
const remaining = this.computeRemainingTime(netFlow);
|
||||||
|
return { netFlow, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
direction: this._lastNetFlow.direction,
|
||||||
|
netFlow: this._lastNetFlow.value,
|
||||||
|
flowSource: this._lastNetFlow.source,
|
||||||
|
secondsRemaining: this._lastRemaining.seconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_levelRate(variant) {
|
||||||
|
const m = this.measurements.type('level').variant(variant).position('atequipment').get();
|
||||||
|
if (!m || !m.values || m.values.length < 2) return null;
|
||||||
|
const current = m.getLaggedSample?.(0);
|
||||||
|
const previous = m.getLaggedSample?.(1);
|
||||||
|
if (!current || !previous || previous.timestamp == null) return null;
|
||||||
|
const dt = (current.timestamp - previous.timestamp) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) return null;
|
||||||
|
return (current.value - previous.value) / dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FlowAggregator;
|
||||||
82
src/measurement/measurementRouter.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// MeasurementRouter — dispatches incoming measurement updates by type and
|
||||||
|
// derives downstream measurements (volume from level, predicted level from
|
||||||
|
// pressure). Pure domain over a context bag; no Node-RED dependency.
|
||||||
|
|
||||||
|
const { coolprop, interpolation } = require('generalFunctions');
|
||||||
|
|
||||||
|
const G = 9.80665;
|
||||||
|
const ASSUMED_TEMPERATURE_C = 15;
|
||||||
|
const ATMOSPHERIC_PRESSURE_PA = 101325;
|
||||||
|
|
||||||
|
class MeasurementRouter {
|
||||||
|
constructor(ctx = {}) {
|
||||||
|
if (!ctx.measurements) throw new Error('MeasurementRouter: ctx.measurements is required');
|
||||||
|
if (!ctx.basin) throw new Error('MeasurementRouter: ctx.basin is required');
|
||||||
|
|
||||||
|
this.measurements = ctx.measurements;
|
||||||
|
this.basin = ctx.basin;
|
||||||
|
this.logger = ctx.logger || null;
|
||||||
|
this._interp = ctx.interpolation || new interpolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
route(measurementType, value, position, eventData = {}) {
|
||||||
|
switch (measurementType) {
|
||||||
|
case 'level':
|
||||||
|
this.onLevelMeasurement(position, value, eventData);
|
||||||
|
return true;
|
||||||
|
case 'pressure':
|
||||||
|
this.onPressureMeasurement(position, value, eventData);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLevelMeasurement(position, value, context = {}) {
|
||||||
|
this.measurements.type('level').variant('measured').position(position)
|
||||||
|
.value(value, context.timestamp, context.unit);
|
||||||
|
|
||||||
|
const series = this.measurements.type('level').variant('measured').position(position);
|
||||||
|
const levelMeters = series.getCurrentValue('m');
|
||||||
|
if (levelMeters == null) return;
|
||||||
|
|
||||||
|
const surfaceArea = this.basin.surfaceArea;
|
||||||
|
const volume = surfaceArea > 0 ? Math.max(levelMeters, 0) * surfaceArea : 0;
|
||||||
|
const percent = this._interp.interpolate_lin_single_point(
|
||||||
|
volume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||||
|
);
|
||||||
|
|
||||||
|
this.measurements.type('volume').variant('measured').position('atequipment')
|
||||||
|
.value(volume, context.timestamp, 'm3');
|
||||||
|
this.measurements.type('volumePercent').variant('measured').position('atequipment')
|
||||||
|
.value(percent, context.timestamp, '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressureMeasurement(position, value, context = {}) {
|
||||||
|
let kelvin = this.measurements
|
||||||
|
.type('temperature').variant('measured').position('atequipment')
|
||||||
|
.getCurrentValue('K') ?? null;
|
||||||
|
|
||||||
|
if (kelvin === null) {
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
|
||||||
|
}
|
||||||
|
this.measurements.type('temperature').variant('assumed').position('atequipment')
|
||||||
|
.value(ASSUMED_TEMPERATURE_C, Date.now(), 'C');
|
||||||
|
kelvin = this.measurements.type('temperature').variant('assumed').position('atequipment')
|
||||||
|
.getCurrentValue('K');
|
||||||
|
}
|
||||||
|
if (kelvin == null) return;
|
||||||
|
|
||||||
|
const density = coolprop.PropsSI('D', 'T', kelvin, 'P', ATMOSPHERIC_PRESSURE_PA, 'Water');
|
||||||
|
const pressurePa = this.measurements.type('pressure').variant('measured').position(position)
|
||||||
|
.getCurrentValue('Pa');
|
||||||
|
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
|
||||||
|
|
||||||
|
const level = pressurePa / (density * G);
|
||||||
|
this.measurements.type('level').variant('predicted').position(position)
|
||||||
|
.value(level, context.timestamp, 'm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MeasurementRouter;
|
||||||
264
src/nodeClass.js
@@ -1,214 +1,80 @@
|
|||||||
|
const { BaseNodeAdapter, configManager } = require('generalFunctions');
|
||||||
|
const PumpingStation = require('./specificClass');
|
||||||
|
const commands = require('./commands');
|
||||||
|
|
||||||
const { outputUtils, configManager } = require('generalFunctions');
|
class nodeClass extends BaseNodeAdapter {
|
||||||
const Specific = require("./specificClass");
|
static DomainClass = PumpingStation;
|
||||||
|
static commands = commands;
|
||||||
|
// Tick-driven: predicted-volume integrator needs delta-time per second.
|
||||||
|
static tickInterval = 1000;
|
||||||
|
static statusInterval = 1000;
|
||||||
|
|
||||||
class nodeClass {
|
buildDomainConfig(uiConfig) {
|
||||||
/**
|
return {
|
||||||
* Create a node.
|
|
||||||
* @param {object} uiConfig - Node-RED node configuration.
|
|
||||||
* @param {object} RED - Node-RED runtime API.
|
|
||||||
* @param {object} nodeInstance - The Node-RED node instance.
|
|
||||||
* @param {string} nameOfNode - The name of the node, used for
|
|
||||||
*/
|
|
||||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
|
||||||
|
|
||||||
// Preserve RED reference for HTTP endpoints if needed
|
|
||||||
this.node = nodeInstance;
|
|
||||||
this.RED = RED;
|
|
||||||
this.name = nameOfNode;
|
|
||||||
|
|
||||||
// Load default & UI config
|
|
||||||
this._loadConfig(uiConfig,this.node);
|
|
||||||
|
|
||||||
// Instantiate core class
|
|
||||||
this._setupSpecificClass();
|
|
||||||
|
|
||||||
// Wire up event and lifecycle handlers
|
|
||||||
this._bindEvents();
|
|
||||||
this._registerChild();
|
|
||||||
this._startTickLoop();
|
|
||||||
this._attachInputHandler();
|
|
||||||
this._attachCloseHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and merge default config with user-defined settings.
|
|
||||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
|
||||||
*/
|
|
||||||
_loadConfig(uiConfig,node) {
|
|
||||||
const cfgMgr = new configManager();
|
|
||||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
|
||||||
|
|
||||||
// Build config: base sections + pumpingStation-specific domain config
|
|
||||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
|
||||||
basin: {
|
basin: {
|
||||||
volume: uiConfig.basinVolume,
|
volume: uiConfig.basinVolume,
|
||||||
height: uiConfig.basinHeight,
|
height: uiConfig.basinHeight,
|
||||||
heightInlet: uiConfig.heightInlet,
|
inflowLevel: uiConfig.inflowLevel,
|
||||||
heightOutlet: uiConfig.heightOutlet,
|
outflowLevel: uiConfig.outflowLevel,
|
||||||
heightOverflow: uiConfig.heightOverflow,
|
overflowLevel: uiConfig.overflowLevel,
|
||||||
|
inletPipeDiameter: uiConfig.inletPipeDiameter,
|
||||||
|
outletPipeDiameter: uiConfig.outletPipeDiameter,
|
||||||
},
|
},
|
||||||
hydraulics: {
|
hydraulics: {
|
||||||
refHeight: uiConfig.refHeight,
|
refHeight: uiConfig.refHeight,
|
||||||
|
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
||||||
basinBottomRef: uiConfig.basinBottomRef,
|
basinBottomRef: uiConfig.basinBottomRef,
|
||||||
}
|
maxInflowRate: uiConfig.maxInflowRate,
|
||||||
});
|
staticHead: uiConfig.staticHead,
|
||||||
|
maxDischargeHead: uiConfig.maxDischargeHead,
|
||||||
// Utility for formatting outputs
|
pipelineLength: uiConfig.pipelineLength,
|
||||||
this._output = new outputUtils();
|
defaultFluid: uiConfig.defaultFluid,
|
||||||
}
|
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
||||||
|
},
|
||||||
/**
|
control: {
|
||||||
* Instantiate the core logic and store as source.
|
mode: uiConfig.controlMode,
|
||||||
*/
|
levelbased: {
|
||||||
_setupSpecificClass() {
|
minLevel: uiConfig.minLevel,
|
||||||
this.source = new Specific(this.config);
|
startLevel: uiConfig.startLevel,
|
||||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
stopLevel: uiConfig.stopLevel,
|
||||||
}
|
holdLevel: uiConfig.holdLevel,
|
||||||
|
maxLevel: uiConfig.maxLevel,
|
||||||
/**
|
// Editor names the field levelCurveType; runtime uses curveType.
|
||||||
* Bind Node-RED status updates.
|
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||||
*/
|
logCurveFactor: uiConfig.logCurveFactor,
|
||||||
_bindEvents() {
|
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||||
|
shiftLevel: uiConfig.shiftLevel,
|
||||||
}
|
shiftArmPercent: uiConfig.shiftArmPercent,
|
||||||
|
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
|
||||||
// init registration msg
|
},
|
||||||
_registerChild() {
|
},
|
||||||
setTimeout(() => {
|
safety: {
|
||||||
this.node.send([
|
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
||||||
null,
|
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
||||||
null,
|
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
|
||||||
{ topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null},
|
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
|
||||||
]);
|
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
||||||
}, 100);
|
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
||||||
}
|
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds,
|
||||||
|
},
|
||||||
_updateNodeStatus() {
|
output: {
|
||||||
const ps = this.source;
|
process: uiConfig.processOutputFormat,
|
||||||
try {
|
dbase: uiConfig.dbaseOutputFormat,
|
||||||
// --- Basin & measurements -------------------------------------------------
|
},
|
||||||
const maxVolBeforeOverflow = ps.basin?.maxVolOverflow ?? ps.basin?.maxVol ?? 0;
|
|
||||||
const volumeMeasurement = ps.measurements.type("volume").variant("measured").position("atEquipment");
|
|
||||||
const currentVolume = volumeMeasurement.getCurrentValue("m3") ?? 0;
|
|
||||||
const netFlowMeasurement = ps.measurements.type("netFlowRate").variant("predicted").position("atEquipment");
|
|
||||||
const netFlowM3s = netFlowMeasurement?.getCurrentValue("m3/s") ?? 0;
|
|
||||||
const netFlowM3h = netFlowM3s * 3600;
|
|
||||||
const percentFull = ps.measurements.type("volume").variant("procent").position("atEquipment").getCurrentValue() ?? 0;
|
|
||||||
|
|
||||||
// --- State information ----------------------------------------------------
|
|
||||||
const direction = ps.state?.direction || "unknown";
|
|
||||||
const secondsRemaining = ps.state?.seconds ?? null;
|
|
||||||
|
|
||||||
const timeRemaining = secondsRemaining ? `${Math.round(secondsRemaining / 60)}` : 0 + " min";
|
|
||||||
|
|
||||||
// --- Icon / colour selection ---------------------------------------------
|
|
||||||
let symbol = "❔";
|
|
||||||
let fill = "grey";
|
|
||||||
|
|
||||||
switch (direction) {
|
|
||||||
case "filling":
|
|
||||||
symbol = "⬆️";
|
|
||||||
fill = "blue";
|
|
||||||
break;
|
|
||||||
case "draining":
|
|
||||||
symbol = "⬇️";
|
|
||||||
fill = "orange";
|
|
||||||
break;
|
|
||||||
case "stable":
|
|
||||||
symbol = "⏸️";
|
|
||||||
fill = "green";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
symbol = "❔";
|
|
||||||
fill = "grey";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Status text ----------------------------------------------------------
|
|
||||||
const textParts = [
|
|
||||||
`${symbol} ${percentFull.toFixed(1)}%`,
|
|
||||||
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)} m³`,
|
|
||||||
`net=${netFlowM3h.toFixed(1)} m³/h`,
|
|
||||||
`t≈${timeRemaining}`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
fill,
|
|
||||||
shape: "dot",
|
|
||||||
text: textParts.join(" | ")
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
|
||||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-only entrypoint mirroring the basin-docs config-mapping surface.
|
||||||
// any time based functions here
|
// Lets `NodeClass.prototype._loadConfig.call({name:'pumpingStation'}, ui, node)`
|
||||||
_startTickLoop() {
|
// produce the merged config without instantiating a full Node-RED adapter.
|
||||||
setTimeout(() => {
|
// Production wiring goes through BaseNodeAdapter; this is a thin shim.
|
||||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
_loadConfig(uiConfig, node) {
|
||||||
|
const cfgMgr = new configManager();
|
||||||
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
|
const name = this.name || 'pumpingStation';
|
||||||
this._statusInterval = setInterval(() => {
|
const domain = nodeClass.prototype.buildDomainConfig.call(this, uiConfig);
|
||||||
const status = this._updateNodeStatus();
|
this.defaultConfig = cfgMgr.getConfig(name);
|
||||||
this.node.status(status);
|
this.config = cfgMgr.buildConfig(name, uiConfig, node && node.id, domain);
|
||||||
}, 1000);
|
return this.config;
|
||||||
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a single tick: update measurement, format and send outputs.
|
|
||||||
*/
|
|
||||||
_tick() {
|
|
||||||
|
|
||||||
//pumping station needs time based ticks to recalc level when predicted
|
|
||||||
this.source.tick();
|
|
||||||
const raw = this.source.getOutput();
|
|
||||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
|
||||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
|
||||||
|
|
||||||
// Send only updated outputs on ports 0 & 1
|
|
||||||
this.node.send([processMsg, influxMsg]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach the node's input handler, routing control messages to the class.
|
|
||||||
*/
|
|
||||||
_attachInputHandler() {
|
|
||||||
this.node.on('input', (msg, send, done) => {
|
|
||||||
switch (msg.topic) {
|
|
||||||
//example
|
|
||||||
/*case 'simulator':
|
|
||||||
this.source.toggleSimulation();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.source.handleInput(msg);
|
|
||||||
break;
|
|
||||||
*/
|
|
||||||
case 'registerChild': {
|
|
||||||
// Register this node as a child of the parent node
|
|
||||||
const childId = msg.payload;
|
|
||||||
const childObj = this.RED.nodes.getNode(childId);
|
|
||||||
this.source.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up timers and intervals when Node-RED stops the node.
|
|
||||||
*/
|
|
||||||
_attachCloseHandler() {
|
|
||||||
this.node.on('close', (done) => {
|
|
||||||
clearInterval(this._tickInterval);
|
|
||||||
clearInterval(this._statusInterval);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
156
src/safety/safetyController.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Safety controller for the pumping-station basin.
|
||||||
|
//
|
||||||
|
// Two hard rules, applied independently every tick:
|
||||||
|
//
|
||||||
|
// 1. DRY-RUN (volume below minVol while draining): pumps must stop.
|
||||||
|
// Shuts down all DOWNSTREAM machines + machine groups + child
|
||||||
|
// stations. Sets blocked=true so the orchestrator skips control
|
||||||
|
// logic — only a manual override or estop can restart pumps.
|
||||||
|
//
|
||||||
|
// 2. OVERFILL (volume above overflow level while filling): pumps must
|
||||||
|
// keep running. Shuts down UPSTREAM equipment only (stop more water
|
||||||
|
// coming in) and child stations. Does NOT touch machine groups or
|
||||||
|
// downstream pumps — they must keep draining. blocked stays false
|
||||||
|
// so level-based control keeps demanding maximum throughput.
|
||||||
|
//
|
||||||
|
// A third path: if no volume reading is available, panic — shut down
|
||||||
|
// every machine and block control.
|
||||||
|
|
||||||
|
function pickVariant(measurements, type, variants, position, unit) {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const v = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||||
|
if (Number.isFinite(v)) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SafetyController {
|
||||||
|
/**
|
||||||
|
* @param {object} ctx
|
||||||
|
* @param {object} ctx.measurements MeasurementContainer-like instance
|
||||||
|
* @param {object} ctx.basin BasinGeometry snapshot ({maxVolAtOverflow, minVol, ...})
|
||||||
|
* @param {object} ctx.config pumpingStation config (uses .safety subtree)
|
||||||
|
* @param {object} ctx.logger generalFunctions logger
|
||||||
|
* @param {object} ctx.machines map of childId → rotatingMachine
|
||||||
|
* @param {object} ctx.stations map of childId → child pumpingStation
|
||||||
|
* @param {object} ctx.machineGroups map of childId → machineGroupControl
|
||||||
|
* @param {string[]} [ctx.volVariants] order of volume variants to try
|
||||||
|
*/
|
||||||
|
constructor(ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.volVariants = ctx.volVariants || ['measured', 'predicted'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the dry-run + overfill rules against the current measurement state.
|
||||||
|
*
|
||||||
|
* @param {object} flowSnapshot { direction: 'filling'|'draining'|'steady',
|
||||||
|
* secondsRemaining: number|null }
|
||||||
|
* @returns {{blocked:boolean, reason:string|null, triggered:string[]}}
|
||||||
|
*/
|
||||||
|
evaluate(flowSnapshot) {
|
||||||
|
const { measurements, basin, config, logger, machines } = this.ctx;
|
||||||
|
const direction = flowSnapshot?.direction ?? 'steady';
|
||||||
|
const secondsRemaining = flowSnapshot?.secondsRemaining ?? null;
|
||||||
|
|
||||||
|
const volUnit = measurements.getUnit('volume');
|
||||||
|
const vol = pickVariant(measurements, 'volume', this.volVariants, 'atequipment', volUnit);
|
||||||
|
|
||||||
|
if (vol == null) {
|
||||||
|
Object.values(machines).forEach((m) => m.handleInput('parent', 'execSequence', 'shutdown'));
|
||||||
|
logger.warn('No volume data available to safe guard system; shutting down all machines.');
|
||||||
|
return { blocked: true, reason: 'no-volume-data', triggered: ['no-volume-data'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggered = [];
|
||||||
|
let blocked = false;
|
||||||
|
let reason = null;
|
||||||
|
|
||||||
|
const dry = this._dryRunRule(vol, direction, secondsRemaining);
|
||||||
|
if (dry.triggered) {
|
||||||
|
this._shutdownDownstream(vol, secondsRemaining);
|
||||||
|
blocked = true;
|
||||||
|
reason = 'dry-run';
|
||||||
|
triggered.push(...dry.flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
const over = this._overfillRule(vol, direction, secondsRemaining);
|
||||||
|
if (over.triggered) {
|
||||||
|
this._shutdownUpstream(vol, secondsRemaining);
|
||||||
|
// Overfill never sets blocked — control keeps running.
|
||||||
|
if (reason == null) reason = 'overfill';
|
||||||
|
triggered.push(...over.flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocked, reason, triggered };
|
||||||
|
}
|
||||||
|
|
||||||
|
_safetyConfig() {
|
||||||
|
return this.ctx.config.safety || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
_dryRunRule(vol, direction, secondsRemaining) {
|
||||||
|
if (direction !== 'draining') return { triggered: false, flags: [] };
|
||||||
|
const s = this._safetyConfig();
|
||||||
|
const dryRunEnabled = Boolean(s.enableDryRunProtection);
|
||||||
|
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||||
|
const triggerLowVol = this.ctx.basin.minVol * (1 + ((Number(s.dryRunThresholdPercent) || 0) / 100));
|
||||||
|
|
||||||
|
const flags = [];
|
||||||
|
if (dryRunEnabled && vol < triggerLowVol) flags.push('dry-run-volume');
|
||||||
|
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||||
|
flags.push('time-remaining');
|
||||||
|
}
|
||||||
|
return { triggered: flags.length > 0, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
_overfillRule(vol, direction, secondsRemaining) {
|
||||||
|
if (direction !== 'filling') return { triggered: false, flags: [] };
|
||||||
|
const s = this._safetyConfig();
|
||||||
|
// basin-docs renamed enableOverfillProtection → enableHighVolumeSafety;
|
||||||
|
// both work as aliases (HEAD already maps in buildDomainConfig).
|
||||||
|
const enabled = Boolean(s.enableHighVolumeSafety ?? s.enableOverfillProtection);
|
||||||
|
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||||
|
const pct = Number(s.highVolumeSafetyThresholdPercent ?? s.overfillThresholdPercent) || 0;
|
||||||
|
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * (pct / 100);
|
||||||
|
|
||||||
|
const flags = [];
|
||||||
|
if (enabled && vol > triggerHighVol) flags.push('overfill-volume');
|
||||||
|
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||||
|
flags.push('time-remaining');
|
||||||
|
}
|
||||||
|
return { triggered: flags.length > 0, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
_shutdownDownstream(vol, secondsRemaining) {
|
||||||
|
const { machines, machineGroups, stations, logger } = this.ctx;
|
||||||
|
Object.values(machines).forEach((machine) => {
|
||||||
|
const pos = machine?.config?.functionality?.positionVsParent;
|
||||||
|
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
|
||||||
|
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
|
||||||
|
Object.values(machineGroups).forEach((g) => g.turnOffAllMachines());
|
||||||
|
logger.warn(
|
||||||
|
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_shutdownUpstream(vol, secondsRemaining) {
|
||||||
|
const { machines, stations, logger } = this.ctx;
|
||||||
|
Object.values(machines).forEach((machine) => {
|
||||||
|
const pos = machine?.config?.functionality?.positionVsParent;
|
||||||
|
if (pos === 'upstream' && machine._isOperationalState()) {
|
||||||
|
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
|
||||||
|
// Machine groups intentionally NOT shut down — they must keep draining.
|
||||||
|
logger.warn(
|
||||||
|
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SafetyController;
|
||||||
@@ -1,696 +1,352 @@
|
|||||||
const EventEmitter = require('events');
|
// PumpingStation — S88 Process Cell orchestrator.
|
||||||
const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation, POSITIONS} = require('generalFunctions');
|
//
|
||||||
|
// Wires the basin / measurement / control / safety modules in configure()
|
||||||
|
// and runs them in tick(). All real work lives in the modules; this file
|
||||||
|
// only stitches them together. See wiki/functional-description.md for the
|
||||||
|
// behaviour spec.
|
||||||
|
|
||||||
class pumpingStation {
|
const { BaseDomain, UnitPolicy, statusBadge } = require('generalFunctions');
|
||||||
constructor(config={}) {
|
const BasinGeometry = require('./basin/BasinGeometry');
|
||||||
|
const { validateThresholdOrdering, computeSafetyPoints } = require('./basin/thresholdValidator');
|
||||||
|
const FlowAggregator = require('./measurement/flowAggregator');
|
||||||
|
const MeasurementRouter = require('./measurement/measurementRouter');
|
||||||
|
const calibration = require('./measurement/calibration');
|
||||||
|
const control = require('./control');
|
||||||
|
const SafetyController = require('./safety/safetyController');
|
||||||
|
|
||||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
class PumpingStation extends BaseDomain {
|
||||||
this.configManager = new configManager();
|
static name = 'pumpingStation';
|
||||||
this.defaultConfig = this.configManager.getConfig('pumpingStation');
|
|
||||||
this.configUtils = new configUtils(this.defaultConfig);
|
|
||||||
this.config = this.configUtils.initConfig(config);
|
|
||||||
this.interpolate = new interpolation();
|
|
||||||
|
|
||||||
// Init after config is set
|
// Internal math runs in m3/s for flow and m for level so the volume
|
||||||
this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
|
// integrator (flow × dt) is unit-consistent — canonical stays m3/s, the
|
||||||
|
// platform-wide convention every cross-node consumer (MGC demand math,
|
||||||
// General properties
|
// physics-sanity) assumes. Strict canonicals make unit drift in child-fed
|
||||||
this.measurements = new MeasurementContainer({
|
// measurements an explicit error.
|
||||||
autoConvert: true
|
// 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',
|
||||||
|
overflowVolume: 'm3', underflowVolume: 'm3',
|
||||||
|
},
|
||||||
|
requireUnitForTypes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// init basin object in pumping station
|
configure() {
|
||||||
this.basin = {};
|
this.basin = new BasinGeometry(this.config.basin, this.config.hydraulics);
|
||||||
this.state = { direction:"", netDownstream:0, netUpstream:0, seconds:0}; // init state object of pumping station to see whats going on
|
|
||||||
|
|
||||||
// Initialize basin-specific properties and calculate used parameters
|
this.flowVariants = ['measured', 'predicted'];
|
||||||
this.initBasinProperties();
|
this.levelVariants = ['measured', 'predicted'];
|
||||||
this.parent = {}; // object to hold parent information for when we follow flow directions.
|
this.volVariants = ['measured', 'predicted'];
|
||||||
this.child = {}; // object to hold child information so we know on what to subscribe
|
this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] };
|
||||||
this.machines = {}; // object to hold child machine information
|
|
||||||
this.stations = {}; // object to hold station information
|
|
||||||
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
|
|
||||||
|
|
||||||
this.logger.debug('pumpstation Initialized with all helpers');
|
this.mode = this.config.control.mode;
|
||||||
}
|
this.controlState = { percControl: 0 };
|
||||||
|
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
||||||
|
|
||||||
/*------------------- Register child events -------------------*/
|
// Last operator demand from set.demand in manual mode. Stored on the
|
||||||
registerChild(child, softwareType) {
|
// host so getOutput()/status reflect it even when no children are
|
||||||
this.logger.debug('Setting up child event for softwaretype ' + softwareType);
|
// registered yet (otherwise forwardDemand is invisible on Port 0/1).
|
||||||
|
// Cleared on mode change away from manual.
|
||||||
|
this._manualDemand = null;
|
||||||
|
|
||||||
//define what to do with measurements
|
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
|
||||||
if(softwareType === "measurement"){
|
// Exposed as instance fields because the e2e/basic tests assert on them
|
||||||
const position = child.config.functionality.positionVsParent;
|
// directly. levelBased strategy reads/writes via the same names.
|
||||||
const measurementType = child.config.asset.type;
|
this._shiftArmed = false;
|
||||||
//rebuild to measurementype.variant no position and then switch based on values not strings or names.
|
this._shiftHoldValue = null;
|
||||||
const eventName = `${measurementType}.measured.${position}`;
|
this._lastDirection = null;
|
||||||
|
|
||||||
this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`);
|
// stopLevel hysteresis (Schmitt trigger) — ported from basin-docs.
|
||||||
// Register event listener for measurement updates
|
// TRUE while engaged (rising-edge at startLevel until falling-edge at
|
||||||
child.measurements.emitter.on(eventName, (eventData) => {
|
// stopLevel). Used by levelBased to emit a small keep-alive output in
|
||||||
this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
|
// the [stopLevel, startLevel] dead band so MGC keeps one pump running.
|
||||||
|
this._stopHystRunning = false;
|
||||||
|
|
||||||
this.logger.debug(` Emitting... ${eventName} with data:`);
|
// Flow dead-band — values below |flowThreshold| (m3/s) are treated as
|
||||||
// Store directly in parent's measurement container
|
// steady. Default ≈ 0.36 m3/h.
|
||||||
this.measurements.type(measurementType).variant("measured").position(position).value(eventData.value, eventData.timestamp, eventData.unit);
|
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||||||
|
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||||||
|
|
||||||
// Call the appropriate handler
|
// FlowAggregator owns the predicted-volume integrator + net-flow + ETA.
|
||||||
this._callMeasurementHandler(measurementType, eventData.value, position, eventData);
|
this.flowAggregator = new FlowAggregator({
|
||||||
|
measurements: this.measurements,
|
||||||
|
basin: this.basin,
|
||||||
|
config: this.config,
|
||||||
|
logger: this.logger,
|
||||||
|
flowVariants: this.flowVariants,
|
||||||
|
levelVariants: this.levelVariants,
|
||||||
|
flowPositions: this.flowPositions,
|
||||||
|
flowThreshold: this.flowThreshold,
|
||||||
|
computeSafetyPoints: () => this._computeSafetyPoints(),
|
||||||
});
|
});
|
||||||
}
|
this.measurementRouter = new MeasurementRouter({
|
||||||
|
measurements: this.measurements,
|
||||||
//define what to do when machines are connected
|
basin: this.basin,
|
||||||
if(softwareType == "machine"){
|
logger: this.logger,
|
||||||
// Check if the machine is already registered
|
|
||||||
this.machines[child.config.general.id] === undefined ? this.machines[child.config.general.id] = child : this.logger.warn(`Machine ${child.config.general.id} is already registered.`);
|
|
||||||
|
|
||||||
//listen for machine pressure changes
|
|
||||||
this.logger.debug(`Listening for flow changes from machine ${child.config.general.id}`);
|
|
||||||
|
|
||||||
switch(child.config.functionality.positionVsParent){
|
|
||||||
case(POSITIONS.DOWNSTREAM):
|
|
||||||
case("atequipment"): //in case of atequipment we also assume downstream seeing as it is registered at this pumpingstation as part of it.
|
|
||||||
//for now lets focus on handling downstream predicted flow
|
|
||||||
child.measurements.emitter.on("flow.predicted.downstream", (eventData) => {
|
|
||||||
this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`);
|
|
||||||
this.measurements.type('flow').variant('predicted').position('out').value(eventData.value,eventData.timestamp,eventData.unit);
|
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
|
|
||||||
|
// Threshold ordering is non-fatal — log + surface for tests/status.
|
||||||
|
this.thresholdIssues = validateThresholdOrdering(
|
||||||
|
this.basin, this.config.control?.levelbased, this.config.safety
|
||||||
|
);
|
||||||
|
for (const issue of this.thresholdIssues) this.logger.warn(issue.msg);
|
||||||
|
|
||||||
case(POSITIONS.UPSTREAM):
|
// Seed predicted volume at the operational floor — without it the
|
||||||
//check for predicted outgoing flow at the connected child pumpingsation
|
// integrator starts from null and the first tick has no anchor.
|
||||||
child.measurements.emitter.on("flow.predicted.downstream", (eventData) => {
|
this.measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`);
|
.value(this.basin.minVol, Date.now(), 'm3').unit('m3');
|
||||||
//register this then as upstream flow that arrives at the station
|
|
||||||
this.measurements.type('flow').variant('predicted').position('in').value(eventData.value,eventData.timestamp,eventData.unit);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
// Registry-as-truth — `this.machines / machineGroups / stations` are
|
||||||
this.logger.warn(`nu such position ${child.config.functionality.positionVsParent}`);
|
// read-only getters flattening `this.child[softwareType]` (BaseDomain
|
||||||
|
// helper). Mutations go through `childRegistrationUtils.registerChild`.
|
||||||
|
this.declareChildGetter('machines', 'machine');
|
||||||
|
this.declareChildGetter('machineGroups', 'machinegroup');
|
||||||
|
this.declareChildGetter('stations', 'pumpingstation');
|
||||||
|
|
||||||
|
// SafetyController's captured ctx exposes the same three names as live
|
||||||
|
// getters (installed in context()), so the registry remains the single
|
||||||
|
// source of truth long after configure() returns.
|
||||||
|
this.safety = new SafetyController(this.context());
|
||||||
|
|
||||||
|
this.router
|
||||||
|
.onRegister('measurement', (child) => this._subscribeMeasurement(child))
|
||||||
|
.onRegister('machine', (child) => {
|
||||||
|
// Skip individual machines when a machineGroup parent is present —
|
||||||
|
// the group's flow.predicted already aggregates child machines.
|
||||||
|
if (Object.keys(this.machineGroups).length === 0) {
|
||||||
|
this._subscribePredictedFlow(child);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onRegister('machinegroup', (child) => this._subscribePredictedFlow(child))
|
||||||
|
.onRegister('pumpingstation', (child) => this._subscribePredictedFlow(child));
|
||||||
|
|
||||||
|
this.logger.debug('PumpingStation initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frozen view passed to control strategies + safety.
|
||||||
|
// `host` is a back-reference so strategies that need to mutate
|
||||||
|
// cross-tick hysteresis state (`_shiftArmed`, `_shiftHoldValue`,
|
||||||
|
// `_lastDirection`, `_stopHystRunning`) write straight to the live
|
||||||
|
// instance — Object.freeze on the view itself is fine because these
|
||||||
|
// flags live on the host, not in the view.
|
||||||
|
//
|
||||||
|
// machines / machineGroups / stations are installed as live getters
|
||||||
|
// that delegate to this.* getters (declareChildGetter). SafetyController
|
||||||
|
// captures this ctx once at construction; the getters keep it reading
|
||||||
|
// fresh from the registry after later child registrations.
|
||||||
|
context() {
|
||||||
|
const host = this;
|
||||||
|
const ctx = {
|
||||||
|
...super.context(),
|
||||||
|
basin: this.basin,
|
||||||
|
flowAggregator: this.flowAggregator,
|
||||||
|
mode: this.mode,
|
||||||
|
flowVariants: this.flowVariants,
|
||||||
|
levelVariants: this.levelVariants,
|
||||||
|
volVariants: this.volVariants,
|
||||||
|
flowThreshold: this.flowThreshold,
|
||||||
|
unitPolicy: this.unitPolicy,
|
||||||
|
host: this,
|
||||||
|
};
|
||||||
|
Object.defineProperty(ctx, 'machines', { enumerable: true, get: () => host.machines });
|
||||||
|
Object.defineProperty(ctx, 'machineGroups', { enumerable: true, get: () => host.machineGroups });
|
||||||
|
Object.defineProperty(ctx, 'stations', { enumerable: true, get: () => host.stations });
|
||||||
|
return Object.freeze(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add one for group later
|
tick() {
|
||||||
if( softwareType == "machineGroup" ){
|
const { netFlow, remaining } = this.flowAggregator.tick();
|
||||||
/* intentionally empty */
|
const safe = this.safety.evaluate({ direction: netFlow.direction, secondsRemaining: remaining.seconds });
|
||||||
|
this.safetyControllerActive = safe.blocked;
|
||||||
|
|
||||||
|
if (!safe.blocked) {
|
||||||
|
Promise.resolve(control.dispatch(this.mode, this.context(), this.controlState, netFlow.direction))
|
||||||
|
.catch((err) => this.logger.error(`control dispatch failed: ${err.message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// add one for pumping station
|
this.state = {
|
||||||
if ( softwareType == "pumpingStation"){
|
direction: netFlow.direction,
|
||||||
// Check if the machine is already registered
|
netFlow: netFlow.value,
|
||||||
this.stations[child.config.general.id] === undefined ? this.machistationsnes[child.config.general.id] = child : this.logger.warn(`Machine ${child.config.general.id} is already registered.`);
|
flowSource: netFlow.source,
|
||||||
|
seconds: remaining.seconds,
|
||||||
//listen for machine pressure changes
|
remainingSource: remaining.source,
|
||||||
this.logger.debug(`Listening for flow changes from machine ${child.config.general.id}`);
|
};
|
||||||
|
this.notifyOutputChanged();
|
||||||
switch(child.config.functionality.positionVsParent){
|
|
||||||
case(POSITIONS.DOWNSTREAM):
|
|
||||||
//check for predicted outgoing flow at the connected child pumpingsation
|
|
||||||
child.measurements.emitter.on("flow.predicted.downstream", (eventData) => {
|
|
||||||
this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`);
|
|
||||||
//register this then as upstream flow that arrives at the station
|
|
||||||
this.measurements.type('flow').variant('predicted').position('out').value(eventData.value,eventData.timestamp,eventData.unit);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case(POSITIONS.UPSTREAM):
|
|
||||||
//check for predicted outgoing flow at the connected child pumpingsation
|
|
||||||
child.measurements.emitter.on("flow.predicted.downstream", (eventData) => {
|
|
||||||
this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`);
|
|
||||||
//register this then as upstream flow that arrives at the station
|
|
||||||
this.measurements.type('flow').variant('predicted').position('in').value(eventData.value,eventData.timestamp,eventData.unit);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// there is no such thing as atequipment from 1 pumpingstation to another....
|
|
||||||
this.logger.warn(`nu such position ${child.config.functionality.positionVsParent} for pumping station`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//in or outgoing flow = direction
|
changeMode(newMode) {
|
||||||
_updateVolumePrediction(flowDir){
|
if (this.config.control.allowedModes?.has?.(newMode)) {
|
||||||
|
this.logger.info(`Control mode changing from ${this.mode} to ${newMode}`);
|
||||||
//get downflow
|
this.mode = newMode;
|
||||||
const seriesExists = this.measurements.type("flow").variant("predicted").position(flowDir).exists();
|
if (newMode !== 'manual') this._manualDemand = null;
|
||||||
if(!seriesExists){return}
|
this.notifyOutputChanged();
|
||||||
|
|
||||||
const series = this.measurements.type("flow").variant("predicted").position(flowDir);
|
|
||||||
const currFLow = series.getLaggedValue(0, "m3/s"); // { value, timestamp, unit }
|
|
||||||
const prevFlow = series.getLaggedValue(1, "m3/s"); // { value, timestamp, unit }
|
|
||||||
|
|
||||||
if (!currFLow || !prevFlow) return;
|
|
||||||
|
|
||||||
this.logger.debug(`currDownflow = ${currFLow.value} , prevDownFlow = ${prevFlow.value}`);
|
|
||||||
|
|
||||||
// calc difference in time
|
|
||||||
const deltaT = currFLow.timestamp - prevFlow.timestamp;
|
|
||||||
const deltaSeconds = deltaT / 1000;
|
|
||||||
|
|
||||||
if (deltaSeconds <= 0) {
|
|
||||||
this.logger.warn(`Flow integration aborted; invalid Δt=${deltaSeconds}s.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const avgFlow = (currFLow.value + prevFlow.value) / 2;
|
|
||||||
const calcVol = avgFlow * deltaSeconds;
|
|
||||||
|
|
||||||
//substract seeing as this is downstream and is being pulled away from the pumpingstaion and keep track of status
|
|
||||||
const currVolume = this.measurements.type('volume').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3');
|
|
||||||
let newVol = currVolume;
|
|
||||||
|
|
||||||
switch(flowDir){
|
|
||||||
case("out"):
|
|
||||||
newVol = currVolume - calcVol;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case("in"):
|
|
||||||
newVol = currVolume + calcVol;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.logger.error('Flow must come in or out of the station!');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
this.measurements.type('volume').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(newVol).unit('m3');
|
|
||||||
//convert to a predicted level
|
|
||||||
const newLevel = this._calcLevelFromVolume(newVol);
|
|
||||||
|
|
||||||
this.measurements.type('level').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(newLevel).unit('m');
|
|
||||||
|
|
||||||
this.logger.debug(`new predicted volume : ${newVol} new predicted level: ${newLevel} `);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//trigger shutdown when level is too low and trigger no start flag for childs ?
|
|
||||||
safetyVolCheck(){
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//update measured temperature to adjust density of liquid
|
|
||||||
updateMeasuredTemperature(){
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
//update measured flow and recalc
|
|
||||||
updateMeasuredFlow(){
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
//keep updating the volume / level when the flow is still active from a machine or machinegroup or incoming from another source
|
|
||||||
tick(){
|
|
||||||
//go through all the functions that require time based checks or updates
|
|
||||||
this._updateVolumePrediction("out"); //check for changes in outgoing flow
|
|
||||||
this._updateVolumePrediction("in"); // check for changes in incomming flow
|
|
||||||
//calc the most important values back to determine state and net up or downstream flow
|
|
||||||
this._calcNetFlow();
|
|
||||||
this._calcRemainingTime();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_callMeasurementHandler(measurementType, value, position, context) {
|
|
||||||
switch (measurementType) {
|
|
||||||
case 'pressure':
|
|
||||||
this.updateMeasuredPressure(value, position, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'flow':
|
|
||||||
this.updateMeasuredFlow(value, position, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'temperature':
|
|
||||||
this.updateMeasuredTemperature(value, position, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'level':
|
|
||||||
this.updateMeasuredLevel(value, position, context);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.logger.warn(`No handler for measurement type: ${measurementType}`);
|
|
||||||
// Generic handler - just update position
|
|
||||||
this.updatePosition();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// context handler for pressure updates
|
|
||||||
updateMeasuredPressure(value, position, context = {}) {
|
|
||||||
|
|
||||||
// init temp
|
|
||||||
let kelvinTemp = null;
|
|
||||||
|
|
||||||
//pressure updates come from pressure boxes inside the basin they get converted to a level and stored as level measured at position inlet or outlet
|
|
||||||
this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
|
|
||||||
|
|
||||||
// Store in parent's measurement container for the first time
|
|
||||||
this.measurements.type("pressure").variant("measured").position(position).value(value, context.timestamp, context.unit);
|
|
||||||
|
|
||||||
//convert pressure to level based on density of water and height of pressure sensor
|
|
||||||
const mTemp = this.measurements.type("temperature").variant("measured").position(POSITIONS.AT_EQUIPMENT).getCurrentValue('K'); //default to 20C if no temperature measurement
|
|
||||||
|
|
||||||
//prefer measured temp but otherwise assume nominal temp for wastewater
|
|
||||||
if(mTemp === null){
|
|
||||||
this.logger.warn(`No temperature measurement available, defaulting to 15C for pressure to level conversion.`);
|
|
||||||
this.measurements.type("temperature").variant("assumed").position(POSITIONS.AT_EQUIPMENT).value(15, Date.now(), "C");
|
|
||||||
kelvinTemp = this.measurements.type('temperature').variant('assumed').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('K');
|
|
||||||
this.logger.debug(`Temperature is : ${kelvinTemp}`);
|
|
||||||
} else {
|
} else {
|
||||||
kelvinTemp = mTemp;
|
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
|
||||||
}
|
}
|
||||||
this.logger.debug(`Using temperature: ${kelvinTemp} K for calculations`);
|
|
||||||
const density = coolprop.PropsSI('D','T',kelvinTemp,'P',101325,'Water'); //density in kg/m3 at temp and surface pressure
|
|
||||||
const g = 9.80665;
|
|
||||||
const pressure_Pa = this.measurements.type("pressure").variant("measured").position(position).getCurrentValue('Pa');
|
|
||||||
const level = pressure_Pa / density * g;
|
|
||||||
|
|
||||||
this.measurements.type("level").variant("predicted").position(position).value(level);
|
|
||||||
//updatePredictedLevel(); ?? OLIFANT!
|
|
||||||
|
|
||||||
//calculate how muc flow went in or out based on pressure difference
|
|
||||||
this.logger.debug(`Using pressure: ${value} for calculations`);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMeasuredLevel(value,position, context = {}){
|
// Calibration — public methods preserved for tests + commands registry.
|
||||||
// Store in parent's measurement container for the first time
|
calibratePredictedVolume(vol, ts = Date.now()) { calibration.calibratePredictedVolume(this, vol, ts); }
|
||||||
this.measurements.type("level").variant("measured").position(position).value(value, context.timestamp, context.unit);
|
calibratePredictedLevel(lvl, ts = Date.now(), unit = 'm') { calibration.calibratePredictedLevel(this, lvl, ts, unit); }
|
||||||
|
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
|
||||||
//fetch level in meter
|
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
|
||||||
const level = this.measurements.type("level").variant("measured").position(position).getCurrentValue('m');
|
|
||||||
//calc vol in m3
|
|
||||||
const volume = this._calcVolumeFromLevel(level);
|
|
||||||
this.logger.debug(`basin minvol : ${this.basin.minVol}, cur volume : ${volume} / ${this.basin.maxVolOverflow}`);
|
|
||||||
|
|
||||||
const proc = this.interpolate.interpolate_lin_single_point(volume,this.basin.minVol,this.basin.maxVolOverflow,0,100);
|
|
||||||
this.logger.debug(`PROC volume : ${proc}`);
|
|
||||||
this.measurements.type("volume").variant("measured").position(POSITIONS.AT_EQUIPMENT).value(volume).unit('m3');
|
|
||||||
this.measurements.type("volume").variant("procent").position(POSITIONS.AT_EQUIPMENT).value(proc);
|
|
||||||
|
|
||||||
|
forwardDemandToChildren(demand) {
|
||||||
|
this._manualDemand = Number.isFinite(demand) ? demand : null;
|
||||||
|
this.notifyOutputChanged();
|
||||||
|
return control.manual.forwardDemand(this.context(), demand);
|
||||||
}
|
}
|
||||||
|
|
||||||
_calcNetFlow() {
|
// Direct delegations preserved so existing tests can drive the strategy
|
||||||
|
// without re-mocking the dispatch layer.
|
||||||
const netFlow_FlowSensor = Math.abs(this.measurements.type("flow").variant("measured").difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit: "m3/s" }));
|
async _controlLevelBased(direction) {
|
||||||
const netFlow_LevelSensor = this._calcNetFlowFromLevelDiff();
|
return control.strategies.levelbased.run(this.context(), this.controlState, direction);
|
||||||
const netFlow_PredictedFlow = Math.abs(this.measurements.type('flow').variant('predicted').difference({ from: "in", to: "out", unit: "m3/s" }));
|
|
||||||
|
|
||||||
switch (true){
|
|
||||||
//prefer flowsensor netflow
|
|
||||||
case (netFlow_FlowSensor!=null):
|
|
||||||
return netFlow_FlowSensor;
|
|
||||||
//try using level difference if possible to infer netflow
|
|
||||||
case (netFlow_LevelSensor!= null):
|
|
||||||
return netFlow_LevelSensor;
|
|
||||||
case (netFlow_PredictedFlow != null):
|
|
||||||
return netFlow_PredictedFlow;
|
|
||||||
default:
|
|
||||||
this.logger.warn(`Can't calculate netflow without the proper measurements or predictions`);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public getter so legacy tests + getOutput keep reading the live demand.
|
||||||
|
get percControl() { return this.controlState.percControl; }
|
||||||
|
set percControl(v) { this.controlState.percControl = v; }
|
||||||
|
|
||||||
|
// ── Predicted-volume integrator — tests drive this directly with a
|
||||||
|
// controlled Date.now, so expose as an instance method that delegates
|
||||||
|
// to FlowAggregator.update().
|
||||||
|
_updatePredictedVolume() {
|
||||||
|
return this.flowAggregator.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
_calcRemainingTime(level,variant){
|
// ── Mirror FlowAggregator internal integrator state so tests that pin
|
||||||
|
// _predictedFlowState before driving a tick keep working.
|
||||||
|
get _predictedFlowState() { return this.flowAggregator._predictedFlowState; }
|
||||||
|
set _predictedFlowState(v) { this.flowAggregator._predictedFlowState = v; }
|
||||||
|
|
||||||
const { heightOverflow, heightOutlet, surfaceArea } = this.basin;
|
_selectBestNetFlow() { return this.flowAggregator.selectBestNetFlow(); }
|
||||||
const flowDiff = this.measurements.type("flow").variant(variant).difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit: "m3/s" });
|
|
||||||
|
|
||||||
let remainingHeight;
|
|
||||||
switch(true){
|
|
||||||
case(flowDiff>0):
|
|
||||||
remainingHeight = Math.max(heightOverflow - level, 0);
|
|
||||||
this.state.seconds = remainingHeight * surfaceArea / flowDiff;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case(flowDiff<0):
|
|
||||||
remainingHeight = Math.max(level - heightOutlet, 0);
|
|
||||||
this.state.seconds = remainingHeight * surfaceArea / Math.abs(flowDiff);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.logger.debug(`doing nothing with level calc`)
|
|
||||||
|
|
||||||
|
_computeSafetyPoints() {
|
||||||
|
return computeSafetyPoints(this.basin, this.config.safety || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOutput() {
|
||||||
|
const out = this.measurements.getFlattenedOutput();
|
||||||
|
Object.assign(out, this.basin.snapshot());
|
||||||
|
out.direction = this.state.direction;
|
||||||
|
out.flowSource = this.state.flowSource;
|
||||||
|
out.timeleft = this.state.seconds;
|
||||||
|
out.percControl = this.controlState.percControl;
|
||||||
|
out.mode = this.mode;
|
||||||
|
out.manualDemand = this._manualDemand;
|
||||||
|
|
||||||
|
// Derived safety thresholds — exposed so editor + dashboards can show
|
||||||
|
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
|
||||||
|
const safety = this._computeSafetyPoints();
|
||||||
|
out.dryRunLevel = safety.dryRunLevel;
|
||||||
|
out.dryRunSafetyVol = safety.dryRunSafetyVol;
|
||||||
|
out.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
|
||||||
|
out.highVolumeSafetyVol = safety.highVolumeSafetyVol;
|
||||||
|
|
||||||
|
// Spill / underflow surface — populated by FlowAggregator when the
|
||||||
|
// predicted-volume integrator hits the upper or lower physical bound.
|
||||||
|
out.predictedOverflowVolume = this.measurements
|
||||||
|
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||||
|
out.predictedOverflowRate = this.measurements
|
||||||
|
.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0;
|
||||||
|
out.predictedUnderflowVolume = this.measurements
|
||||||
|
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
_calcDirection(flowDiff){
|
getStatusBadge() {
|
||||||
|
const STYLES = {
|
||||||
|
filling: { arrow: '⬆️', fill: 'blue' },
|
||||||
|
draining: { arrow: '⬇️', fill: 'orange' },
|
||||||
|
steady: { arrow: '⏸️', fill: 'green' },
|
||||||
|
};
|
||||||
|
const { arrow = '❔', fill = 'grey' } = STYLES[this.state?.direction] || {};
|
||||||
|
const pct = this.measurements.type('volumePercent').variant('predicted').position('atequipment').getCurrentValue() ?? 0;
|
||||||
|
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;
|
||||||
|
|
||||||
let direction = null;
|
return statusBadge.compose(
|
||||||
const flowThreshold = 0.001;
|
[mode, `${arrow} ${pct.toFixed(1)}%`, `net: ${netFlowM3h.toFixed(0)} m³/h`, manualPart],
|
||||||
|
{ fill, shape: 'dot' }
|
||||||
switch (true){
|
|
||||||
case flowDiff > flowThreshold:
|
|
||||||
direction = "filling";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case flowDiff < -flowThreshold:
|
|
||||||
direction = "draining";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case flowDiff < flowThreshold && flowDiff > -flowThreshold:
|
|
||||||
direction = "stable";
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.logger.warn("Uknown state direction detected??");
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
return direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
_calcNetFlowFromLevelDiff() {
|
|
||||||
const { surfaceArea } = this.basin;
|
|
||||||
const levelObj = this.measurements.type("level").variant("measured").position(POSITIONS.AT_EQUIPMENT);
|
|
||||||
const level = levelObj.getCurrentValue("m");
|
|
||||||
const prevLevel = levelObj.getLaggedValue(2, "m"); // { value, timestamp, unit }
|
|
||||||
const measurement = levelObj.get();
|
|
||||||
const latestTimestamp = measurement?.getLatestTimestamp();
|
|
||||||
|
|
||||||
if (level === null || !prevLevel || latestTimestamp == null) {
|
|
||||||
this.logger.warn(`no flowdiff ${level}, previous level ${prevLevel}, latestTimestamp ${latestTimestamp} found escaping`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaSeconds = (latestTimestamp - prevLevel.timestamp) / 1000;
|
|
||||||
if (deltaSeconds <= 0) {
|
|
||||||
this.logger.warn(`Level fallback: invalid Δt=${deltaSeconds} , LatestTimestamp : ${latestTimestamp}, PrevTimestamp : ${prevLevel.timestamp}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lvlDiff = level - prevLevel.value;
|
|
||||||
const lvlRate = lvlDiff / deltaSeconds; // m/s
|
|
||||||
const netFlowRate = lvlRate * surfaceArea; // m³/s inferred from level trend
|
|
||||||
|
|
||||||
return netFlowRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
initBasinProperties() {
|
|
||||||
|
|
||||||
// Load and calc basic params
|
|
||||||
const volEmptyBasin = this.config.basin.volume;
|
|
||||||
const heightBasin = this.config.basin.height;
|
|
||||||
const heightInlet = this.config.basin.heightInlet;
|
|
||||||
const heightOutlet = this.config.basin.heightOutlet;
|
|
||||||
const heightOverflow = this.config.basin.heightOverflow;
|
|
||||||
|
|
||||||
//calculated params
|
|
||||||
const surfaceArea = volEmptyBasin / heightBasin;
|
|
||||||
const maxVol = heightBasin * surfaceArea; // if Basin where to ever fill up completely this is the water volume
|
|
||||||
const maxVolOverflow = heightOverflow * surfaceArea ; // Max water volume before you start loosing water to overflow
|
|
||||||
const minVol = heightOutlet * surfaceArea;
|
|
||||||
const minVolOut = heightInlet * surfaceArea ; // this will indicate if its an open end or a closed end.
|
|
||||||
|
|
||||||
this.basin.volEmptyBasin = volEmptyBasin ;
|
|
||||||
this.basin.heightBasin = heightBasin ;
|
|
||||||
this.basin.heightInlet = heightInlet ;
|
|
||||||
this.basin.heightOutlet = heightOutlet ;
|
|
||||||
this.basin.heightOverflow = heightOverflow ;
|
|
||||||
this.basin.surfaceArea = surfaceArea ;
|
|
||||||
this.basin.maxVol = maxVol ;
|
|
||||||
this.basin.maxVolOverflow = maxVolOverflow;
|
|
||||||
this.basin.minVol = minVol ;
|
|
||||||
this.basin.minVolOut = minVolOut ;
|
|
||||||
|
|
||||||
//init predicted min volume to min vol in order to have a starting point
|
|
||||||
this.measurements.type("volume").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(minVol).unit('m3');
|
|
||||||
|
|
||||||
this.logger.debug(`
|
|
||||||
Basin initialized | area=${surfaceArea.toFixed(2)} m²,
|
|
||||||
max=${maxVol.toFixed(2)} m³,
|
|
||||||
overflow=${maxVolOverflow.toFixed(2)} m³`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_calcVolumeFromLevel(level) {
|
// ── Direction helper kept for tests pinning the dead-band semantics ──
|
||||||
const surfaceArea = this.basin.surfaceArea;
|
_deriveDirection(netFlow) { return this.flowAggregator.deriveDirection(netFlow); }
|
||||||
return Math.max(level, 0) * surfaceArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
_calcLevelFromVolume(vol){
|
// ── Volume/level conversions kept for tests + back-compat ──────────────
|
||||||
const surfaceArea = this.basin.surfaceArea;
|
_calcVolumeFromLevel(level) { return this.basin.volumeFromLevel(level); }
|
||||||
return Math.max(vol, 0) / surfaceArea;
|
_calcLevelFromVolume(volume) { return this.basin.levelFromVolume(volume); }
|
||||||
}
|
|
||||||
|
|
||||||
|
_subscribeMeasurement(child) {
|
||||||
|
const position = child.config.functionality.positionVsParent;
|
||||||
|
const measurementType = child.config.asset.type;
|
||||||
|
const eventName = `${measurementType}.measured.${position}`;
|
||||||
|
|
||||||
getOutput() {
|
const handle = (eventData = {}) => {
|
||||||
// Improved output object generation
|
this.logger.debug(
|
||||||
const output = {};
|
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
||||||
//build the output object
|
);
|
||||||
this.measurements.getTypes().forEach(type => {
|
if (measurementType === 'level') {
|
||||||
this.measurements.getVariants(type).forEach(variant => {
|
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
||||||
this.measurements.getPositions(variant).forEach(position => {
|
return;
|
||||||
const sample = this.measurements.type(type).variant(variant).position(position);
|
|
||||||
output[`${type}.${variant}.${position}`] = sample.getCurrentValue();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//fill in the rest of the output object
|
|
||||||
output["state"] = this.state;
|
|
||||||
output["basin"] = this.basin;
|
|
||||||
|
|
||||||
if(this.flowDrift != null){
|
|
||||||
const flowDrift = this.flowDrift;
|
|
||||||
output["flowNrmse"] = flowDrift.nrmse;
|
|
||||||
output["flowLongterNRMSD"] = flowDrift.longTermNRMSD;
|
|
||||||
output["flowImmediateLevel"] = flowDrift.immediateLevel;
|
|
||||||
output["flowLongTermLevel"] = flowDrift.longTermLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = pumpingStation;
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------- */
|
|
||||||
/* Example: pumping station + rotating machine + measurements (stand-alone) */
|
|
||||||
/* ------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const PumpingStation = require("./specificClass");
|
|
||||||
const RotatingMachine = require("../../rotatingMachine/src/specificClass");
|
|
||||||
const Measurement = require("../../measurement/src/specificClass");
|
|
||||||
|
|
||||||
/** Helpers ******************************************************************/
|
|
||||||
function createPumpingStationConfig(name) {
|
|
||||||
return {
|
|
||||||
general: {
|
|
||||||
logging: { enabled: true, logLevel: "debug" },
|
|
||||||
name,
|
|
||||||
id: `${name}-${Date.now()}`,
|
|
||||||
unit: "m3/h"
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: "pumpingStation",
|
|
||||||
role: "stationcontroller"
|
|
||||||
},
|
|
||||||
basin: {
|
|
||||||
volume: 43.75,
|
|
||||||
height: 3.5,
|
|
||||||
heightInlet: 0.3,
|
|
||||||
heightOutlet: 0.2,
|
|
||||||
heightOverflow: 3.0
|
|
||||||
},
|
|
||||||
hydraulics: {
|
|
||||||
refHeight: "NAP",
|
|
||||||
basinBottomRef: 0
|
|
||||||
}
|
}
|
||||||
|
this.measurements.type(measurementType).variant('measured').position(position)
|
||||||
|
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||||||
|
this.measurementRouter.route(measurementType, eventData.value, position, eventData);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function createLevelMeasurementConfig(name) {
|
child.measurements.emitter.on(eventName, handle);
|
||||||
return {
|
|
||||||
general: {
|
// Seed from the child's current value. The emitter only delivers FUTURE
|
||||||
logging: { enabled: true, logLevel: "debug" },
|
// updates, so a parent that registers after the child already emitted
|
||||||
name,
|
// (e.g. a once-only inject that fired during startup before this
|
||||||
id: `${name}-${Date.now()}`,
|
// subscription existed) would otherwise never see that value. Replaying
|
||||||
unit: "m"
|
// the last sample makes a late subscriber pick up the present state.
|
||||||
},
|
const series = child.measurements
|
||||||
functionality: {
|
.type(measurementType).variant('measured').position(position).get?.();
|
||||||
softwareType: "measurement",
|
const sample = series?.getLaggedSample?.(0);
|
||||||
role: "sensor",
|
if (sample && sample.value != null) {
|
||||||
positionVsParent: POSITIONS.AT_EQUIPMENT
|
handle({ ...sample, childName: child.config.general.name });
|
||||||
},
|
}
|
||||||
asset: {
|
}
|
||||||
category: "sensor",
|
|
||||||
type: "level",
|
_subscribePredictedFlow(child) {
|
||||||
model: "demo-level",
|
// Map the child's position to the orchestrator's posKey + the most
|
||||||
supplier: "demoCo",
|
// specific aggregator event. 'downstream' is preferred over 'atequipment'
|
||||||
unit: "m"
|
// because they carry the same total — subscribing to both double-counts.
|
||||||
},
|
const POS_MAP = {
|
||||||
scaling: { enabled: false },
|
downstream: ['out', 'flow.predicted.downstream'],
|
||||||
smoothing: { smoothWindow: 5, smoothMethod: "none" }
|
out: ['out', 'flow.predicted.downstream'],
|
||||||
|
atequipment:['out', 'flow.predicted.downstream'],
|
||||||
|
upstream: ['in', 'flow.predicted.upstream'],
|
||||||
|
in: ['in', 'flow.predicted.upstream'],
|
||||||
};
|
};
|
||||||
}
|
const position = (child.config.functionality.positionVsParent || '').toLowerCase();
|
||||||
|
const mapped = POS_MAP[position];
|
||||||
function createFlowMeasurementConfig(name, position) {
|
if (!mapped) {
|
||||||
return {
|
this.logger.warn(`Unsupported predicted flow position "${position}" from ${child.config.general.name}`);
|
||||||
general: {
|
return;
|
||||||
logging: { enabled: true, logLevel: "debug" },
|
|
||||||
name,
|
|
||||||
id: `${name}-${Date.now()}`,
|
|
||||||
unit: "m3/s"
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: "measurement",
|
|
||||||
role: "sensor",
|
|
||||||
positionVsParent: position
|
|
||||||
},
|
|
||||||
asset: {
|
|
||||||
category: "sensor",
|
|
||||||
type: "flow",
|
|
||||||
model: "demo-flow",
|
|
||||||
supplier: "demoCo",
|
|
||||||
unit: "m3/s"
|
|
||||||
},
|
|
||||||
scaling: { enabled: false },
|
|
||||||
smoothing: { smoothWindow: 5, smoothMethod: "none" }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function createMachineConfig(name) {
|
|
||||||
|
|
||||||
return {
|
|
||||||
|
|
||||||
general: {
|
|
||||||
name: name,
|
|
||||||
logging: {
|
|
||||||
enabled: true,
|
|
||||||
logLevel: "warn",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
asset: {
|
|
||||||
supplier: "Hydrostal",
|
|
||||||
type: "pump",
|
|
||||||
category: "centrifugal",
|
|
||||||
model: "hidrostal-H05K-S03R", // Ensure this field is present.
|
|
||||||
}
|
}
|
||||||
|
const [posKey, eventName] = mapped;
|
||||||
|
const childId = child.config.general.id ?? child.config.general.name;
|
||||||
|
|
||||||
|
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
||||||
|
const unit = eventData.unit || child.config?.general?.unit;
|
||||||
|
const ts = eventData.timestamp || Date.now();
|
||||||
|
this.measurements.type('flow').variant('predicted').position(posKey).child(childId)
|
||||||
|
.value(eventData.value, ts, unit);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMachineStateConfig() {
|
module.exports = PumpingStation;
|
||||||
return {
|
|
||||||
general: {
|
|
||||||
logging: {
|
|
||||||
enabled: true,
|
|
||||||
logLevel: "debug",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Your custom config here (or leave empty for defaults)
|
|
||||||
movement: {
|
|
||||||
speed: 1,
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
starting: 2,
|
|
||||||
warmingup: 3,
|
|
||||||
stopping: 2,
|
|
||||||
coolingdown: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// convenience for seeding measurements
|
|
||||||
function pushSample(measurement, type, value, unit) { // eslint-disable-line no-unused-vars
|
|
||||||
const pos = measurement.config.functionality.positionVsParent;
|
|
||||||
measurement.measurements
|
|
||||||
.type(type)
|
|
||||||
.variant("measured")
|
|
||||||
.position(pos)
|
|
||||||
.value(value, Date.now(), unit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Demo *********************************************************************/
|
|
||||||
(async function demoStationWithPump() {
|
|
||||||
const station = new PumpingStation(createPumpingStationConfig("PumpingStationDemo"));
|
|
||||||
const pump = new RotatingMachine(createMachineConfig("Pump1"), createMachineStateConfig());
|
|
||||||
|
|
||||||
const levelSensor = new Measurement(createLevelMeasurementConfig("WetWellLevel")); // eslint-disable-line no-unused-vars
|
|
||||||
const upstreamFlow = new Measurement(createFlowMeasurementConfig("InfluentFlow", POSITIONS.UPSTREAM)); // eslint-disable-line no-unused-vars
|
|
||||||
const downstreamFlow = new Measurement(createFlowMeasurementConfig("PumpDischargeFlow", POSITIONS.DOWNSTREAM));
|
|
||||||
|
|
||||||
|
|
||||||
// station uses the sensors
|
|
||||||
/*
|
|
||||||
station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType);
|
|
||||||
station.childRegistrationUtils.registerChild(upstreamFlow, upstreamFlow.config.functionality.softwareType);
|
|
||||||
station.childRegistrationUtils.registerChild(downstreamFlow, downstreamFlow.config.functionality.softwareType);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// pump owns the downstream flow sensor
|
|
||||||
pump.childRegistrationUtils.registerChild(downstreamFlow, downstreamFlow.config.functionality.positionVsParent);
|
|
||||||
station.childRegistrationUtils.registerChild(pump, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
setInterval(() => station.tick(), 1000);
|
|
||||||
|
|
||||||
// seed a starting level & flow
|
|
||||||
/*
|
|
||||||
pushSample(levelSensor, "level", 1.8, "m");
|
|
||||||
pushSample(upstreamFlow, "flow", 0.35, "m3/s");
|
|
||||||
pushSample(downstreamFlow, "flow", 0.20, "m3/s");
|
|
||||||
*/
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 20));
|
|
||||||
|
|
||||||
// pump increases discharge flow
|
|
||||||
/*
|
|
||||||
pushSample(downstreamFlow, "flow", 0.28, "m3/s");
|
|
||||||
pushSample(upstreamFlow, "flow", 0.40, "m3/s");
|
|
||||||
pushSample(levelSensor, "level", 1.85, "m");
|
|
||||||
*/
|
|
||||||
console.log("Station output:", station.getOutput());
|
|
||||||
await pump.handleInput("parent", "execSequence", "startup");
|
|
||||||
await pump.handleInput("parent", "execMovement", 50);
|
|
||||||
console.log("Station state:", station.state);
|
|
||||||
console.log("Station output:", station.getOutput());
|
|
||||||
console.log("Pump state:", pump.state.getCurrentState());
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
//coolprop example
|
|
||||||
(async () => {
|
|
||||||
const PropsSI = await coolprop.getPropsSI();
|
|
||||||
|
|
||||||
// 👇 replace these with your real inputs
|
|
||||||
const tC_input = 25; // °C
|
|
||||||
const pPa_input = 101325; // Pa
|
|
||||||
|
|
||||||
// Sanitize & convert
|
|
||||||
const T = Number(tC_input) + 273.15; // K
|
|
||||||
const P = Number(pPa_input); // Pa
|
|
||||||
const fluid = 'Water';
|
|
||||||
|
|
||||||
// Preconditions
|
|
||||||
if (!Number.isFinite(T) || !Number.isFinite(P)) {
|
|
||||||
throw new Error(`Bad inputs: T=${T} K, P=${P} Pa`);
|
|
||||||
}
|
|
||||||
if (T <= 0) throw new Error(`Temperature must be in Kelvin (>0). Got ${T}.`);
|
|
||||||
if (P <= 0) throw new Error(`Pressure must be >0 Pa. Got ${P}.`);
|
|
||||||
|
|
||||||
// Try T,P order
|
|
||||||
let rho = PropsSI('D', 'T', T, 'P', P, fluid);
|
|
||||||
// Fallback: P,T order (should be equivalent)
|
|
||||||
if (!Number.isFinite(rho)) rho = PropsSI('D', 'P', P, 'T', T, fluid);
|
|
||||||
|
|
||||||
console.log({ T, P, rho });
|
|
||||||
|
|
||||||
if (!Number.isFinite(rho)) {
|
|
||||||
console.error('Still Infinity. Extra checks:');
|
|
||||||
console.error('typeof T:', typeof T, 'typeof P:', typeof P);
|
|
||||||
console.error('Example known-good call:', PropsSI('D', 'T', 298.15, 'P', 101325, 'Water'));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
*/
|
|
||||||
|
|||||||
101
test/_output-manifest.md
Normal 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 0–14). **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.
|
||||||
106
test/basic/BasinGeometry.basic.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Basic unit tests for BasinGeometry.
|
||||||
|
// Run with: node --test test/basic/BasinGeometry.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const BasinGeometry = require('../../src/basin/BasinGeometry');
|
||||||
|
|
||||||
|
function makeBasin(overrides = {}) {
|
||||||
|
const basin = {
|
||||||
|
volume: 50,
|
||||||
|
height: 5,
|
||||||
|
inflowLevel: 3,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
...overrides.basin,
|
||||||
|
};
|
||||||
|
const hydraulics = {
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
...overrides.hydraulics,
|
||||||
|
};
|
||||||
|
return new BasinGeometry(basin, hydraulics);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('constructor produces correct surfaceArea = volume / height', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.surfaceArea, 10); // 50 / 5
|
||||||
|
assert.equal(g.heightBasin, 5);
|
||||||
|
assert.equal(g.volEmptyBasin, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxVolAtOverflow equals overflowLevel × surfaceArea', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.maxVolAtOverflow, 4.5 * 10); // 45
|
||||||
|
assert.equal(g.minVolAtInflow, 3 * 10); // 30
|
||||||
|
assert.equal(g.minVolAtOutflow, 0.2 * 10); // 2
|
||||||
|
assert.equal(g.maxVol, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minVol selects outlet-based when minHeightBasedOn = 'outlet'", () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.minVol, g.minVolAtOutflow);
|
||||||
|
assert.equal(g.minHeightBasedOn, 'outlet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minVol selects inlet-based when minHeightBasedOn = 'inlet'", () => {
|
||||||
|
const g = makeBasin({ hydraulics: { minHeightBasedOn: 'inlet' } });
|
||||||
|
assert.equal(g.minVol, g.minVolAtInflow);
|
||||||
|
assert.equal(g.minHeightBasedOn, 'inlet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('volumeFromLevel(0) returns 0; negative level clamps to 0', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.volumeFromLevel(0), 0);
|
||||||
|
assert.equal(g.volumeFromLevel(-1), 0);
|
||||||
|
assert.equal(g.volumeFromLevel(-1e9), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('volumeFromLevel(positive) is level × surfaceArea', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.volumeFromLevel(2.5), 25);
|
||||||
|
assert.equal(g.volumeFromLevel(5), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelFromVolume(maxVol) returns heightBasin', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.levelFromVolume(g.maxVol), g.heightBasin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelFromVolume(0) returns 0; negative volume clamps to 0', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.levelFromVolume(0), 0);
|
||||||
|
assert.equal(g.levelFromVolume(-10), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip: volumeFromLevel(levelFromVolume(v)) ≈ v for v in range', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
for (const v of [0, 0.001, 1, 12.34, 25, 49.999, 50]) {
|
||||||
|
const back = g.volumeFromLevel(g.levelFromVolume(v));
|
||||||
|
assert.ok(Math.abs(back - v) < 1e-9, `round-trip failed for v=${v}, got ${back}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip: levelFromVolume(volumeFromLevel(L)) ≈ L for L in range', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
for (const L of [0, 0.05, 1, 2.5, 4.5, 5]) {
|
||||||
|
const back = g.levelFromVolume(g.volumeFromLevel(L));
|
||||||
|
assert.ok(Math.abs(back - L) < 1e-9, `round-trip failed for L=${L}, got ${back}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot() exposes legacy this.basin field names', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
const s = g.snapshot();
|
||||||
|
const expectedKeys = [
|
||||||
|
'volEmptyBasin', 'heightBasin', 'inflowLevel', 'outflowLevel',
|
||||||
|
'overflowLevel', 'surfaceArea', 'maxVol', 'maxVolAtOverflow',
|
||||||
|
'minVolAtInflow', 'minVolAtOutflow', 'minVol', 'minHeightBasedOn',
|
||||||
|
];
|
||||||
|
for (const k of expectedKeys) {
|
||||||
|
assert.ok(k in s, `snapshot missing key: ${k}`);
|
||||||
|
}
|
||||||
|
assert.equal(s.volEmptyBasin, 50);
|
||||||
|
assert.equal(s.surfaceArea, 10);
|
||||||
|
assert.equal(s.minHeightBasedOn, 'outlet');
|
||||||
|
});
|
||||||
85
test/basic/_probe_upstream_emit.test.js
Normal 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');
|
||||||
|
});
|
||||||
106
test/basic/calibration.basic.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Basic tests for the calibration helpers.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
const {
|
||||||
|
calibratePredictedVolume,
|
||||||
|
calibratePredictedLevel,
|
||||||
|
setManualInflow,
|
||||||
|
} = require('../../src/measurement/calibration');
|
||||||
|
|
||||||
|
function makeBasin() {
|
||||||
|
return {
|
||||||
|
surfaceArea: 10,
|
||||||
|
minVol: 2,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
inflowLevel: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(seedVolume = null) {
|
||||||
|
const measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
|
||||||
|
});
|
||||||
|
const basin = makeBasin();
|
||||||
|
if (seedVolume != null) {
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(seedVolume, Date.now() - 5_000, 'm3').unit('m3');
|
||||||
|
}
|
||||||
|
const ctx = { measurements, basin };
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('calibratePredictedVolume clears prior series and writes new value', async () => {
|
||||||
|
const ctx = makeCtx(12);
|
||||||
|
const before = ctx.measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(before - 12) < 1e-9);
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
calibratePredictedVolume(ctx, 30, ts);
|
||||||
|
|
||||||
|
const m = ctx.measurements.type('volume').variant('predicted').position('atequipment').get();
|
||||||
|
assert.equal(m.values.length, 1, 'series should hold exactly the calibration point');
|
||||||
|
assert.ok(Math.abs(m.getCurrentValue() - 30) < 1e-9);
|
||||||
|
|
||||||
|
// Level was derived: 30 / 10 = 3 m.
|
||||||
|
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 3) < 1e-9, `derived level was ${lvl}`);
|
||||||
|
|
||||||
|
assert.equal(ctx._predictedFlowState.lastTimestamp, ts);
|
||||||
|
assert.equal(ctx._predictedFlowState.inflow, 0);
|
||||||
|
assert.equal(ctx._predictedFlowState.outflow, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibratePredictedLevel writes both level and derived volume', async () => {
|
||||||
|
const ctx = makeCtx(2);
|
||||||
|
calibratePredictedLevel(ctx, 4.0, Date.now(), 'm');
|
||||||
|
|
||||||
|
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 4.0) < 1e-9);
|
||||||
|
|
||||||
|
const vol = ctx.measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(vol - 40) < 1e-9, `derived volume was ${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setManualInflow writes to flow.predicted.in.manual-qin', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const ts = Date.now();
|
||||||
|
setManualInflow(ctx, 0.025, ts, 'm3/s');
|
||||||
|
|
||||||
|
const series = ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin');
|
||||||
|
const val = series.getCurrentValue('m3/s');
|
||||||
|
assert.ok(Math.abs(val - 0.025) < 1e-9, `manual-qin value was ${val}`);
|
||||||
|
|
||||||
|
// It must NOT collide with the default child bucket.
|
||||||
|
const defaultBucket = ctx.measurements.measurements?.flow?.predicted?.in?.default;
|
||||||
|
assert.equal(defaultBucket, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibration uses ctx.flowAggregator.resetState when present', async () => {
|
||||||
|
const ctx = makeCtx(5);
|
||||||
|
let resetCalled = null;
|
||||||
|
ctx.flowAggregator = { resetState: (ts) => { resetCalled = ts; } };
|
||||||
|
|
||||||
|
const ts = 1234567890;
|
||||||
|
calibratePredictedVolume(ctx, 20, ts);
|
||||||
|
|
||||||
|
assert.equal(resetCalled, ts);
|
||||||
|
// The plain bag should NOT be touched when the aggregator hook is present.
|
||||||
|
assert.equal(ctx._predictedFlowState, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibratePredictedVolume rejects bad context', async () => {
|
||||||
|
assert.throws(() => calibratePredictedVolume({}, 10));
|
||||||
|
assert.throws(() => calibratePredictedLevel({}, 1.0));
|
||||||
|
assert.throws(() => setManualInflow({}, 0.01));
|
||||||
|
});
|
||||||
185
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// Basic tests for the pumpingStation commands registry.
|
||||||
|
// Run with: node --test test/basic/commands.basic.test.js
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createRegistry } = require('generalFunctions');
|
||||||
|
const commands = require('../../src/commands');
|
||||||
|
|
||||||
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
warn: (m) => calls.warn.push(String(m)),
|
||||||
|
error: (m) => calls.error.push(String(m)),
|
||||||
|
info: (m) => calls.info.push(String(m)),
|
||||||
|
debug: (m) => calls.debug.push(String(m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSource({ mode = 'manual' } = {}) {
|
||||||
|
const calls = {
|
||||||
|
changeMode: [],
|
||||||
|
calibratePredictedVolume: [],
|
||||||
|
calibratePredictedLevel: [],
|
||||||
|
setManualInflow: [],
|
||||||
|
forwardDemandToChildren: [],
|
||||||
|
registerChild: [],
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
mode,
|
||||||
|
logger: makeLogger(),
|
||||||
|
changeMode: (m) => calls.changeMode.push(m),
|
||||||
|
calibratePredictedVolume: (v) => calls.calibratePredictedVolume.push(v),
|
||||||
|
calibratePredictedLevel: (v) => calls.calibratePredictedLevel.push(v),
|
||||||
|
setManualInflow: (v, ts, u) => calls.setManualInflow.push({ v, ts, u }),
|
||||||
|
forwardDemandToChildren: async (d) => { calls.forwardDemandToChildren.push(d); },
|
||||||
|
childRegistrationUtils: {
|
||||||
|
registerChild: (childSource, position) =>
|
||||||
|
calls.registerChild.push({ childSource, position }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { source, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx({ child = null, logger = makeLogger() } = {}) {
|
||||||
|
return {
|
||||||
|
logger,
|
||||||
|
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
|
||||||
|
node: {},
|
||||||
|
send: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRegistry(logger) {
|
||||||
|
return createRegistry(commands, { logger });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
test('canonical topics dispatch to their handlers', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.mode', payload: 'levelbased' }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.changeMode, ['levelbased']);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate.volume', payload: '12.5' }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.calibratePredictedVolume, [12.5]);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate.level', payload: 1.25 }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.calibratePredictedLevel, [1.25]);
|
||||||
|
|
||||||
|
// Registry normalises to the descriptor's `units.default` (m3/h) before
|
||||||
|
// the handler runs. 0.5 m3/s -> 1800 m3/h.
|
||||||
|
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s' }, source, makeCtx());
|
||||||
|
assert.equal(calls.setManualInflow.length, 1);
|
||||||
|
assert.equal(calls.setManualInflow[0].v, 1800);
|
||||||
|
assert.equal(calls.setManualInflow[0].u, 'm3/h');
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 100 }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.forwardDemandToChildren, [100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const child = { id: 'child-1', source: { tag: 'child-domain' } };
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
|
||||||
|
source,
|
||||||
|
makeCtx({ child })
|
||||||
|
);
|
||||||
|
assert.equal(calls.registerChild.length, 1);
|
||||||
|
assert.equal(calls.registerChild[0].childSource, child.source);
|
||||||
|
assert.equal(calls.registerChild[0].position, 'upstream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(ctxLogger);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
|
||||||
|
assert.deepEqual(calls.changeMode, ['manual', 'manual']);
|
||||||
|
const deprecWarns = ctxLogger.calls.warn.filter((m) => m.includes("'changemode' is deprecated"));
|
||||||
|
assert.equal(deprecWarns.length, 1, 'deprecation warning should log exactly once');
|
||||||
|
assert.equal(reg.deprecationStats().changemode, 2);
|
||||||
|
|
||||||
|
// q_in alias also routes to setInflow.
|
||||||
|
await reg.dispatch({ topic: 'q_in', payload: 0.25, unit: 'm3/s' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
assert.equal(calls.setManualInflow.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child.register with unknown child id logs warn and does not throw', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await assert.doesNotReject(() =>
|
||||||
|
reg.dispatch(
|
||||||
|
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
|
||||||
|
source,
|
||||||
|
makeCtx({ logger: ctxLogger })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal(calls.registerChild.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
|
||||||
|
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set.inflow accepts number payload and { value, unit, timestamp } object payload', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
// After registry units-normalisation the handler always sees a number in
|
||||||
|
// the descriptor's default unit (m3/h). 0.5 m3/s -> 1800 m3/h.
|
||||||
|
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.setManualInflow[0], { v: 1800, ts: 1000, u: 'm3/h' });
|
||||||
|
|
||||||
|
// Object payload `{ value, unit }` is flattened to a number; 2 m3/h stays
|
||||||
|
// 2 m3/h. The timestamp travels on the msg envelope after normalisation
|
||||||
|
// (the per-payload `timestamp` field is not preserved by the flatten).
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'set.inflow', payload: { value: 2, unit: 'm3/h' }, timestamp: 2000 },
|
||||||
|
source,
|
||||||
|
makeCtx()
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls.setManualInflow[1], { v: 2, ts: 2000, u: 'm3/h' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set.demand in non-manual mode logs debug and does not call forwardDemandToChildren', async () => {
|
||||||
|
const { source, calls } = makeSource({ mode: 'levelbased' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
assert.equal(calls.forwardDemandToChildren.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.debug.some((m) => m.includes('set.demand') && m.includes('levelbased')),
|
||||||
|
`expected debug about ignoring demand, got: ${JSON.stringify(ctxLogger.calls.debug)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set.demand with non-numeric payload logs warn and does not call', async () => {
|
||||||
|
const { source, calls } = makeSource({ mode: 'manual' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
assert.equal(calls.forwardDemandToChildren.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes('set.demand') && m.includes('oops')),
|
||||||
|
`expected warn about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
232
test/basic/control-levelBased.basic.test.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// Unit tests for the level-based control strategy.
|
||||||
|
// Run with: node --test test/basic/control-levelBased.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const levelBased = require('../../src/control/levelBased');
|
||||||
|
|
||||||
|
function makeMeasurements(levelMeters) {
|
||||||
|
// Minimal MeasurementContainer stand-in. The strategy only calls
|
||||||
|
// getUnit('level') and a chain ending in getCurrentValue(unit).
|
||||||
|
const chain = {
|
||||||
|
type() { return chain; },
|
||||||
|
variant() { return chain; },
|
||||||
|
position() { return chain; },
|
||||||
|
getCurrentValue() {
|
||||||
|
return Number.isFinite(levelMeters) ? levelMeters : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getUnit: () => 'm',
|
||||||
|
type: () => chain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroup(name) {
|
||||||
|
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); },
|
||||||
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
|
turnOffAllMachines: () => { calls.turnOff += 1; },
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(levelMeters, opts = {}) {
|
||||||
|
const groups = {
|
||||||
|
a: makeGroup('A'),
|
||||||
|
b: makeGroup('B'),
|
||||||
|
c: makeGroup('C'),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
measurements: makeMeasurements(levelMeters),
|
||||||
|
config: {
|
||||||
|
control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } },
|
||||||
|
},
|
||||||
|
logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} },
|
||||||
|
machineGroups: groups,
|
||||||
|
machines: {},
|
||||||
|
levelVariants: ['measured', 'predicted'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => {
|
||||||
|
const ctx = makeCtx(0.5);
|
||||||
|
const state = { percControl: 42 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 0);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
|
||||||
|
assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge
|
||||||
|
// hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so
|
||||||
|
// MGC doesn't kick a pump on at flow.min before the gate is ever passed.
|
||||||
|
test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => {
|
||||||
|
const ctx = makeCtx(1.5);
|
||||||
|
const state = { percControl: 17 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 0, 'percControl held at 0 before engagement');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff');
|
||||||
|
assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => {
|
||||||
|
const ctx = makeCtx(2);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 0);
|
||||||
|
// Critical: at startLevel pumps are engaged at min flow, NOT turned off.
|
||||||
|
// The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps
|
||||||
|
// at this boundary even though the hysteresis was armed.
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel');
|
||||||
|
assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
|
||||||
|
const ctx = makeCtx(4);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => {
|
||||||
|
const ctx = makeCtx(10);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
// interpolate_lin_single_point clamps via limit_input(o_min, o_max).
|
||||||
|
assert.equal(state.percControl, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('percControl forwarded to every group via setDemand(pct, "%")', async () => {
|
||||||
|
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 50);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.setDemand.length, 1, 'one forward per group');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [50, '%']);
|
||||||
|
assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand');
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => {
|
||||||
|
// startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between
|
||||||
|
// startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now
|
||||||
|
// the ramp is anchored at startLevel so level=2.5 → 25 %.
|
||||||
|
const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } });
|
||||||
|
ctx.basin = { inflowLevel: 3 };
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.ok(Math.abs(state.percControl - 25) < 1e-9,
|
||||||
|
`expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => {
|
||||||
|
// Same geometry but operator raises holdLevel to 3 so the ramp's 0 %
|
||||||
|
// foot moves up. Level=2.5 should now sit in the hold band: pumps are
|
||||||
|
// engaged but emit 0 % (= MGC's flow.min, NOT turn-off).
|
||||||
|
const ctx = makeCtx(2.5, {
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 },
|
||||||
|
});
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 0, '0 % in the configurable hold band');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band');
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [0, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => {
|
||||||
|
// stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the
|
||||||
|
// band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %).
|
||||||
|
const ctx = makeCtx(1.5, {
|
||||||
|
levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 },
|
||||||
|
});
|
||||||
|
// Pre-arm: simulate that level previously crossed startLevel.
|
||||||
|
ctx.host = { _stopHystRunning: true };
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.deepEqual(g._calls.setDemand[0], [1, '%']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
|
||||||
|
const ctx = makeCtx(NaN);
|
||||||
|
let warned = false;
|
||||||
|
ctx.logger.warn = () => { warned = true; };
|
||||||
|
const state = { percControl: 7 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(warned, true);
|
||||||
|
assert.equal(state.percControl, 7);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.equal(g._calls.handleInput.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: a station engaged above startLevel but with no machine group
|
||||||
|
// registered (e.g. the Port 2 parent↔group registration was dropped by a
|
||||||
|
// partial redeploy) computes a real demand that goes nowhere. The strategy
|
||||||
|
// must surface this once, not fail silently. See the 2026-05-27 "PS not
|
||||||
|
// reacting to level" trace.
|
||||||
|
test('engaged with NO machine group registered → warns once (throttled via host)', async () => {
|
||||||
|
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged
|
||||||
|
ctx.machineGroups = {}; // registration lost
|
||||||
|
ctx.host = {};
|
||||||
|
const warns = [];
|
||||||
|
ctx.logger.warn = (m) => warns.push(m);
|
||||||
|
|
||||||
|
const state = { percControl: 0 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.ok(state.percControl > 0, 'demand is computed even though there is no group');
|
||||||
|
assert.equal(warns.length, 1, 'warns exactly once');
|
||||||
|
assert.match(warns[0], /no machine group is registered/i);
|
||||||
|
assert.equal(ctx.host._warnedNoMachineGroup, true);
|
||||||
|
|
||||||
|
// Subsequent ticks while still group-less stay quiet (no log spam).
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warning re-arms after a group reappears then disappears again', async () => {
|
||||||
|
const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } });
|
||||||
|
ctx.host = {};
|
||||||
|
const warns = [];
|
||||||
|
ctx.logger.warn = (m) => warns.push(m);
|
||||||
|
const state = { percControl: 0 };
|
||||||
|
|
||||||
|
ctx.machineGroups = {};
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
|
||||||
|
// Group registers again → flag clears, no new warning.
|
||||||
|
ctx.machineGroups = { a: makeGroup('A') };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
assert.equal(ctx.host._warnedNoMachineGroup, false);
|
||||||
|
|
||||||
|
// Group lost again → warns once more.
|
||||||
|
ctx.machineGroups = {};
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(warns.length, 2, 're-armed after recovery');
|
||||||
|
});
|
||||||
71
test/basic/control-manual.basic.test.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Unit tests for the manual control strategy.
|
||||||
|
// Run with: node --test test/basic/control-manual.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { UnitPolicy } = require('generalFunctions');
|
||||||
|
const manual = require('../../src/control/manual');
|
||||||
|
|
||||||
|
const unitPolicy = UnitPolicy.declare({
|
||||||
|
canonical: { flow: 'm3/s' },
|
||||||
|
output: { flow: 'm3/s' },
|
||||||
|
requireUnitForTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeGroup(name) {
|
||||||
|
const calls = { handleInput: [] };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMachine(name) {
|
||||||
|
const calls = { handleInput: [] };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('forwardDemand calls handleInput("parent", canonical m3/s demand) on every machine group', async () => {
|
||||||
|
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
||||||
|
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
|
||||||
|
|
||||||
|
await manual.forwardDemand(ctx, 360);
|
||||||
|
|
||||||
|
for (const g of Object.values(groups)) {
|
||||||
|
assert.equal(g._calls.handleInput.length, 1);
|
||||||
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 0.1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwardDemand with no machineGroups but direct machines splits demand evenly', async () => {
|
||||||
|
const machines = { m1: makeMachine('M1'), m2: makeMachine('M2'), m3: makeMachine('M3'), m4: makeMachine('M4') };
|
||||||
|
const ctx = { machineGroups: {}, machines, logger: makeLogger() };
|
||||||
|
|
||||||
|
await manual.forwardDemand(ctx, 80);
|
||||||
|
|
||||||
|
for (const m of Object.values(machines)) {
|
||||||
|
assert.equal(m._calls.handleInput.length, 1);
|
||||||
|
assert.deepEqual(m._calls.handleInput[0], ['parent', 'execMovement', 20]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run() is a no-op (manual mode is event-driven)', async () => {
|
||||||
|
const groups = { a: makeGroup('A') };
|
||||||
|
const ctx = { machineGroups: groups, machines: {}, unitPolicy, logger: makeLogger() };
|
||||||
|
await manual.run(ctx, { percControl: 0 });
|
||||||
|
assert.equal(groups.a._calls.handleInput.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual exports name === "manual"', () => {
|
||||||
|
assert.equal(manual.name, 'manual');
|
||||||
|
});
|
||||||
183
test/basic/flowAggregator.basic.test.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Basic tests for FlowAggregator. Pure node:test, no Node-RED runtime.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
const FlowAggregator = require('../../src/measurement/flowAggregator');
|
||||||
|
|
||||||
|
function makeBasin() {
|
||||||
|
// Constant-cross-section basin: 50 m3 / 5 m height ⇒ surfaceArea = 10 m2.
|
||||||
|
const surfaceArea = 10;
|
||||||
|
return {
|
||||||
|
surfaceArea,
|
||||||
|
minVol: 2,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45, // overflow at 4.5 m
|
||||||
|
minVolAtOutflow: 2,
|
||||||
|
minVolAtInflow: 30,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
inflowLevel: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMeasurements() {
|
||||||
|
return new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAggregator(overrides = {}) {
|
||||||
|
const measurements = overrides.measurements || makeMeasurements();
|
||||||
|
const basin = overrides.basin || makeBasin();
|
||||||
|
// Seed predicted volume at minVol so update() has a starting point.
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(basin.minVol).unit('m3');
|
||||||
|
const fa = new FlowAggregator({ measurements, basin, flowThreshold: 1e-4 });
|
||||||
|
return { fa, measurements, basin };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('FlowAggregator.update integrates inflow-outflow over delta-t', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
// Net flow = 0.01 m3/s (in) - 0.005 m3/s (out) = 0.005 m3/s.
|
||||||
|
const t0 = Date.now() - 10_000; // 10 s ago
|
||||||
|
measurements.type('flow').variant('predicted').position('in').child('src')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('out').child('snk')
|
||||||
|
.value(0.005, t0, 'm3/s');
|
||||||
|
|
||||||
|
// Force the integrator to know we are starting 10 s in the past.
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// Expect minVol(2) + 0.005 * ~10 ≈ 2.05 m3. Allow slack for clock jitter.
|
||||||
|
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.update integrates measured inflow when predicted side is empty', async () => {
|
||||||
|
// Regression: a real upstream sensor writes `flow.measured.upstream.<id>`
|
||||||
|
// (the measurement node hard-codes variant='measured'), but the integrator
|
||||||
|
// used to read variant='predicted' only — so level stayed flat while the
|
||||||
|
// status row reported +N m³/h. The fix mirrors selectBestNetFlow's
|
||||||
|
// variant precedence per side.
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
const t0 = Date.now() - 10_000;
|
||||||
|
// Measured inflow at 'upstream' (one of the inflow position aliases),
|
||||||
|
// no outflow side at all.
|
||||||
|
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// Expect minVol(2) + 0.01 × ~10 ≈ 2.10 m3.
|
||||||
|
assert.ok(vol > 2.09 && vol < 2.11, `measured inflow did not integrate: vol=${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.update mixes measured inflow with predicted outflow', async () => {
|
||||||
|
// Realistic mix: real upstream sensor (measured) + pump-curve outflow
|
||||||
|
// (predicted). The picker resolves each side independently, so the net
|
||||||
|
// balance uses both.
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
const t0 = Date.now() - 10_000;
|
||||||
|
measurements.type('flow').variant('measured').position('upstream').child('sensor-A')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('downstream').child('pump-A')
|
||||||
|
.value(0.004, t0, 'm3/s');
|
||||||
|
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// minVol(2) + (0.01 - 0.004) × ~10 ≈ 2.06 m3.
|
||||||
|
assert.ok(vol > 2.05 && vol < 2.07, `mixed-variant integration produced vol=${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
measurements.type('flow').variant('measured').position('in').child('m')
|
||||||
|
.value(0.02, Date.now(), 'm3/s');
|
||||||
|
measurements.type('flow').variant('measured').position('out').child('m')
|
||||||
|
.value(0.01, Date.now(), 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('in').child('p')
|
||||||
|
.value(0.5, Date.now(), 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('out').child('p')
|
||||||
|
.value(0.0, Date.now(), 'm3/s');
|
||||||
|
|
||||||
|
const r = fa.selectBestNetFlow();
|
||||||
|
assert.equal(r.source, 'measured');
|
||||||
|
assert.ok(Math.abs(r.value - 0.01) < 1e-9);
|
||||||
|
assert.equal(r.direction, 'filling');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.selectBestNetFlow falls back to level rate when no flow', async () => {
|
||||||
|
const { fa, measurements, basin } = makeAggregator();
|
||||||
|
// Seed two level samples 2 s apart, rising 0.1 m → rate 0.05 m/s
|
||||||
|
// → net flow = 0.05 * 10 m2 = 0.5 m3/s (filling).
|
||||||
|
const t0 = Date.now() - 2_000;
|
||||||
|
const t1 = Date.now();
|
||||||
|
measurements.type('level').variant('measured').position('atequipment').child('default')
|
||||||
|
.value(1.0, t0, 'm');
|
||||||
|
measurements.type('level').variant('measured').position('atequipment').child('default')
|
||||||
|
.value(1.1, t1, 'm');
|
||||||
|
|
||||||
|
const r = fa.selectBestNetFlow();
|
||||||
|
assert.ok(r.source.startsWith('level:'), `source was ${r.source}`);
|
||||||
|
assert.equal(r.direction, 'filling');
|
||||||
|
assert.ok(Math.abs(r.value - basin.surfaceArea * 0.05) < 1e-3, `net flow was ${r.value}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.deriveDirection threshold semantics', async () => {
|
||||||
|
const { fa } = makeAggregator();
|
||||||
|
assert.equal(fa.deriveDirection(0), 'steady');
|
||||||
|
assert.equal(fa.deriveDirection(fa.flowThreshold * 2), 'filling');
|
||||||
|
assert.equal(fa.deriveDirection(-fa.flowThreshold * 2), 'draining');
|
||||||
|
assert.equal(fa.deriveDirection(fa.flowThreshold * 0.5), 'steady');
|
||||||
|
assert.equal(fa.deriveDirection(-fa.flowThreshold * 0.5), 'steady');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.computeRemainingTime — filling uses overflow ceiling', async () => {
|
||||||
|
const { fa, measurements, basin } = makeAggregator();
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(2.0, Date.now(), 'm');
|
||||||
|
// Net 0.05 m3/s upward; remaining height = 4.5 - 2.0 = 2.5 m.
|
||||||
|
// seconds = 2.5 * 10 / 0.05 = 500 s.
|
||||||
|
const r = fa.computeRemainingTime({ value: 0.05, source: 'measured', direction: 'filling' });
|
||||||
|
assert.ok(Math.abs(r.seconds - 500) < 1e-6, `seconds was ${r.seconds}`);
|
||||||
|
assert.equal(typeof r.source, 'string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.computeRemainingTime — draining uses outflow floor', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(1.0, Date.now(), 'm');
|
||||||
|
// Net -0.05 m3/s; remaining height = 1.0 - 0.2 = 0.8 m.
|
||||||
|
// seconds = 0.8 * 10 / 0.05 = 160 s.
|
||||||
|
const r = fa.computeRemainingTime({ value: -0.05, source: 'measured', direction: 'draining' });
|
||||||
|
assert.ok(Math.abs(r.seconds - 160) < 1e-6, `seconds was ${r.seconds}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.snapshot exposes the expected shape', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
measurements.type('flow').variant('measured').position('in').child('m')
|
||||||
|
.value(0.02, Date.now(), 'm3/s');
|
||||||
|
fa.tick();
|
||||||
|
const snap = fa.snapshot();
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'direction'));
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'netFlow'));
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'flowSource'));
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'secondsRemaining'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.computeRemainingTime — below threshold returns null seconds', async () => {
|
||||||
|
const { fa } = makeAggregator();
|
||||||
|
const r = fa.computeRemainingTime({ value: 0, source: null, direction: 'steady' });
|
||||||
|
assert.equal(r.seconds, null);
|
||||||
|
});
|
||||||
106
test/basic/measurementRouter.basic.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Basic tests for MeasurementRouter.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer, coolprop } = require('generalFunctions');
|
||||||
|
const MeasurementRouter = require('../../src/measurement/measurementRouter');
|
||||||
|
|
||||||
|
// CoolProp is async-init; ensure it's warm before any pressure-conversion
|
||||||
|
// test runs.
|
||||||
|
test.before(async () => {
|
||||||
|
await coolprop.init({ refrigerant: 'Water' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeBasin() {
|
||||||
|
return {
|
||||||
|
surfaceArea: 10,
|
||||||
|
minVol: 2,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
inflowLevel: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMeasurements() {
|
||||||
|
return new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLogger() {
|
||||||
|
const calls = { warn: [], info: [], error: [], debug: [] };
|
||||||
|
return {
|
||||||
|
warn: (m) => calls.warn.push(m),
|
||||||
|
info: (m) => calls.info.push(m),
|
||||||
|
error: (m) => calls.error.push(m),
|
||||||
|
debug: (m) => calls.debug.push(m),
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('onLevelMeasurement writes volume + percent', async () => {
|
||||||
|
const measurements = makeMeasurements();
|
||||||
|
const basin = makeBasin();
|
||||||
|
const router = new MeasurementRouter({ measurements, basin });
|
||||||
|
|
||||||
|
router.onLevelMeasurement('atequipment', 2.5, { unit: 'm', timestamp: Date.now() });
|
||||||
|
|
||||||
|
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3');
|
||||||
|
// 2.5 m * 10 m² = 25 m3.
|
||||||
|
assert.ok(Math.abs(vol - 25) < 1e-9, `volume was ${vol}`);
|
||||||
|
|
||||||
|
const pct = measurements.type('volumePercent').variant('measured').position('atequipment').getCurrentValue('%');
|
||||||
|
// (25 - 2) / (45 - 2) * 100 ≈ 53.488...
|
||||||
|
assert.ok(pct > 53 && pct < 54, `percent was ${pct}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onPressureMeasurement falls back to assumed temperature and warns', async () => {
|
||||||
|
const measurements = makeMeasurements();
|
||||||
|
const basin = makeBasin();
|
||||||
|
const logger = fakeLogger();
|
||||||
|
const router = new MeasurementRouter({ measurements, basin, logger });
|
||||||
|
|
||||||
|
// No temperature seeded — must fall back to assumed 15C.
|
||||||
|
measurements.type('pressure').variant('measured').position('atequipment')
|
||||||
|
.value(20000, Date.now(), 'Pa');
|
||||||
|
router.onPressureMeasurement('atequipment', 20000, { unit: 'Pa', timestamp: Date.now() });
|
||||||
|
|
||||||
|
const warned = logger._calls.warn.some((m) => /assuming 15C|temperature/i.test(m));
|
||||||
|
assert.ok(warned, 'expected a warn about missing temperature');
|
||||||
|
|
||||||
|
const assumedT = measurements.type('temperature').variant('assumed').position('atequipment')
|
||||||
|
.getCurrentValue('K');
|
||||||
|
assert.ok(Number.isFinite(assumedT), 'assumed temperature was not stored');
|
||||||
|
|
||||||
|
const lvl = measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m');
|
||||||
|
// 20000 Pa / (~999 kg/m³ * 9.80665) ≈ 2.04 m.
|
||||||
|
assert.ok(lvl > 1.9 && lvl < 2.2, `derived level was ${lvl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route() dispatches by measurement type', async () => {
|
||||||
|
const measurements = makeMeasurements();
|
||||||
|
const basin = makeBasin();
|
||||||
|
const router = new MeasurementRouter({ measurements, basin });
|
||||||
|
|
||||||
|
const handledLevel = router.route('level', 1.5, 'atequipment', { unit: 'm' });
|
||||||
|
assert.equal(handledLevel, true);
|
||||||
|
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 1.5) < 1e-9);
|
||||||
|
|
||||||
|
// Unknown type returns false (no dispatch).
|
||||||
|
const handledOther = router.route('flow', 0.1, 'in', {});
|
||||||
|
assert.equal(handledOther, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor rejects missing context fields', async () => {
|
||||||
|
assert.throws(() => new MeasurementRouter({}));
|
||||||
|
assert.throws(() => new MeasurementRouter({ measurements: makeMeasurements() }));
|
||||||
|
});
|
||||||
74
test/basic/nodeClass-config.test.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const NodeClass = require('../../src/nodeClass');
|
||||||
|
|
||||||
|
function loadConfig(uiConfig = {}) {
|
||||||
|
const ctx = { name: 'pumpingStation' };
|
||||||
|
NodeClass.prototype._loadConfig.call(ctx, {
|
||||||
|
name: 'PS Config Test',
|
||||||
|
basinVolume: 80,
|
||||||
|
basinHeight: 8,
|
||||||
|
inflowLevel: 3.2,
|
||||||
|
outflowLevel: 0.4,
|
||||||
|
overflowLevel: 7.4,
|
||||||
|
inletPipeDiameter: 0.5,
|
||||||
|
outletPipeDiameter: 0.35,
|
||||||
|
refHeight: 'NAP',
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
basinBottomRef: -1.2,
|
||||||
|
maxInflowRate: 300,
|
||||||
|
staticHead: 11,
|
||||||
|
maxDischargeHead: 22,
|
||||||
|
pipelineLength: 120,
|
||||||
|
defaultFluid: 'wastewater',
|
||||||
|
temperatureReferenceDegC: 16,
|
||||||
|
controlMode: 'levelbased',
|
||||||
|
minLevel: 0.8,
|
||||||
|
startLevel: 2,
|
||||||
|
maxLevel: 6.5,
|
||||||
|
levelCurveType: 'log',
|
||||||
|
logCurveFactor: 7,
|
||||||
|
enableDryRunProtection: true,
|
||||||
|
dryRunThresholdPercent: 3,
|
||||||
|
enableHighVolumeSafety: true,
|
||||||
|
highVolumeSafetyThresholdPercent: 96,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 60,
|
||||||
|
processOutputFormat: 'process',
|
||||||
|
dbaseOutputFormat: 'influxdb',
|
||||||
|
...uiConfig,
|
||||||
|
}, { id: 'node-1' });
|
||||||
|
return ctx.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('nodeClass config mapping — basin, hydraulics, mode and safety fields', () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
assert.equal(cfg.basin.inletPipeDiameter, 0.5);
|
||||||
|
assert.equal(cfg.basin.outletPipeDiameter, 0.35);
|
||||||
|
assert.equal(cfg.hydraulics.maxInflowRate, 300);
|
||||||
|
assert.equal(cfg.hydraulics.staticHead, 11);
|
||||||
|
assert.equal(cfg.hydraulics.maxDischargeHead, 22);
|
||||||
|
assert.equal(cfg.hydraulics.pipelineLength, 120);
|
||||||
|
assert.equal(cfg.hydraulics.defaultFluid, 'wastewater');
|
||||||
|
assert.equal(cfg.hydraulics.temperatureReferenceDegC, 16);
|
||||||
|
assert.equal(cfg.control.mode, 'levelbased');
|
||||||
|
assert.equal(cfg.control.levelbased.curveType, 'log');
|
||||||
|
assert.equal(cfg.control.levelbased.logCurveFactor, 7);
|
||||||
|
assert.equal(cfg.safety.enableHighVolumeSafety, true);
|
||||||
|
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 96);
|
||||||
|
assert.equal(cfg.output.process, 'process');
|
||||||
|
assert.equal(cfg.output.dbase, 'influxdb');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nodeClass config mapping — accepts deprecated overfill UI fields', () => {
|
||||||
|
const cfg = loadConfig({
|
||||||
|
enableHighVolumeSafety: undefined,
|
||||||
|
highVolumeSafetyThresholdPercent: undefined,
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
overfillThresholdPercent: 91,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(cfg.safety.enableHighVolumeSafety, false);
|
||||||
|
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 91);
|
||||||
|
});
|
||||||
81
test/basic/replay-on-subscribe.basic.test.js
Normal 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');
|
||||||
|
});
|
||||||
230
test/basic/safetyController.basic.test.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const SafetyController = require('../../src/safety/safetyController');
|
||||||
|
|
||||||
|
// --------------------------- fakes ---------------------------
|
||||||
|
|
||||||
|
function fakeMeasurements(values) {
|
||||||
|
// values keyed by `${type}.${variant}.${position}` → number|null
|
||||||
|
return {
|
||||||
|
getUnit: (_type) => 'm3',
|
||||||
|
type(t) {
|
||||||
|
return {
|
||||||
|
variant(v) {
|
||||||
|
return {
|
||||||
|
position(p) {
|
||||||
|
return {
|
||||||
|
getCurrentValue() {
|
||||||
|
const k = `${t}.${v}.${p}`;
|
||||||
|
return values[k];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMachine(positionVsParent, operational = true) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
config: { functionality: { positionVsParent } },
|
||||||
|
_isOperationalState: () => operational,
|
||||||
|
handleInput: (...args) => calls.push(args),
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStation() {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
handleInput: (...args) => calls.push(args),
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroup() {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
turnOffAllMachines: () => calls.push(['turnOffAllMachines']),
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const warns = [];
|
||||||
|
return {
|
||||||
|
warn: (msg) => warns.push(msg),
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
warns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx({
|
||||||
|
vol = 50,
|
||||||
|
basin = { minVol: 10, maxVolAtOverflow: 90 },
|
||||||
|
safety = {
|
||||||
|
enableDryRunProtection: true,
|
||||||
|
enableOverfillProtection: true,
|
||||||
|
dryRunThresholdPercent: 10,
|
||||||
|
overfillThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
machines = {},
|
||||||
|
stations = {},
|
||||||
|
machineGroups = {},
|
||||||
|
} = {}) {
|
||||||
|
const measurements = fakeMeasurements({
|
||||||
|
'volume.measured.atequipment': vol,
|
||||||
|
'volume.predicted.atequipment': vol,
|
||||||
|
});
|
||||||
|
const logger = makeLogger();
|
||||||
|
return {
|
||||||
|
ctx: { measurements, basin, config: { safety }, logger, machines, stations, machineGroups },
|
||||||
|
logger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------- tests ---------------------------
|
||||||
|
|
||||||
|
test('normal volume + filling → not blocked, no shutdowns', () => {
|
||||||
|
const m = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({ vol: 50, machines: { m } });
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
|
||||||
|
assert.deepStrictEqual(r, { blocked: false, reason: null, triggered: [] });
|
||||||
|
assert.strictEqual(m.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dry-run trigger: low volume + draining → blocked, downstream shut down', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const at = makeMachine('atequipment');
|
||||||
|
const up = makeMachine('upstream');
|
||||||
|
const station = makeStation();
|
||||||
|
const group = makeGroup();
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 5, // below 10 * (1 + 10/100) = 11
|
||||||
|
machines: { down, at, up },
|
||||||
|
stations: { station },
|
||||||
|
machineGroups: { group },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
|
||||||
|
assert.strictEqual(r.blocked, true);
|
||||||
|
assert.strictEqual(r.reason, 'dry-run');
|
||||||
|
assert.ok(r.triggered.includes('dry-run-volume'));
|
||||||
|
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(at.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.strictEqual(up.calls.length, 0, 'upstream untouched in dry-run');
|
||||||
|
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(group.calls[0], ['turnOffAllMachines']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dry-run does NOT trigger when filling', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({ vol: 5, machines: { down } });
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
|
||||||
|
// Filling at vol=5 (below overfill threshold 85.5) → no trigger at all.
|
||||||
|
assert.strictEqual(r.blocked, false);
|
||||||
|
assert.strictEqual(r.reason, null);
|
||||||
|
assert.strictEqual(down.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overfill trigger: high volume + filling → not blocked, only upstream + station shut down', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const at = makeMachine('atequipment');
|
||||||
|
const up = makeMachine('upstream');
|
||||||
|
const station = makeStation();
|
||||||
|
const group = makeGroup();
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 88, // above 90 * 0.95 = 85.5
|
||||||
|
machines: { down, at, up },
|
||||||
|
stations: { station },
|
||||||
|
machineGroups: { group },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
|
||||||
|
assert.strictEqual(r.blocked, false, 'overfill must NOT block control');
|
||||||
|
assert.strictEqual(r.reason, 'overfill');
|
||||||
|
assert.ok(r.triggered.includes('overfill-volume'));
|
||||||
|
assert.deepStrictEqual(up.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.strictEqual(down.calls.length, 0, 'downstream must keep running');
|
||||||
|
assert.strictEqual(at.calls.length, 0, 'atequipment must keep running');
|
||||||
|
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.strictEqual(group.calls.length, 0, 'machine groups must keep draining');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no volume data → blocked, all machines shut down (panic)', () => {
|
||||||
|
const a = makeMachine('downstream');
|
||||||
|
const b = makeMachine('upstream');
|
||||||
|
const c = makeMachine('atequipment');
|
||||||
|
// override measurements to return null
|
||||||
|
const measurements = {
|
||||||
|
getUnit: () => 'm3',
|
||||||
|
type: () => ({ variant: () => ({ position: () => ({ getCurrentValue: () => null }) }) }),
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
measurements,
|
||||||
|
basin: { minVol: 10, maxVolAtOverflow: 90 },
|
||||||
|
config: { safety: { enableDryRunProtection: true, enableOverfillProtection: true, dryRunThresholdPercent: 10, overfillThresholdPercent: 95 } },
|
||||||
|
logger: makeLogger(),
|
||||||
|
machines: { a, b, c },
|
||||||
|
stations: {},
|
||||||
|
machineGroups: {},
|
||||||
|
};
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'steady', secondsRemaining: null });
|
||||||
|
assert.strictEqual(r.blocked, true);
|
||||||
|
assert.strictEqual(r.reason, 'no-volume-data');
|
||||||
|
assert.deepStrictEqual(a.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(b.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(c.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time-based protection: short remainingTime while draining triggers dry-run shutdowns', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 50, // well above dry-run vol threshold
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false, // volume rule disabled
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 10,
|
||||||
|
overfillThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 60,
|
||||||
|
},
|
||||||
|
machines: { down },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 30 });
|
||||||
|
assert.strictEqual(r.blocked, true);
|
||||||
|
assert.strictEqual(r.reason, 'dry-run');
|
||||||
|
assert.ok(r.triggered.includes('time-remaining'));
|
||||||
|
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled rules: enableDryRunProtection=false + draining low → no trigger', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 5, // would normally trigger dry-run
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false,
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 10,
|
||||||
|
overfillThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
machines: { down },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
|
||||||
|
assert.strictEqual(r.blocked, false);
|
||||||
|
assert.strictEqual(r.reason, null);
|
||||||
|
assert.strictEqual(down.calls.length, 0);
|
||||||
|
});
|
||||||
656
test/basic/specificClass.test.js
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
|
||||||
|
// Run with: node --test test/basic/specificClass.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
|
||||||
|
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
||||||
|
// assignment is no longer possible. Tests inject mock groups through the
|
||||||
|
// real registration handshake so the registry remains the source of truth.
|
||||||
|
function registerMockGroup(ps, id, behavior = {}) {
|
||||||
|
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
|
||||||
|
const mock = {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
|
||||||
|
asset: { category: 'controller' },
|
||||||
|
},
|
||||||
|
measurements: {
|
||||||
|
emitter: { on: () => {} },
|
||||||
|
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||||
|
},
|
||||||
|
setDemand: behavior.setDemand
|
||||||
|
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
|
||||||
|
handleInput: behavior.handleInput
|
||||||
|
|| (async (...args) => { calls.handleInput.push(args); }),
|
||||||
|
turnOffAllMachines: behavior.turnOffAllMachines
|
||||||
|
|| (() => { calls.turnOff += 1; }),
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard config shape. Override any section by passing { section: {...} }.
|
||||||
|
function makeConfig(overrides = {}) {
|
||||||
|
const base = {
|
||||||
|
general: {
|
||||||
|
name: 'TestStation',
|
||||||
|
id: 'ps-test',
|
||||||
|
unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
flowThreshold: 1e-4,
|
||||||
|
},
|
||||||
|
functionality: {
|
||||||
|
softwareType: 'pumpingStation',
|
||||||
|
role: 'stationcontroller',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
},
|
||||||
|
basin: {
|
||||||
|
volume: 50,
|
||||||
|
height: 5,
|
||||||
|
inflowLevel: 3,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
inletPipeDiameter: 0.4,
|
||||||
|
outletPipeDiameter: 0.3,
|
||||||
|
},
|
||||||
|
hydraulics: {
|
||||||
|
refHeight: 'NAP',
|
||||||
|
basinBottomRef: 0,
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased', 'manual']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false,
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 2,
|
||||||
|
highVolumeSafetyThresholdPercent: 98,
|
||||||
|
overfillThresholdPercent: 98,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const k of Object.keys(overrides)) {
|
||||||
|
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
|
||||||
|
? { ...base[k], ...overrides[k] }
|
||||||
|
: overrides[k];
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
|
||||||
|
await t.test('surfaceArea = volume / height', () => {
|
||||||
|
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
|
||||||
|
});
|
||||||
|
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
|
||||||
|
assert.equal(ps.basin.maxVol, 50);
|
||||||
|
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
|
||||||
|
});
|
||||||
|
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
|
||||||
|
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
|
||||||
|
});
|
||||||
|
await t.test('minVolAtInflow = inflowLevel × area', () => {
|
||||||
|
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
|
||||||
|
});
|
||||||
|
await t.test('minVolAtOutflow = outflowLevel × area', () => {
|
||||||
|
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
|
||||||
|
});
|
||||||
|
await t.test('minVol honours minHeightBasedOn=outlet', () => {
|
||||||
|
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
|
||||||
|
});
|
||||||
|
await t.test('minVol honours minHeightBasedOn=inlet', () => {
|
||||||
|
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||||
|
assert.equal(ps2.basin.minVol, 30);
|
||||||
|
});
|
||||||
|
await t.test('pipe diameters are part of basin contract', () => {
|
||||||
|
assert.equal(ps.basin.inletPipeDiameter, 0.4);
|
||||||
|
assert.equal(ps.basin.outletPipeDiameter, 0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Level ↔ volume roundtrip', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('_calcVolumeFromLevel multiplies by area', () => {
|
||||||
|
assert.equal(ps._calcVolumeFromLevel(2), 20);
|
||||||
|
});
|
||||||
|
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
|
||||||
|
assert.equal(ps._calcVolumeFromLevel(-3), 0);
|
||||||
|
});
|
||||||
|
await t.test('_calcLevelFromVolume divides by area', () => {
|
||||||
|
assert.equal(ps._calcLevelFromVolume(20), 2);
|
||||||
|
});
|
||||||
|
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
|
||||||
|
assert.equal(ps._calcLevelFromVolume(-10), 0);
|
||||||
|
});
|
||||||
|
await t.test('roundtrip preserves level', () => {
|
||||||
|
const v = ps._calcVolumeFromLevel(2.7);
|
||||||
|
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||||
|
await t.test('valid config returns no issues', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
assert.equal(ps.thresholdIssues.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('minLevel > startLevel flagged', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
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', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('overflowLevel > basinHeight flagged', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
|
||||||
|
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
|
||||||
|
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
hydraulics: { minHeightBasedOn: 'inlet' },
|
||||||
|
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Direction derivation — _deriveDirection', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('positive flow above dead-band → filling', () => {
|
||||||
|
assert.equal(ps._deriveDirection(0.01), 'filling');
|
||||||
|
});
|
||||||
|
await t.test('negative flow below dead-band → draining', () => {
|
||||||
|
assert.equal(ps._deriveDirection(-0.01), 'draining');
|
||||||
|
});
|
||||||
|
await t.test('flow inside dead-band → steady', () => {
|
||||||
|
assert.equal(ps._deriveDirection(0), 'steady');
|
||||||
|
assert.equal(ps._deriveDirection(1e-5), 'steady');
|
||||||
|
assert.equal(ps._deriveDirection(-1e-5), 'steady');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mode change — changeMode', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('valid mode swap updates this.mode', () => {
|
||||||
|
ps.changeMode('manual');
|
||||||
|
assert.equal(ps.mode, 'manual');
|
||||||
|
});
|
||||||
|
await t.test('rejected mode leaves this.mode unchanged', () => {
|
||||||
|
ps.changeMode('manual');
|
||||||
|
ps.changeMode('notamode');
|
||||||
|
assert.equal(ps.mode, 'manual');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Calibration — predicted volume and level', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('calibratePredictedVolume rewrites volume series', () => {
|
||||||
|
ps.calibratePredictedVolume(25);
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(vol - 25) < 1e-9);
|
||||||
|
});
|
||||||
|
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
|
||||||
|
ps.calibratePredictedVolume(30);
|
||||||
|
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
|
||||||
|
});
|
||||||
|
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
|
||||||
|
ps.calibratePredictedLevel(2.5);
|
||||||
|
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||||
|
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||||
|
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
assert.equal(ps.percControl, 0);
|
||||||
|
assert.equal(mock._calls.turnOff, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
// 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 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, 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.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
|
||||||
|
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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); // startLevel=2, maxLevel=4 → 25 %
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
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 (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({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: {
|
||||||
|
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||||
|
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
registerMockGroup(ps, 'mgc1');
|
||||||
|
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
|
||||||
|
ps.calibratePredictedLevel(3.5);
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
assert.equal(ps._shiftArmed, false);
|
||||||
|
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||||
|
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
|
||||||
|
ps.calibratePredictedLevel(3.85);
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
assert.equal(ps._shiftArmed, true);
|
||||||
|
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
|
||||||
|
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
|
||||||
|
await ps._controlLevelBased('draining');
|
||||||
|
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||||
|
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
|
||||||
|
ps.calibratePredictedLevel(3.6);
|
||||||
|
await ps._controlLevelBased('draining');
|
||||||
|
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
|
||||||
|
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
|
||||||
|
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
|
||||||
|
ps.calibratePredictedLevel(2.75);
|
||||||
|
await ps._controlLevelBased('draining');
|
||||||
|
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
|
||||||
|
// Below startLevel ⇒ output 0 % AND disarm.
|
||||||
|
ps.calibratePredictedLevel(1.9);
|
||||||
|
await ps._controlLevelBased('draining');
|
||||||
|
assert.equal(ps.percControl, 0);
|
||||||
|
assert.equal(ps._shiftArmed, false);
|
||||||
|
assert.equal(ps._shiftHoldValue, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: {
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(3.85);
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
await ps._controlLevelBased('draining');
|
||||||
|
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||||
|
// Direction back to filling ⇒ up curve, hold cleared, still armed.
|
||||||
|
ps.calibratePredictedLevel(3.9);
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
assert.equal(ps._shiftHoldValue, null);
|
||||||
|
assert.equal(ps._shiftArmed, true);
|
||||||
|
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
|
||||||
|
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
|
||||||
|
await ps._controlLevelBased('draining');
|
||||||
|
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('log curve has fast early response', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
// 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');
|
||||||
|
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
|
||||||
|
await ps._controlLevelBased('filling');
|
||||||
|
assert.ok(ps.percControl > 50);
|
||||||
|
assert.ok(ps.percControl < 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
registerMockGroup(ps, 'mgc1');
|
||||||
|
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
assert.ok(ps.percControl >= 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput — flattens basin + state + demand', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
ps.percControl = 37;
|
||||||
|
|
||||||
|
await t.test('includes basin geometry fields', () => {
|
||||||
|
const out = ps.getOutput();
|
||||||
|
assert.equal(out.volEmptyBasin, 50);
|
||||||
|
assert.equal(out.maxVolAtOverflow, 45);
|
||||||
|
assert.equal(out.minVolAtInflow, 30);
|
||||||
|
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||||
|
assert.equal(out.inletPipeDiameter, 0.4);
|
||||||
|
assert.equal(out.outletPipeDiameter, 0.3);
|
||||||
|
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
|
||||||
|
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 1e-9);
|
||||||
|
});
|
||||||
|
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||||
|
const out = ps.getOutput();
|
||||||
|
assert.ok('direction' in out);
|
||||||
|
assert.ok('flowSource' in out);
|
||||||
|
assert.ok('timeleft' in out);
|
||||||
|
});
|
||||||
|
await t.test('includes percControl', () => {
|
||||||
|
assert.equal(ps.getOutput().percControl, 37);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
|
||||||
|
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
|
||||||
|
assert.ok(Math.abs(v - 0.05) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
|
||||||
|
// tracks any excess as cumulative `overflowVolume` plus a synthetic
|
||||||
|
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
|
||||||
|
// pinned. We drive ticks manually with monotonic timestamps to keep tests
|
||||||
|
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
|
||||||
|
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
|
||||||
|
}));
|
||||||
|
// Seed predicted volume just below the spill point.
|
||||||
|
// maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
|
||||||
|
const t0 = 1_700_000_000_000;
|
||||||
|
ps.calibratePredictedVolume(44, t0);
|
||||||
|
// Heavy inflow, no real outflow (no pumps wired).
|
||||||
|
ps.setManualInflow(2, t0, 'm3/s'); // 2 m³/s, dt=1s → 2 m³/tick
|
||||||
|
|
||||||
|
await t.test('first overflow tick clamps volume and records spill increment', () => {
|
||||||
|
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||||
|
Date.now = () => t0 + 1000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(vol, 45); // pinned at overflow
|
||||||
|
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(cumulative, 1); // proposed=44+2=46, excess=1 m³ this tick
|
||||||
|
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||||
|
assert.equal(spill, 2); // instantaneous balance: inflow − outflowReal
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
|
||||||
|
Date.now = () => t0 + 2000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(vol, 45);
|
||||||
|
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(cumulative, 3); // 1 + 2
|
||||||
|
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||||
|
assert.equal(spill, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
|
||||||
|
const net = ps._selectBestNetFlow();
|
||||||
|
// inflow=2, outflow_total=2 (synthetic spill), net = 0
|
||||||
|
assert.ok(Math.abs(net.value) < 1e-9);
|
||||||
|
assert.equal(net.source, 'predicted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('once inflow stops, spill flow clears and clamp releases', () => {
|
||||||
|
ps.setManualInflow(0, t0 + 2000, 'm3/s');
|
||||||
|
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||||
|
Date.now = () => t0 + 3000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||||
|
assert.equal(spill, 0);
|
||||||
|
// Volume stays at 45 (no draining force) but is no longer "pinned".
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(vol, 45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Predicted volume — dry-run lower clamp', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
// dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
|
||||||
|
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||||
|
}));
|
||||||
|
const t0 = 1_700_000_000_000;
|
||||||
|
|
||||||
|
await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
|
||||||
|
// Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
|
||||||
|
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
Date.now = () => t0 + 1000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(vol, 2); // unchanged — clamp doesn't fire because we started below it
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
|
||||||
|
// Calibrate well above, then push outflow that would cross the threshold.
|
||||||
|
ps.calibratePredictedVolume(3, t0 + 1000);
|
||||||
|
// outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
|
||||||
|
ps.setManualOutflow(2, t0 + 1000, 'm3/s');
|
||||||
|
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||||
|
Date.now = () => t0 + 2000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(vol - 2.1) < 1e-9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
// Seed an overflow scenario.
|
||||||
|
const t0 = 1_700_000_000_000;
|
||||||
|
ps.calibratePredictedVolume(44, t0);
|
||||||
|
ps.setManualInflow(2, t0, 'm3/s');
|
||||||
|
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||||
|
Date.now = () => t0 + 1000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const out = ps.getOutput();
|
||||||
|
assert.equal(out.predictedOverflowVolume, 1);
|
||||||
|
assert.equal(out.predictedOverflowRate, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
|
||||||
|
// from above, so a basin seeded below + continued outflow used to integrate
|
||||||
|
// the volume arbitrarily negative. The level helper masked this by flooring
|
||||||
|
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
|
||||||
|
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||||
|
}));
|
||||||
|
const t0 = 1_700_000_000_000;
|
||||||
|
|
||||||
|
await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
|
||||||
|
ps.calibratePredictedVolume(0.5, t0); // below dryRunSafetyVol (2.1)
|
||||||
|
ps.setManualOutflow(2, t0, 'm3/s'); // 2 m³/s for 1s → would drop to -1.5
|
||||||
|
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
|
||||||
|
Date.now = () => t0 + 1000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(vol, 0); // floored at 0, not -1.5
|
||||||
|
const underflow = ps.measurements
|
||||||
|
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(underflow, 1.5); // tracked as diagnostic
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
|
||||||
|
Date.now = () => t0 + 2000;
|
||||||
|
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(vol, 0);
|
||||||
|
const underflow = ps.measurements
|
||||||
|
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.equal(underflow, 3.5); // 1.5 + 2.0
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('getOutput exposes predictedUnderflowVolume', () => {
|
||||||
|
const out = ps.getOutput();
|
||||||
|
assert.equal(out.predictedUnderflowVolume, 3.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
|
||||||
|
ps.setManualInflow(1, t0 + 2000, 'm3/s');
|
||||||
|
ps.setManualOutflow(0, t0 + 2000, 'm3/s');
|
||||||
|
ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||||
|
Date.now = () => t0 + 3000;
|
||||||
|
ps._updatePredictedVolume();
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(vol - 1) < 1e-9); // 0 + 1 = 1, NOT pinned to 2.1
|
||||||
|
});
|
||||||
|
});
|
||||||
124
test/basic/thresholdValidator.basic.test.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// Basic unit tests for thresholdValidator.
|
||||||
|
// Run with: node --test test/basic/thresholdValidator.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { validateThresholdOrdering } = require('../../src/basin/thresholdValidator');
|
||||||
|
const BasinGeometry = require('../../src/basin/BasinGeometry');
|
||||||
|
|
||||||
|
// A valid baseline: outlet 0.2 < inflow 3 < overflow 4.5 ≤ height 5,
|
||||||
|
// dryRun = 0.2 * 1.10 = 0.22 ≤ minLevel 1 ≤ start 2 < max 4
|
||||||
|
// ≤ highVolumeSafetyLevel 4.275.
|
||||||
|
function validBasinAndCfg() {
|
||||||
|
const basin = new BasinGeometry(
|
||||||
|
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||||
|
{ minHeightBasedOn: 'outlet' }
|
||||||
|
);
|
||||||
|
const levelbased = { minLevel: 1, startLevel: 2, maxLevel: 4 };
|
||||||
|
const safety = { dryRunThresholdPercent: 10, overfillThresholdPercent: 95 };
|
||||||
|
return { basin, levelbased, safety };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('valid ordering returns empty array', () => {
|
||||||
|
const { basin, levelbased, safety } = validBasinAndCfg();
|
||||||
|
const issues = validateThresholdOrdering(basin, levelbased, safety);
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outflowLevel >= inflowLevel triggers issue with correct shape', () => {
|
||||||
|
const basin = new BasinGeometry(
|
||||||
|
// outflow 3.5 > inflow 3 — invariant broken.
|
||||||
|
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 3.5, overflowLevel: 4.5 },
|
||||||
|
{ minHeightBasedOn: 'outlet' }
|
||||||
|
);
|
||||||
|
const issues = validateThresholdOrdering(basin, { minLevel: 1, startLevel: 2, maxLevel: 4 }, { dryRunThresholdPercent: 0, overfillThresholdPercent: 100 });
|
||||||
|
const hit = issues.find((i) => i.aName === 'outflowLevel' && i.bName === 'inflowLevel');
|
||||||
|
assert.ok(hit, 'expected an outflowLevel < inflowLevel issue');
|
||||||
|
assert.equal(hit.op, '<');
|
||||||
|
assert.equal(hit.a, 3.5);
|
||||||
|
assert.equal(hit.b, 3);
|
||||||
|
assert.match(hit.msg, /outflowLevel.*<.*inflowLevel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxLevel >= highVolumeSafetyLevel triggers issue', () => {
|
||||||
|
const { basin } = validBasinAndCfg();
|
||||||
|
// highVolumeSafetyLevel = overflowLevel × highPct/100 = 4.5 × 0.80 = 3.6.
|
||||||
|
// maxLevel 4 > 3.6 → expect a `maxLevel <= highVolumeSafetyLevel` issue.
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
basin,
|
||||||
|
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 80 }
|
||||||
|
);
|
||||||
|
const hit = issues.find((i) => i.aName === 'maxLevel' && i.bName === 'highVolumeSafetyLevel');
|
||||||
|
assert.ok(hit, 'expected a maxLevel <= highVolumeSafetyLevel issue');
|
||||||
|
assert.equal(hit.op, '<=');
|
||||||
|
assert.equal(hit.a, 4);
|
||||||
|
assert.ok(Math.abs(hit.b - 3.6) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NaN / undefined values are skipped, not flagged as issues', () => {
|
||||||
|
const { basin } = validBasinAndCfg();
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
basin,
|
||||||
|
{ minLevel: undefined, startLevel: NaN, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
|
||||||
|
);
|
||||||
|
// dryRunLevel <= minLevel skipped (minLevel undefined → NaN)
|
||||||
|
// minLevel <= startLevel skipped (both NaN-ish)
|
||||||
|
// startLevel < maxLevel skipped (startLevel NaN)
|
||||||
|
// maxLevel <= highVolumeSafetyLevel still checked → 4 ≤ 4.275 OK.
|
||||||
|
// Geometry checks also OK.
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple violations produce multiple issues in stable order', () => {
|
||||||
|
// Build a basin with two geometry violations.
|
||||||
|
const basin = new BasinGeometry(
|
||||||
|
// outflow 4 > inflow 3 (broken) AND overflow 6 > height 5 (broken)
|
||||||
|
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 4, overflowLevel: 6 },
|
||||||
|
{ minHeightBasedOn: 'outlet' }
|
||||||
|
);
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
basin,
|
||||||
|
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 0, overfillThresholdPercent: 100 }
|
||||||
|
);
|
||||||
|
// Expect at least the two geometry issues, in declaration order:
|
||||||
|
// outflowLevel < inflowLevel comes before overflowLevel <= basinHeight.
|
||||||
|
const idxOutflow = issues.findIndex((i) => i.aName === 'outflowLevel');
|
||||||
|
const idxOverflow = issues.findIndex((i) => i.aName === 'overflowLevel' && i.bName === 'basinHeight');
|
||||||
|
assert.ok(idxOutflow >= 0, 'expected outflowLevel issue');
|
||||||
|
assert.ok(idxOverflow >= 0, 'expected overflowLevel <= basinHeight issue');
|
||||||
|
assert.ok(idxOutflow < idxOverflow, 'issues should be in check-declaration order');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a plain basin object (duck-typed via getters)', () => {
|
||||||
|
const plainBasin = {
|
||||||
|
volEmptyBasin: 50,
|
||||||
|
heightBasin: 5,
|
||||||
|
inflowLevel: 3,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
surfaceArea: 10,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45,
|
||||||
|
minVolAtInflow: 30,
|
||||||
|
minVolAtOutflow: 2,
|
||||||
|
minVol: 2,
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
};
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
plainBasin,
|
||||||
|
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
|
||||||
|
);
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitted levelbased / safety objects are tolerated', () => {
|
||||||
|
const { basin } = validBasinAndCfg();
|
||||||
|
// No control or safety supplied → only geometry checks run; valid basin geometry → []
|
||||||
|
const issues = validateThresholdOrdering(basin, undefined, undefined);
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
104
test/integration/basic-dashboard-flow.test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
function loadDashboardFlow() {
|
||||||
|
const flowPath = path.join(__dirname, '../../examples/02-Dashboard.json');
|
||||||
|
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContextStub() {
|
||||||
|
const store = {};
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
return store[key];
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
||||||
|
const flow = loadDashboardFlow();
|
||||||
|
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, 'pumpingStation node should exist');
|
||||||
|
assert.equal(ps.type, 'pumpingStation');
|
||||||
|
assert.equal(ps.controlMode, 'levelbased');
|
||||||
|
assert.equal(ps.levelCurveType, 'linear');
|
||||||
|
assert.equal(ps.inletPipeDiameter, 0.3);
|
||||||
|
assert.equal(ps.outletPipeDiameter, 0.3);
|
||||||
|
assert.ok(parser, 'fn_status_split should exist');
|
||||||
|
assert.equal(parser.outputs, 15);
|
||||||
|
assert.equal(levelChart.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 === 'fn_status_split');
|
||||||
|
assert.ok(parser, 'fn_status_split should exist');
|
||||||
|
|
||||||
|
const func = new Function('msg', 'context', 'node', parser.func);
|
||||||
|
const context = makeContextStub();
|
||||||
|
const node = { send() {} };
|
||||||
|
|
||||||
|
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
|
||||||
|
// runtime writes without an explicit .child(), childId='default'. Mirror
|
||||||
|
// the real shape here. (See generalFunctions/src/measurements/
|
||||||
|
// MeasurementContainer.js getFlattenedOutput.)
|
||||||
|
const out = func({
|
||||||
|
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,
|
||||||
|
timeleft: 400,
|
||||||
|
},
|
||||||
|
}, context, node);
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(out));
|
||||||
|
assert.equal(out.length, 15);
|
||||||
|
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));
|
||||||
|
assert.deepEqual(out[14], { topic: 'percControl', payload: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||||
|
const flow = loadDashboardFlow();
|
||||||
|
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() {} };
|
||||||
|
|
||||||
|
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
||||||
|
const out = func({ payload: { percControl: 20 } }, context, node);
|
||||||
|
|
||||||
|
assert.equal(out[2].payload, '3.10 m');
|
||||||
|
assert.equal(out[5].payload, '20.0 %');
|
||||||
|
});
|
||||||
219
test/integration/shifted-ramp-end-to-end.test.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
|
||||||
|
// Drives a full fill→arm→drain cycle through the same code path the
|
||||||
|
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
|
||||||
|
// hold-then-ramp output behaviour.
|
||||||
|
//
|
||||||
|
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
|
||||||
|
const SURFACE_AREA = 10; // basin volume / height = 50/5
|
||||||
|
const TICK_MS = 1000; // simulate 1 s per tick
|
||||||
|
|
||||||
|
function makeConfig() {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
name: 'TestPS',
|
||||||
|
id: 'ps-e2e',
|
||||||
|
unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
flowThreshold: 1e-4,
|
||||||
|
},
|
||||||
|
functionality: {
|
||||||
|
softwareType: 'pumpingStation',
|
||||||
|
role: 'stationcontroller',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
},
|
||||||
|
basin: {
|
||||||
|
volume: 50, height: 5,
|
||||||
|
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||||
|
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
|
||||||
|
},
|
||||||
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased', 'manual']),
|
||||||
|
levelbased: {
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false, enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
|
||||||
|
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineGroups is a registry-backed getter (declareChildGetter) — inject
|
||||||
|
// the fake MGC via the real child-registration handshake so the registry
|
||||||
|
// stays the source of truth across configure() and tick().
|
||||||
|
function registerMockGroup(ps, id, demands) {
|
||||||
|
const mock = {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
|
||||||
|
asset: { category: 'controller' },
|
||||||
|
},
|
||||||
|
measurements: {
|
||||||
|
emitter: { on: () => {} },
|
||||||
|
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||||
|
},
|
||||||
|
handleInput: async (_src, d) => { demands.push(d); },
|
||||||
|
turnOffAllMachines: () => {},
|
||||||
|
};
|
||||||
|
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a PS with a fake MGC that captures every demand sent to it,
|
||||||
|
// and a clock we control so _updatePredictedVolume integrates over a
|
||||||
|
// known dt regardless of wall-clock.
|
||||||
|
function buildHarness() {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const demands = [];
|
||||||
|
registerMockGroup(ps, 'mgc1', demands);
|
||||||
|
// Seed level at startLevel so the run begins idle.
|
||||||
|
ps.calibratePredictedLevel(2.0);
|
||||||
|
// Override Date.now via a controllable clock that advances `step()`.
|
||||||
|
let now = ps._predictedFlowState.lastTimestamp || 0;
|
||||||
|
ps._fakeNow = () => now;
|
||||||
|
ps._fakeAdvance = (ms) => { now += ms; };
|
||||||
|
// Patch global Date.now JUST inside the scope of these tests.
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = ps._fakeNow;
|
||||||
|
// Restore on completion.
|
||||||
|
ps._restore = () => { Date.now = realNow; };
|
||||||
|
return { ps, demands };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function step(ps, qIn, qOut) {
|
||||||
|
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
|
||||||
|
// topic handlers in nodeClass.js), advance time, then tick once.
|
||||||
|
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
|
||||||
|
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
|
||||||
|
ps._fakeAdvance(TICK_MS);
|
||||||
|
ps.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelOf(ps) {
|
||||||
|
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
|
||||||
|
const { ps } = buildHarness();
|
||||||
|
try {
|
||||||
|
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
|
||||||
|
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
|
||||||
|
// 0.05/SURFACE_AREA = 0.005 m per second.
|
||||||
|
let armedAt = null;
|
||||||
|
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
|
||||||
|
await step(ps, 0.05, 0);
|
||||||
|
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
|
||||||
|
}
|
||||||
|
assert.ok(armedAt, 'shift should arm during fill');
|
||||||
|
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
|
||||||
|
// jitter for time-discretization.
|
||||||
|
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
|
||||||
|
`expected arm near level=3.8, got ${armedAt.level}`);
|
||||||
|
assert.ok(armedAt.pct >= 80 - 1e-6,
|
||||||
|
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
|
||||||
|
|
||||||
|
// While still filling and armed, output should track the up curve
|
||||||
|
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
|
||||||
|
const fillingPct = ps.percControl;
|
||||||
|
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
|
||||||
|
`filling-armed output should still be on up curve, got ${fillingPct}`);
|
||||||
|
// No hold captured yet (still filling).
|
||||||
|
assert.equal(ps._shiftHoldValue, null);
|
||||||
|
|
||||||
|
// ─── PHASE B: flip to draining ─────────────────────────────────────
|
||||||
|
// First drain tick captures the hold. We need direction='draining' as
|
||||||
|
// determined by _selectBestNetFlow → so q_in - q_out must be negative
|
||||||
|
// by more than the dead-band (1e-4).
|
||||||
|
await step(ps, 0, 0.05); // net = -0.05
|
||||||
|
assert.equal(ps.state.direction, 'draining');
|
||||||
|
// Hold captured = up curve at the level when direction flipped. The
|
||||||
|
// captured value is recorded BEFORE this drain tick lowered the level
|
||||||
|
// further, so it should match the last filling tick's output (within
|
||||||
|
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
|
||||||
|
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
|
||||||
|
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
|
||||||
|
const hold = ps._shiftHoldValue;
|
||||||
|
|
||||||
|
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
|
||||||
|
// Drain until level just above shiftLevel=3.5. Output stays = hold.
|
||||||
|
let held = true;
|
||||||
|
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
|
||||||
|
await step(ps, 0, 0.05);
|
||||||
|
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
|
||||||
|
}
|
||||||
|
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
|
||||||
|
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
|
||||||
|
`still expected hold=${hold}, got ${ps.percControl}`);
|
||||||
|
|
||||||
|
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
|
||||||
|
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
|
||||||
|
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
|
||||||
|
const justBelow = ps.percControl;
|
||||||
|
assert.ok(justBelow < hold,
|
||||||
|
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
|
||||||
|
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
|
||||||
|
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
|
||||||
|
const mid = ps.percControl;
|
||||||
|
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
|
||||||
|
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
|
||||||
|
|
||||||
|
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
|
||||||
|
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
|
||||||
|
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
|
||||||
|
assert.equal(ps._shiftHoldValue, null);
|
||||||
|
assert.equal(ps.percControl, 0);
|
||||||
|
} finally {
|
||||||
|
ps._restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
|
||||||
|
const { ps } = buildHarness();
|
||||||
|
try {
|
||||||
|
// Fill to arm + some headroom.
|
||||||
|
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
|
||||||
|
assert.equal(ps._shiftArmed, true);
|
||||||
|
|
||||||
|
// First drain transition → hold #1.
|
||||||
|
await step(ps, 0, 0.05);
|
||||||
|
const hold1 = ps._shiftHoldValue;
|
||||||
|
assert.ok(hold1 >= 80 - 1e-6);
|
||||||
|
|
||||||
|
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
|
||||||
|
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
|
||||||
|
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
|
||||||
|
|
||||||
|
// Flip back to filling at higher rate; up curve resumes; hold cleared.
|
||||||
|
await step(ps, 0.05, 0);
|
||||||
|
assert.equal(ps._shiftHoldValue, null);
|
||||||
|
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
|
||||||
|
|
||||||
|
// Fill higher than before (output goes higher).
|
||||||
|
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
|
||||||
|
const fillingPct = ps.percControl;
|
||||||
|
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
|
||||||
|
|
||||||
|
// Drain again → fresh hold #2 = current up curve %.
|
||||||
|
await step(ps, 0, 0.05);
|
||||||
|
const hold2 = ps._shiftHoldValue;
|
||||||
|
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
|
||||||
|
} finally {
|
||||||
|
ps._restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for pumpingStation specificClass (domain logic).
|
|
||||||
*
|
|
||||||
* The pumpingStation class manages a basin (wet well):
|
|
||||||
* - initBasinProperties: derives surface area, volumes from config
|
|
||||||
* - _calcVolumeFromLevel / _calcLevelFromVolume: linear geometry
|
|
||||||
* - _calcDirection: filling / draining / stable from flow diff
|
|
||||||
* - _callMeasurementHandler: dispatches to type-specific handlers
|
|
||||||
* - getOutput: builds an output snapshot
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PumpingStation = require('../src/specificClass');
|
|
||||||
|
|
||||||
// --------------- helpers ---------------
|
|
||||||
|
|
||||||
function makeConfig(overrides = {}) {
|
|
||||||
const base = {
|
|
||||||
general: {
|
|
||||||
name: 'TestStation',
|
|
||||||
id: 'ps-test-1',
|
|
||||||
unit: 'm3/h',
|
|
||||||
logging: { enabled: false, logLevel: 'error' },
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: 'pumpingStation',
|
|
||||||
role: 'stationcontroller',
|
|
||||||
positionVsParent: 'atEquipment',
|
|
||||||
},
|
|
||||||
basin: {
|
|
||||||
volume: 50, // m3 (empty basin volume)
|
|
||||||
height: 5, // m
|
|
||||||
heightInlet: 0.3, // m
|
|
||||||
heightOutlet: 0.2, // m
|
|
||||||
heightOverflow: 4.0, // m
|
|
||||||
},
|
|
||||||
hydraulics: {
|
|
||||||
refHeight: 'NAP',
|
|
||||||
basinBottomRef: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of Object.keys(overrides)) {
|
|
||||||
if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) {
|
|
||||||
base[key] = { ...base[key], ...overrides[key] };
|
|
||||||
} else {
|
|
||||||
base[key] = overrides[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- tests ---------------
|
|
||||||
|
|
||||||
describe('pumpingStation specificClass', () => {
|
|
||||||
|
|
||||||
describe('constructor / initialization', () => {
|
|
||||||
it('should create an instance with the given config', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps).toBeDefined();
|
|
||||||
expect(ps.config.general.name).toBe('teststation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize state object with default values', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.state).toEqual({ direction: '', netDownstream: 0, netUpstream: 0, seconds: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize empty machines, stations, child, parent objects', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.machines).toEqual({});
|
|
||||||
expect(ps.stations).toEqual({});
|
|
||||||
expect(ps.child).toEqual({});
|
|
||||||
expect(ps.parent).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initBasinProperties()', () => {
|
|
||||||
it('should calculate surfaceArea = volume / height', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 50 / 5 = 10 m2
|
|
||||||
expect(ps.basin.surfaceArea).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate maxVol = height * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 5 * 10 = 50
|
|
||||||
expect(ps.basin.maxVol).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate maxVolOverflow = heightOverflow * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 4.0 * 10 = 40
|
|
||||||
expect(ps.basin.maxVolOverflow).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate minVol = heightOutlet * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 0.2 * 10 = 2
|
|
||||||
expect(ps.basin.minVol).toBeCloseTo(2, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate minVolOut = heightInlet * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 0.3 * 10 = 3
|
|
||||||
expect(ps.basin.minVolOut).toBeCloseTo(3, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store the raw config values on basin', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.basin.volEmptyBasin).toBe(50);
|
|
||||||
expect(ps.basin.heightBasin).toBe(5);
|
|
||||||
expect(ps.basin.heightInlet).toBe(0.3);
|
|
||||||
expect(ps.basin.heightOutlet).toBe(0.2);
|
|
||||||
expect(ps.basin.heightOverflow).toBe(4.0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcVolumeFromLevel()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return level * surfaceArea', () => {
|
|
||||||
// surfaceArea = 10, level = 2 => 20
|
|
||||||
expect(ps._calcVolumeFromLevel(2)).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for level = 0', () => {
|
|
||||||
expect(ps._calcVolumeFromLevel(0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp negative levels to 0', () => {
|
|
||||||
expect(ps._calcVolumeFromLevel(-3)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcLevelFromVolume()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return volume / surfaceArea', () => {
|
|
||||||
// surfaceArea = 10, vol = 20 => 2
|
|
||||||
expect(ps._calcLevelFromVolume(20)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for volume = 0', () => {
|
|
||||||
expect(ps._calcLevelFromVolume(0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp negative volumes to 0', () => {
|
|
||||||
expect(ps._calcLevelFromVolume(-10)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('volume/level roundtrip', () => {
|
|
||||||
it('should roundtrip level -> volume -> level', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const level = 2.7;
|
|
||||||
const vol = ps._calcVolumeFromLevel(level);
|
|
||||||
const levelBack = ps._calcLevelFromVolume(vol);
|
|
||||||
expect(levelBack).toBeCloseTo(level, 10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcDirection()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return "filling" for positive flow above threshold', () => {
|
|
||||||
expect(ps._calcDirection(0.01)).toBe('filling');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "draining" for negative flow below negative threshold', () => {
|
|
||||||
expect(ps._calcDirection(-0.01)).toBe('draining');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "stable" for flow near zero (within threshold)', () => {
|
|
||||||
expect(ps._calcDirection(0.0005)).toBe('stable');
|
|
||||||
expect(ps._calcDirection(-0.0005)).toBe('stable');
|
|
||||||
expect(ps._calcDirection(0)).toBe('stable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_callMeasurementHandler()', () => {
|
|
||||||
it('should not throw for flow and temperature measurement types', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// flow and temperature handlers are empty stubs, safe to call
|
|
||||||
expect(() => ps._callMeasurementHandler('flow', 0.5, 'downstream', {})).not.toThrow();
|
|
||||||
expect(() => ps._callMeasurementHandler('temperature', 15, 'atEquipment', {})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should dispatch to the correct handler based on measurement type', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// Verify the switch dispatches by checking it does not warn for known types
|
|
||||||
// pressure handler stores values and attempts coolprop calculation
|
|
||||||
// level handler stores values and computes volume
|
|
||||||
// We verify the dispatch logic by calling with type and checking no unhandled error
|
|
||||||
const spy = jest.spyOn(ps, 'updateMeasuredFlow');
|
|
||||||
ps._callMeasurementHandler('flow', 0.5, 'downstream', {});
|
|
||||||
expect(spy).toHaveBeenCalledWith(0.5, 'downstream', {});
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOutput()', () => {
|
|
||||||
it('should return an object containing state and basin', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const out = ps.getOutput();
|
|
||||||
expect(out).toHaveProperty('state');
|
|
||||||
expect(out).toHaveProperty('basin');
|
|
||||||
expect(out.state).toBe(ps.state);
|
|
||||||
expect(out.basin).toBe(ps.basin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include measurement keys in the output', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const out = ps.getOutput();
|
|
||||||
// After initialization the predicted volume is set
|
|
||||||
expect(typeof out).toBe('object');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcRemainingTime()', () => {
|
|
||||||
it('should not throw when called with a level and variant', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// Should not throw even with no measurement data; it will just find null diffs
|
|
||||||
expect(() => ps._calcRemainingTime(2, 'predicted')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('tick()', () => {
|
|
||||||
it('should call _updateVolumePrediction and _calcNetFlow', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const spyVol = jest.spyOn(ps, '_updateVolumePrediction');
|
|
||||||
const spyNet = jest.spyOn(ps, '_calcNetFlow');
|
|
||||||
// stub _calcRemainingTime to avoid needing full measurement data
|
|
||||||
ps._calcRemainingTime = jest.fn();
|
|
||||||
ps.tick();
|
|
||||||
expect(spyVol).toHaveBeenCalledWith('out');
|
|
||||||
expect(spyVol).toHaveBeenCalledWith('in');
|
|
||||||
expect(spyNet).toHaveBeenCalled();
|
|
||||||
spyVol.mockRestore();
|
|
||||||
spyNet.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle basin with zero height gracefully', () => {
|
|
||||||
// surfaceArea = volume / height => division by 0 gives Infinity
|
|
||||||
const config = makeConfig({ basin: { volume: 50, height: 0, heightInlet: 0, heightOutlet: 0, heightOverflow: 0 } });
|
|
||||||
const ps = new PumpingStation(config);
|
|
||||||
expect(ps.basin.surfaceArea).toBe(Infinity);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle basin with very small dimensions', () => {
|
|
||||||
const config = makeConfig({ basin: { volume: 0.001, height: 0.001, heightInlet: 0, heightOutlet: 0, heightOverflow: 0.0005 } });
|
|
||||||
const ps = new PumpingStation(config);
|
|
||||||
expect(ps.basin.surfaceArea).toBeCloseTo(1, 5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
131
wiki/Home.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# pumpingStation
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
A `pumpingStation` models a wet-well lift station: one basin with sensors, and one or more pumps that move water against an elevation difference. It integrates basin volume each tick, picks a control mode (level-based by default), and sends a demand setpoint to its pumps so the basin level stays inside its safe operating band.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## At a glance
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|:---|:---|
|
||||||
|
| What it represents | A wet-well lift station: a basin + N pumps |
|
||||||
|
| S88 level | Process Cell |
|
||||||
|
| Use it when | You need to lift water from a low point to a higher one, with sensors driving demand |
|
||||||
|
| Don't use it for | Pressurised distribution networks (use a pumpingStation cascade or VGC instead), or a single pump with no basin (parent a `rotatingMachine` directly) |
|
||||||
|
| Children it accepts | `measurement`, `machine`, `machinegroup`, `pumpingstation` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it looks in Node-RED
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it models
|
||||||
|
|
||||||
|
A rectangular basin with measured inflow, measured (or pump-summed) outflow, and a level sensor. The diagram below is the live source; open it in [draw.io](https://app.diagrams.net/) to edit.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The basin has five horizontal reference lines that matter to the controller:
|
||||||
|
|
||||||
|
| Line | Role |
|
||||||
|
|:---|:---|
|
||||||
|
| `overflowLevel` | Physical weir crest. Above this level the basin is spilling. |
|
||||||
|
| `maxLevel` | Demand saturates at 100 % at or above this level. |
|
||||||
|
| `startLevel` | Falling-ramp returns to 0 % demand here; deadband upper bound. |
|
||||||
|
| `minLevel` | Below this level the controller commands all pumps off. |
|
||||||
|
| `dryRunLevel` | Pump-protection cutoff (safety layer, mode-independent). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Try it — 3-minute demo
|
||||||
|
|
||||||
|
Import the basic example flow, deploy, and watch the basin react to inject buttons.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \
|
||||||
|
http://localhost:1880/flow
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
What to click in the dashboard after deploy:
|
||||||
|
|
||||||
|
1. `set.mode = levelbased` → the controller switches to level-based mode.
|
||||||
|
2. `set.inflow = 60 m³/h` → inflow is now feeding the basin.
|
||||||
|
3. `cmd.calibrate.level = 1.5 m` → the volume integrator syncs to a known level.
|
||||||
|
4. Watch Port 0 in the debug pane: level rises, predicted volume integrates, demand follows the curve.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of the basic flow reacting to mode + inflow clicks. Save as `wiki/_partial-gifs/pumpingStation/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typical wiring
|
||||||
|
|
||||||
|
The two patterns you'll see most.
|
||||||
|
|
||||||
|
### Standalone (`01-Basic.json`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### With a measurement child and an MGC parent
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The five things you'll send
|
||||||
|
|
||||||
|
| Topic | Payload | What it does |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `set.mode` | `"levelbased"` or `"manual"` | Switches control strategy. Manual exposes `set.demand` as the direct setpoint. |
|
||||||
|
| `set.demand` | number, m³/h | Operator outflow setpoint. Honoured in `manual` mode. |
|
||||||
|
| `set.inflow` | number, m³/h | Push a measured inflow into the basin balance (if you don't have a `measurement` child for inflow). |
|
||||||
|
| `cmd.calibrate.level` | number, m | Sync the volume integrator to a known level reading. Useful at startup. |
|
||||||
|
| `cmd.calibrate.volume` | number, m³ | Sync the volume integrator to a known volume reading. |
|
||||||
|
|
||||||
|
## What you'll see come out
|
||||||
|
|
||||||
|
Sample Port 0 message (delta-compressed — only changed fields each tick):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "pumpingStation#PS1",
|
||||||
|
"payload": {
|
||||||
|
"level": 1.62,
|
||||||
|
"volume": 32.4,
|
||||||
|
"direction": "filling",
|
||||||
|
"demand": 38,
|
||||||
|
"safety": { "blocked": false },
|
||||||
|
"etaSeconds": 412
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|:---|:---|
|
||||||
|
| `level` | Current basin level (m). Measured if a level `measurement` is registered; predicted otherwise. |
|
||||||
|
| `volume` | Integrated predicted volume (m³). |
|
||||||
|
| `direction` | `filling` / `draining` / `steady` based on the flow dead-band. |
|
||||||
|
| `demand` | What the station is asking its pumps to do (0–100 %). |
|
||||||
|
| `safety.blocked` | True when the safety layer is overriding the control loop. |
|
||||||
|
| `etaSeconds` | Predicted time to full (if filling) or empty (if draining). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need more?
|
||||||
|
|
||||||
|
| Page | What you'll find |
|
||||||
|
|:---|:---|
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart, lifecycle sequence, output ports |
|
||||||
|
| [Reference — Examples](Reference-Examples) | All shipped example flows + Docker compose snippet + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | When not to use this node, known limitations, open questions |
|
||||||
|
|
||||||
|
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
158
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Reference — Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Code structure for `pumpingStation`: the three-tier sandwich, the `src/` layout, the FSM, the lifecycle, and the output-port pipeline. Everything here is reproducible from `src/`. For an intuitive overview, return to [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three-tier code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes/pumpingStation/
|
||||||
|
|
|
||||||
|
+-- pumpingStation.js entry: RED.nodes.registerType('pumpingstation', NodeClass)
|
||||||
|
|
|
||||||
|
+-- src/
|
||||||
|
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
|
||||||
|
| specificClass.js extends BaseDomain (orchestration only)
|
||||||
|
| |
|
||||||
|
| +-- commands/
|
||||||
|
| | index.js topic descriptors
|
||||||
|
| | handlers.js pure handler functions
|
||||||
|
| |
|
||||||
|
| +-- basin/
|
||||||
|
| | BasinGeometry.js basin shape, level <-> volume conversion
|
||||||
|
| | thresholdValidator.js derives + validates safety / control thresholds
|
||||||
|
| |
|
||||||
|
| +-- measurement/
|
||||||
|
| | flowAggregator.js net-flow + predicted-volume integrator
|
||||||
|
| | measurementRouter.js routes measurement-child events
|
||||||
|
| | calibration.js calibrate-to-known-level / volume helpers
|
||||||
|
| |
|
||||||
|
| +-- control/
|
||||||
|
| | index.js mode dispatcher (levelbased, manual, ...)
|
||||||
|
| |
|
||||||
|
| +-- safety/
|
||||||
|
| safetyController.js dry-run + high-volume + panic guards
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier responsibilities
|
||||||
|
|
||||||
|
| Tier | File | What it owns | Touches `RED.*` |
|
||||||
|
|:---|:---|:---|:---:|
|
||||||
|
| entry | `pumpingStation.js` | Type registration | Yes |
|
||||||
|
| nodeClass | `src/nodeClass.js` | Input routing, tick loop, output ports, status badge | Yes |
|
||||||
|
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; run them in `tick()`; nothing more | No |
|
||||||
|
|
||||||
|
The specificClass is stitching, not implementation. All real work lives in `basin/`, `measurement/`, `control/`, `safety/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State chart — safety controller
|
||||||
|
|
||||||
|
The pumpingStation does not have a per-mode FSM (control modes are stateless transfer functions). The state machine that matters is the **safety controller**, which can block or pass control commands.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> running
|
||||||
|
running --> blocked_dryrun: level < dryRunLevel
|
||||||
|
running --> blocked_highvolume: level >= highVolumeSafetyLevel
|
||||||
|
running --> blocked_panic: no-data panic timer expires
|
||||||
|
blocked_dryrun --> running: level recovers above hysteresis
|
||||||
|
blocked_highvolume --> running: level falls below hysteresis
|
||||||
|
blocked_panic --> running: data resumes
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `blocked_*` state sets `safety.blocked = true` on Port 0 and prevents the control layer from emitting a non-zero demand. The hysteresis is mode-independent and lives in `src/safety/safetyController.js`.
|
||||||
|
|
||||||
|
### Safety-rules asymmetry
|
||||||
|
|
||||||
|
The `dryRunLevel` and `highVolumeSafetyLevel` rules differ in **which children they stop**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Rule | What stops | Why |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| Dry run | All children (pumps off) | Pumps cavitate without water; protect the equipment |
|
||||||
|
| High volume | Only outflow-side pumps | Spill is the lesser evil; some pumps may still serve safety functions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lifecycle — one tick
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant tick as 1s tick
|
||||||
|
participant sc as specificClass.tick()
|
||||||
|
participant fa as flowAggregator
|
||||||
|
participant safe as safetyController
|
||||||
|
participant ctrl as control[mode]
|
||||||
|
participant out as Port 0 / 1
|
||||||
|
|
||||||
|
tick->>sc: tick()
|
||||||
|
sc->>fa: update predicted volume
|
||||||
|
fa->>fa: pick best net-flow source (measured / aggregated)
|
||||||
|
sc->>safe: evaluate
|
||||||
|
alt safety blocked
|
||||||
|
safe-->>sc: { blocked: true }
|
||||||
|
Note over sc: skip control layer
|
||||||
|
else safe to run
|
||||||
|
sc->>ctrl: strategies[mode].run(context)
|
||||||
|
ctrl-->>sc: demand 0..100
|
||||||
|
end
|
||||||
|
sc->>out: getOutput() — emit Port 0 + Port 1 deltas
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tick is 1 Hz. The output pipeline (Port 0 + Port 1) is driven by `outputUtils.formatMsg` — only changed fields are sent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output ports
|
||||||
|
|
||||||
|
| Port | Carries | Sample shape |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| 0 (process) | Delta-compressed state snapshot consumed by downstream Node-RED logic | `{topic, payload: {level, volume, demand, direction, safety, etaSeconds}}` |
|
||||||
|
| 1 (telemetry) | InfluxDB line-protocol string with the same fields as Port 0 | `pumpingStation,id=PS1 level=1.62,volume=32.4 ...` |
|
||||||
|
| 2 (register / control) | `child.register` upward at init; internal control plumbing later | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
|
||||||
|
|
||||||
|
See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tick timing and event sources
|
||||||
|
|
||||||
|
| Source | Where it fires | What it triggers |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `setInterval(1000)` | `BaseNodeAdapter` lifecycle | `specificClass.tick()` — the per-second integrator update |
|
||||||
|
| `measurement` emitter event | Child node's `emitter.emit(<type>.measured.<position>, ...)` | `measurementRouter` updates the basin balance |
|
||||||
|
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to a handler |
|
||||||
|
| `child.register` from another node | Port 2 of a child | `_subscribeMeasurement` or `_subscribePredictedFlow` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
| If you're changing... | Read first |
|
||||||
|
|:---|:---|
|
||||||
|
| Basin geometry, level/volume conversion | `src/basin/BasinGeometry.js`, `src/basin/thresholdValidator.js` |
|
||||||
|
| Net-flow selection, predicted-volume integration | `src/measurement/flowAggregator.js` |
|
||||||
|
| Calibration commands | `src/measurement/calibration.js` |
|
||||||
|
| Control modes (level-based, manual, future modes) | `src/control/index.js` |
|
||||||
|
| Safety blocks | `src/safety/safetyController.js` |
|
||||||
|
| Topic dispatch | `src/commands/index.js` + `src/commands/handlers.js` |
|
||||||
|
| Adapter, ticking, output ports | `src/nodeClass.js` (and `BaseNodeAdapter` in `generalFunctions`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped example flows |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known limitations and open questions |
|
||||||
|
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||||
263
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Reference — Contracts
|
||||||
|
|
||||||
|
 
|
||||||
|
|
||||||
|
> [!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`** — do not hand-edit between the `BEGIN AUTOGEN` / `END AUTOGEN` markers. Source of truth for everything on this page: the node's `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/pumpingStation.json`.
|
||||||
|
>
|
||||||
|
> For an intuitive overview, return to the [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic contract
|
||||||
|
|
||||||
|
The **Unit** column reflects each descriptor's declared unit (via the `unit: 'm3/h'` shorthand or the legacy `units: { measure, default }`; the measure is derived from the unit). The default unit is what the commandRegistry coerces incoming values to before the handler runs.
|
||||||
|
|
||||||
|
**Command envelope (all EVOLV nodes).** Every command shares one envelope on top of `msg.topic`:
|
||||||
|
- **Value + unit** — send `msg.payload` as a number (with optional sibling `msg.unit`) **or** as `{ value, unit }`. The registry always converts the value to the descriptor's unit before the handler; numeric strings are converted too. A missing unit assumes the descriptor default.
|
||||||
|
- **`msg.origin`** — the control authority that issued the command: `parent` (automation/parent controller, the default), `GUI` (SCADA/HMI operator), or `fysical` (physical buttons). On nodes with a control mode, the mode's `allowedSources` decides which origins are accepted; releasing control is done by changing the mode.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
|
||||||
|
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
|
||||||
|
| `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
|
||||||
|
| `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
|
||||||
|
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
### 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 — 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 — `getOutput()` shape
|
||||||
|
|
||||||
|
Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
| Key | Type | Unit | Sample |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `direction` | string | — | `"steady"` |
|
||||||
|
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
||||||
|
| `dryRunSafetyVol` | number | — | `2.55` |
|
||||||
|
| `flowSource` | null | — | `null` |
|
||||||
|
| `heightBasin` | number | m | `4` |
|
||||||
|
| `highVolumeSafetyLevel` | number | — | `3.7239999999999998` |
|
||||||
|
| `highVolumeSafetyVol` | number | — | `46.55` |
|
||||||
|
| `inflowLevel` | number | m | `1.5` |
|
||||||
|
| `inletPipeDiameter` | number | — | `0.4` |
|
||||||
|
| `manualDemand` | null | — | `null` |
|
||||||
|
| `maxVol` | number | m3 | `50` |
|
||||||
|
| `maxVolAtOverflow` | number | m3 | `47.5` |
|
||||||
|
| `minHeightBasedOn` | string | — | `"outlet"` |
|
||||||
|
| `minVol` | number | m3 | `2.5` |
|
||||||
|
| `minVolAtInflow` | number | m3 | `18.75` |
|
||||||
|
| `minVolAtOutflow` | number | m3 | `2.5` |
|
||||||
|
| `mode` | string | — | `"levelbased"` |
|
||||||
|
| `outflowLevel` | number | m | `0.2` |
|
||||||
|
| `outletPipeDiameter` | number | — | `0.4` |
|
||||||
|
| `overflowLevel` | number | m | `3.8` |
|
||||||
|
| `percControl` | number | % | `0` |
|
||||||
|
| `predictedOverflowRate` | number | — | `0` |
|
||||||
|
| `predictedOverflowVolume` | number | — | `0` |
|
||||||
|
| `predictedUnderflowVolume` | number | — | `0` |
|
||||||
|
| `surfaceArea` | number | m2 | `12.5` |
|
||||||
|
| `timeleft` | null | s | `null` |
|
||||||
|
| `volEmptyBasin` | number | m3 | `50` |
|
||||||
|
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `2.5` |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
Sample values come from a stub instantiation in `wikiGen` — 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` — string, the active control strategy (`levelbased` / `manual` / `flowbased` / `none`). Echoes the most recent `set.mode` input.
|
||||||
|
> - `manualDemand` — 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 — 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 — editor form to config keys
|
||||||
|
|
||||||
|
Source of truth: `generalFunctions/src/configs/pumpingStation.json`.
|
||||||
|
|
||||||
|
### Basin geometry (`config.basin`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Unit | Notes |
|
||||||
|
|:---|:---|:---|:---|:---|
|
||||||
|
| Basin Volume | `basin.volume` | `1` | m3 | Total geometric storage from floor to rim |
|
||||||
|
| Basin Height | `basin.height` | `1` | m | Floor-to-rim wall height |
|
||||||
|
| Inlet Elevation | `basin.inflowLevel` | `2` | m | Bottom of incoming pipe, from floor |
|
||||||
|
| Outlet Elevation | `basin.outflowLevel` | `0.2` | m | Top of pump-suction pipe, from floor |
|
||||||
|
| Inlet Pipe Diameter | `basin.inletPipeDiameter` | `0.4` | m | For future hydraulic upgrades |
|
||||||
|
| Outlet Pipe Diameter | `basin.outletPipeDiameter` | `0.4` | m | For future hydraulic upgrades |
|
||||||
|
| Overflow Level | `basin.overflowLevel` | `2.5` | m | Physical overflow weir crest |
|
||||||
|
|
||||||
|
### Safety thresholds (`config.safety`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| High-Volume Safety % | `safety.highVolumeSafetyThresholdPercent` | `98` | Trigger high-volume safety at this fill % |
|
||||||
|
| Dry-Run Safety Level | `safety.dryRunLevel` | `0.2` | Below this level all pumps stop |
|
||||||
|
| Enable High-Volume Safety | `safety.enableHighVolumeSafety` | `true` | Master switch |
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Earlier versions used `enableOverfillProtection` and `overfillThresholdPercent`. Those names are deprecated. The current canonical names are `enableHighVolumeSafety` and `highVolumeSafetyThresholdPercent`. See `.claude/refactor/OPEN_QUESTIONS.md` for the alias-removal timeline.
|
||||||
|
|
||||||
|
### Control mode (`config.control`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Mode | `control.mode` | `"levelbased"` | One of `levelbased`, `manual`, `flowbased`*, `pressureBased`*, `percentageBased`*, `powerBased`*, `hybrid`*. Asterisked modes are placeholders in code. |
|
||||||
|
| Level Curve Type | `control.levelbased.curveType` | `"linear"` | `linear` or `log` |
|
||||||
|
| Log Curve Factor | `control.levelbased.logCurveFactor` | `0.5` | Slope tuning for log curve |
|
||||||
|
| Min Level | `control.levelbased.minLevel` | `0.3` | Demand hard-zero below this |
|
||||||
|
| Start Level | `control.levelbased.startLevel` | `0.5` | Falling-ramp returns to 0 % here |
|
||||||
|
| Stop Level | `control.levelbased.stopLevel` | `0.4` | Schmitt-trigger lower bound for pump-count keep-alive |
|
||||||
|
| Max Level | `control.levelbased.maxLevel` | `2.3` | Demand saturates at 100 % here |
|
||||||
|
| Enable Shifted Ramp | `control.levelbased.enableShiftedRamp` | `true` | Hysteresis-armed shift between rising / falling ramps |
|
||||||
|
| Manual Flow Setpoint | `control.manual.flowSetpoint` | `0` | Honoured in `manual` mode |
|
||||||
|
|
||||||
|
### General (`config.general`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Time-left full / empty threshold | `general.timeleftToFullOrEmptyThresholdSeconds` | `120` | ETA below this triggers warning state |
|
||||||
|
| Flow dead-band | `general.flowThreshold` | `1e-4` m³/s | Net-flow below this is treated as steady |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Child registration
|
||||||
|
|
||||||
|
Source: `nodes/pumpingStation/src/specificClass.js` `configure()`, lines 107–116.
|
||||||
|
|
||||||
|
| Software type | Filter | Wired to | Side-effect |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `measurement` | any | `_subscribeMeasurement` | Subscribes to the measurement's emitter; updates basin balance |
|
||||||
|
| `machine` | only if no `machinegroup` parent is present | direct dispatch | Bypassed when an MGC is the predicted-flow source |
|
||||||
|
| `machinegroup` | any | `_subscribePredictedFlow` | Reads aggregated predicted flow from the MGC |
|
||||||
|
| `pumpingstation` | any | `_subscribePredictedFlow` | Cascaded PS — reads predicted outflow of upstream station |
|
||||||
|
|
||||||
|
The router only subscribes to the **highest-level aggregator** for predicted flow. If an MGC is present, direct `machine` children are not double-counted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit policy
|
||||||
|
|
||||||
|
Source: `nodes/pumpingStation/src/specificClass.js` lines 21–30.
|
||||||
|
|
||||||
|
| Quantity | Canonical (internal) | Output (rendered) |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| Flow | `m3/s` | `m3/s` (also `netFlowRate`) |
|
||||||
|
| Level | `m` | `m` |
|
||||||
|
| Volume | `m3` | `m3` |
|
||||||
|
| Pressure | `Pa` | (not surfaced) |
|
||||||
|
| Power | `W` | (not surfaced) |
|
||||||
|
| Temperature | `K` | (not surfaced) |
|
||||||
|
|
||||||
|
`overflowVolume` and `underflowVolume` are explicitly listed in the policy output so the `MeasurementContainer` keeps the integrator's `m3` unit on those streams (`FlowAggregator` writes spill / underflow per tick).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart, lifecycle |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped example flows |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known limitations and open questions |
|
||||||
|
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||||
|
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |
|
||||||
147
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Reference — Examples
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Every example flow shipped under `nodes/pumpingStation/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/pumpingStation/examples/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped examples
|
||||||
|
|
||||||
|
| File | Tier | What it shows |
|
||||||
|
|:---|:---:|:---|
|
||||||
|
| `examples/01-Basic.json` | 1 | Single pumpingStation driven by inject nodes — no parent, no dashboard. Numbered driver groups for Mode / Flow signals / Operator demand / Calibration. |
|
||||||
|
| `examples/02-Dashboard.json` | 2 | Same command surface as Basic, driven by a FlowFuse Dashboard 2.0 page (Controls + live Status rows + 4 trend charts + raw-output table). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading a flow
|
||||||
|
|
||||||
|
### Via the editor
|
||||||
|
|
||||||
|
1. Open the Node-RED editor at `http://localhost:1880`.
|
||||||
|
2. Menu → Import.
|
||||||
|
3. Drag-and-drop the JSON file, or paste its contents.
|
||||||
|
4. Click Deploy.
|
||||||
|
|
||||||
|
### Via the Admin API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/pumpingStation/examples/01-Basic.json \
|
||||||
|
http://localhost:1880/flow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example 01 — Basic standalone
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> What `pumpingStation` does not do, current rough edges, and open questions tracked against the refactor. Live source for the open items: `.claude/refactor/OPEN_QUESTIONS.md` in the EVOLV superproject.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When you would not use this node
|
||||||
|
|
||||||
|
| Scenario | Use instead |
|
||||||
|
|:---|:---|
|
||||||
|
| Pressurised distribution network without a basin | Cascade pumpingStations, or a `valveGroupControl` parented to a flow source |
|
||||||
|
| Single pump, no basin, no level sensor | Parent a `rotatingMachine` directly under a UI driver |
|
||||||
|
| Air manifold (compressor + valves) | A future `compressorStation` — not implemented |
|
||||||
|
| Open-channel flow without a wet-well | Out of scope for the current basin model (rectangular prismatic only) |
|
||||||
|
| Sludge thickening basin | Use a `settler` — different settling-velocity model required |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
### Implemented modes vs schema modes
|
||||||
|
|
||||||
|
The schema's `control.mode` enum lists eight modes, but only two are implemented in code:
|
||||||
|
|
||||||
|
| Mode | Status | Notes |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `levelbased` | Implemented | Default; the most production-tested path |
|
||||||
|
| `manual` | Implemented | Operator's `set.demand` is forwarded unchanged |
|
||||||
|
| `flowbased` | Placeholder | Schema accepts it; runtime falls back to levelbased |
|
||||||
|
| `pressureBased` | Placeholder | Same as above |
|
||||||
|
| `percentageBased` | Placeholder | Same as above |
|
||||||
|
| `powerBased` | Placeholder | Same as above |
|
||||||
|
| `hybrid` | Placeholder | Same as above |
|
||||||
|
| `mpc` | Not in code | Reserved name |
|
||||||
|
|
||||||
|
If you select an unimplemented mode in the editor, the basin runs but the controller stays in level-based. Tracked.
|
||||||
|
|
||||||
|
### Basin shape
|
||||||
|
|
||||||
|
Only rectangular prismatic basins are supported. Cylindrical, frusto-conical, or stepped basins would need a new `BasinGeometry` implementation. The `volume = level * surfaceArea` relationship is hard-wired.
|
||||||
|
|
||||||
|
### Net-flow source selection
|
||||||
|
|
||||||
|
When both an MGC parent and direct rotatingMachine children are wired, the station subscribes only to the MGC's predicted flow. If you intentionally have MGC + extra individual pumps, the extras are invisible to the volume integrator. The router protects against double-counting but does not warn about this edge case.
|
||||||
|
|
||||||
|
### Aliases not yet removed
|
||||||
|
|
||||||
|
The following legacy aliases still work but log a deprecation warning on first use. They are scheduled for removal in Phase 7:
|
||||||
|
|
||||||
|
| Canonical | Legacy alias |
|
||||||
|
|:---|:---|
|
||||||
|
| `set.mode` | `changemode` |
|
||||||
|
| `set.inflow` | `q_in` |
|
||||||
|
| `set.outflow` | `q_out` |
|
||||||
|
| `set.demand` | `Qd` |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` |
|
||||||
|
| `child.register` | `registerChild` |
|
||||||
|
|
||||||
|
Update integrations now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions (tracked)
|
||||||
|
|
||||||
|
Pulled from `.claude/refactor/OPEN_QUESTIONS.md`. Last reviewed on the date in the badge above.
|
||||||
|
|
||||||
|
| Question | Where it lives |
|
||||||
|
|:---|:---|
|
||||||
|
| `overfillVol` alias drop — same shape as the already-done `overfillLevel` drop | OPEN_QUESTIONS.md (pumpingStation entry) |
|
||||||
|
| Net-flow source warning when multiple aggregators are wired | Internal — not yet ticketed |
|
||||||
|
| Cylindrical basin geometry | Internal — not yet ticketed |
|
||||||
|
| Docker E2E sign-off (P2.14) | OPEN_QUESTIONS.md (Phase 6) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
### From pre-refactor
|
||||||
|
|
||||||
|
| Pre-refactor | Now |
|
||||||
|
|:---|:---|
|
||||||
|
| `enableOverfillProtection` | `enableHighVolumeSafety` |
|
||||||
|
| `overfillThresholdPercent` | `highVolumeSafetyThresholdPercent` |
|
||||||
|
| Legacy topics (`changemode`, `q_in`, ...) | Canonical topics (see [Reference — Contracts](Reference-Contracts) for the alias map) |
|
||||||
|
| `basic.flow.json` (legacy) | `01-Basic.json` (canonical-topic version) |
|
||||||
|
|
||||||
|
### Renamed safety thresholds
|
||||||
|
|
||||||
|
The safety layer used to expose threshold fields named `overfill*`. Those names suggested the layer prevents overflow specifically; in practice the rule handles high-volume conditions more broadly (high level + low inflow / outflow imbalance). The current names (`highVolumeSafety*`) reflect that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters (alias map at the end) |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, state chart |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows |
|
||||||
17
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
### pumpingStation
|
||||||
|
|
||||||
|
- [Home](Home)
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- [Contracts](Reference-Contracts)
|
||||||
|
- [Architecture](Reference-Architecture)
|
||||||
|
- [Examples](Reference-Examples)
|
||||||
|
- [Limitations](Reference-Limitations)
|
||||||
|
|
||||||
|
**Related**
|
||||||
|
|
||||||
|
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||||
|
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
|
||||||
|
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
|
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||||
2
wiki/_partial-flows/pumpingStation/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Downloadable example flow JSONs.
|
||||||
|
# Canonical examples live under nodes/pumpingStation/examples/.
|
||||||
4
wiki/_partial-gifs/pumpingStation/.gitkeep
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Dashboard interaction GIFs for pumpingStation.
|
||||||
|
# Naming: NN-short-description.gif
|
||||||
|
# Optimise with: gifsicle -O3 --lossy=80 in.gif -o out.gif
|
||||||
|
# Target <= 1 MB.
|
||||||
3
wiki/_partial-screenshots/pumpingStation/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Node-RED editor screenshots for pumpingStation.
|
||||||
|
# Naming: NN-short-description.png
|
||||||
|
# See Home.md callouts.
|
||||||
BIN
wiki/_partial-screenshots/pumpingStation/01-node-and-editor.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
wiki/_partial-screenshots/pumpingStation/02-basic-flow.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 208 KiB |
72
wiki/diagrams/README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Diagrams
|
||||||
|
|
||||||
|
Editable source diagrams for the pumpingStation wiki. The current diagrams are **`.drawio.svg` files with the draw.io source embedded**, so anyone can edit the SVG directly in [draw.io](https://app.diagrams.net/) without touching any Markdown.
|
||||||
|
|
||||||
|
## File roles
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `<name>.drawio` | Optional native draw.io XML source, if a diagram also keeps a standalone source file. |
|
||||||
|
| `<name>.drawio.svg` | SVG export of the same diagram (with source embedded). What the wiki actually renders, and what round-trips back into draw.io. |
|
||||||
|
|
||||||
|
An optional standalone `.drawio` file can be committed beside the SVG, but the embedded-source SVG is enough for the wiki to render and for the next editor to pick up from exactly where the last one left off.
|
||||||
|
|
||||||
|
## Editing workflow
|
||||||
|
|
||||||
|
1. **Clone** the repo (you likely already have it if you're editing):
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.wbd-rd.nl/RnD/pumpingStation.git
|
||||||
|
cd pumpingStation/wiki/diagrams
|
||||||
|
```
|
||||||
|
2. **Open** the `.drawio.svg` file in draw.io:
|
||||||
|
- Web: [app.diagrams.net](https://app.diagrams.net/) → *Open Existing Diagram*, or drag-and-drop.
|
||||||
|
- Desktop: [drawio-desktop](https://github.com/jgraph/drawio-desktop/releases).
|
||||||
|
3. **Edit** — move shapes, change labels, adjust layout.
|
||||||
|
4. **Export** to SVG with the source embedded:
|
||||||
|
- `File → Export as → SVG…`
|
||||||
|
- Check **Include a copy of my diagram** ← this is what lets future edits round-trip through the SVG.
|
||||||
|
- Save next to the source as `<name>.drawio.svg` (overwrite).
|
||||||
|
5. **Commit & push** the edited SVG, plus the `.drawio` file if one exists:
|
||||||
|
```bash
|
||||||
|
git add wiki/diagrams/<name>.drawio.svg
|
||||||
|
git commit -m "Update <name>: <what changed>"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Referencing a diagram from a wiki page
|
||||||
|
|
||||||
|
In any Markdown page under `wiki/`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up in exports.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- kebab-case, one concept per diagram.
|
||||||
|
- Current diagrams:
|
||||||
|
|
||||||
|
| Diagram | Shows |
|
||||||
|
|---|---|
|
||||||
|
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
|
||||||
|
| `modes/level-based/basin-mode-level-linear` | Level-based linear control curve — rising ramp starts at inlet level, falling ramp shifts to `startLevel` |
|
||||||
|
| `modes/level-based/basin-mode-level-log` | Level-based logarithmic control curve — fast early response, falling ramp shifts to `startLevel` |
|
||||||
|
| `control-zones` | Legacy vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
|
||||||
|
| `safety-rules` | Dry-run vs high-volume safety rule asymmetry — which children stop, which keep running |
|
||||||
|
|
||||||
|
## Making a brand-new diagram
|
||||||
|
|
||||||
|
1. Open draw.io, start blank.
|
||||||
|
2. Draw it.
|
||||||
|
3. `File → Export as → SVG…` with **Include a copy of my diagram** checked → save as `wiki/diagrams/<name>.drawio.svg`.
|
||||||
|
4. Reference from the wiki page with ``.
|
||||||
|
5. Add an entry to the table above.
|
||||||
|
6. Commit the new `.drawio.svg` and updated `.md` together.
|
||||||
|
|
||||||
|
## These starters are rough
|
||||||
|
|
||||||
|
Some diagrams are still rough — layout is approximate, colors and fonts may be defaults, and alignment may need refinement. They're meant to be improved in draw.io as the model settles.
|
||||||
|
|
||||||
|
Open the `.drawio.svg` in draw.io and it will load the editable model. The SVG has the draw.io XML embedded in a `content="…"` attribute on the root `<svg>` element — that's what lets draw.io re-open its own SVG exports.
|
||||||
6
wiki/diagrams/basin-model.drawio.svg
Normal file
|
After Width: | Height: | Size: 686 KiB |
162
wiki/diagrams/control-zones.drawio.svg
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 660" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||||
|
<diagram name="control-zones" id="controlZones">
|
||||||
|
<mxGraphModel dx="1000" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="700" pageHeight="800" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="title" value="levelbased mode — three zones" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="20" width="500" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="axis" value="" style="endArrow=classic;html=1;strokeColor=#000;strokeWidth=2;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="280" y="600" as="sourcePoint" />
|
||||||
|
<mxPoint x="280" y="80" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="axis_label" value="level" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="240" y="60" width="50" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="overflow" value="heightOverflow — weir crest (spill → measure)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="130" width="380" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="overflow_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="270" y="140" as="sourcePoint" />
|
||||||
|
<mxPoint x="290" y="140" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="run_band" value="RUN — linear 0 → 100 %" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#1E8449;fontSize=12;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="160" width="220" height="110" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="maxflow" value="maxFlowLevel — 100 % demand" style="text;html=1;fontSize=12;align=left;fontColor=#D68910;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="265" width="300" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="maxflow_tick" value="" style="endArrow=none;html=1;strokeColor=#D68910;strokeWidth=2;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="265" y="275" as="sourcePoint" />
|
||||||
|
<mxPoint x="295" y="275" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="ramp_label" value="(ramp — demand scales linearly with level)" style="text;html=1;fontSize=11;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="300" width="320" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="startlevel" value="startLevel — 0 % demand (ramp starts)" style="text;html=1;fontSize=12;align=left;fontColor=#1E8449;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="335" width="340" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="start_tick" value="" style="endArrow=none;html=1;strokeColor=#1E8449;strokeWidth=2;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="265" y="345" as="sourcePoint" />
|
||||||
|
<mxPoint x="295" y="345" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="dead_band" value="DEAD ZONE — hysteresis, keep last cmd" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#F57C00;fontSize=12;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="360" width="220" height="80" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="inlet" value="heightInlet — inflow pipe" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="395" width="300" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="inlet_tick" value="" style="endArrow=none;html=1;strokeColor=#1F4E79;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="270" y="405" as="sourcePoint" />
|
||||||
|
<mxPoint x="290" y="405" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="stoplevel" value="stopLevel — unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="440" width="300" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="stop_tick" value="" style="endArrow=none;html=1;strokeColor=#6C3483;strokeWidth=2;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="265" y="450" as="sourcePoint" />
|
||||||
|
<mxPoint x="295" y="450" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="stop_band" value="pumps OFF (MGC shutdown)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#F4ECF7;strokeColor=#6C3483;fontSize=12;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="465" width="220" height="80" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="outlet" value="heightOutlet — outflow pipe (dry-run trip here)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="510" width="360" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="outlet_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="270" y="520" as="sourcePoint" />
|
||||||
|
<mxPoint x="290" y="520" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="floor" value="0 (floor)" style="text;html=1;fontSize=11;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="580" width="60" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>">
|
||||||
|
<title>levelbased mode — three zones</title>
|
||||||
|
<defs>
|
||||||
|
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#000" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text x="350" y="30" text-anchor="middle" font-weight="bold" font-size="16">levelbased mode — three zones</text>
|
||||||
|
|
||||||
|
<!-- Vertical level axis -->
|
||||||
|
<line x1="280" y1="600" x2="280" y2="80" stroke="#000" stroke-width="2" marker-end="url(#arr)" />
|
||||||
|
<text x="260" y="75" text-anchor="end" font-weight="bold" font-size="13">level</text>
|
||||||
|
|
||||||
|
<!-- heightOverflow -->
|
||||||
|
<line x1="270" y1="140" x2="290" y2="140" stroke="#B22222" stroke-width="2" />
|
||||||
|
<text x="300" y="144" fill="#B22222" font-size="12">heightOverflow — weir crest (spill → measure)</text>
|
||||||
|
|
||||||
|
<!-- RUN band -->
|
||||||
|
<rect x="300" y="160" width="240" height="110" fill="#E8F5E9" stroke="#1E8449" />
|
||||||
|
<text x="420" y="220" text-anchor="middle" font-size="13" fill="#1E8449" font-weight="bold">RUN</text>
|
||||||
|
<text x="420" y="238" text-anchor="middle" font-size="12" fill="#1E8449">linear 0 → 100 %</text>
|
||||||
|
|
||||||
|
<!-- maxFlowLevel -->
|
||||||
|
<line x1="265" y1="275" x2="295" y2="275" stroke="#D68910" stroke-width="3" />
|
||||||
|
<text x="305" y="279" fill="#D68910" font-size="12" font-weight="bold">maxFlowLevel — 100 % demand</text>
|
||||||
|
|
||||||
|
<!-- Ramp label -->
|
||||||
|
<text x="305" y="314" font-size="11" font-style="italic">(ramp — demand scales linearly with level)</text>
|
||||||
|
|
||||||
|
<!-- startLevel -->
|
||||||
|
<line x1="265" y1="345" x2="295" y2="345" stroke="#1E8449" stroke-width="3" />
|
||||||
|
<text x="305" y="349" fill="#1E8449" font-size="12" font-weight="bold">startLevel — 0 % demand (ramp starts)</text>
|
||||||
|
|
||||||
|
<!-- DEAD ZONE band -->
|
||||||
|
<rect x="300" y="360" width="240" height="80" fill="#FFF8E1" stroke="#F57C00" />
|
||||||
|
<text x="420" y="390" text-anchor="middle" font-size="13" fill="#B78200" font-weight="bold">DEAD ZONE</text>
|
||||||
|
<text x="420" y="408" text-anchor="middle" font-size="12" fill="#B78200">hysteresis — keep last cmd</text>
|
||||||
|
|
||||||
|
<!-- heightInlet (inside dead zone) -->
|
||||||
|
<line x1="270" y1="405" x2="290" y2="405" stroke="#1F4E79" stroke-width="2" />
|
||||||
|
<text x="550" y="409" fill="#1F4E79" font-size="12">heightInlet</text>
|
||||||
|
|
||||||
|
<!-- stopLevel -->
|
||||||
|
<line x1="265" y1="450" x2="295" y2="450" stroke="#6C3483" stroke-width="3" />
|
||||||
|
<text x="305" y="454" fill="#6C3483" font-size="12" font-weight="bold">stopLevel — unconditional STOP</text>
|
||||||
|
|
||||||
|
<!-- STOP band -->
|
||||||
|
<rect x="300" y="465" width="240" height="80" fill="#F4ECF7" stroke="#6C3483" />
|
||||||
|
<text x="420" y="500" text-anchor="middle" font-size="13" fill="#6C3483" font-weight="bold">pumps OFF</text>
|
||||||
|
<text x="420" y="518" text-anchor="middle" font-size="12" fill="#6C3483">(MGC shutdown)</text>
|
||||||
|
|
||||||
|
<!-- heightOutlet -->
|
||||||
|
<line x1="270" y1="540" x2="290" y2="540" stroke="#B22222" stroke-width="2" />
|
||||||
|
<text x="305" y="544" fill="#B22222" font-size="12">heightOutlet — outflow pipe (dry-run trip)</text>
|
||||||
|
|
||||||
|
<!-- floor -->
|
||||||
|
<line x1="265" y1="600" x2="295" y2="600" stroke="#000" stroke-width="2" />
|
||||||
|
<text x="305" y="604" font-size="11">0 (floor)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 256 KiB |
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||||
|
<diagram name="safety-rules" id="safetyRules">
|
||||||
|
<mxGraphModel dx="1200" dy="700" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="900" pageHeight="700" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="title" value="Safety rules — asymmetric by direction" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="150" y="20" width="600" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="dryrun_box" value="DRY-RUN&#10;(direction = draining)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#E65100;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="80" y="80" width="340" height="340" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="dr_upstream" value="upstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="140" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="dr_downstream" value="downstream children — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="170" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="dr_machinegroups" value="machineGroups — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="200" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="dr_control" value="control loop — BLOCKED" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="230" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="dr_note" value="safetyControllerActive = true&#10;&#10;Pumps must stop before sucking air." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="290" width="300" height="80" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="overfill_box" value="OVERFILL&#10;(direction = filling)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="480" y="80" width="340" height="340" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="of_upstream" value="upstream children — STOP ⚠" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#C62828;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="500" y="140" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="of_downstream" value="downstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="500" y="170" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="of_machinegroups" value="machineGroups — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="500" y="200" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="of_control" value="control loop — ACTIVE" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="500" y="230" width="300" height="24" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="of_note" value="Level control keeps commanding downstream MGC.&#10;&#10;⚠ &quot;upstream STOP&quot; is only correct in a cascaded layout. In a gravity-sewer station the inflow can&apos;t be stopped — log the spill instead." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="500" y="290" width="300" height="120" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="trigger_title" value="Triggers (either condition fires the rule):" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="80" y="450" width="740" height="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="trigger_list" value="• vol &lt; triggerLowVol (triggerLowVol = minVol × (1 + pct/100))&#10;• vol &gt; triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)&#10;• remainingTime &lt; timeleftToFullOrEmptyThresholdSeconds (if enabled)" style="text;html=1;fontSize=12;align=left;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="80" y="480" width="740" height="80" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>">
|
||||||
|
<title>Safety rules — asymmetric by direction</title>
|
||||||
|
|
||||||
|
<text x="450" y="30" text-anchor="middle" font-weight="bold" font-size="16">Safety rules — asymmetric by direction</text>
|
||||||
|
|
||||||
|
<!-- DRY-RUN box -->
|
||||||
|
<rect x="80" y="80" width="340" height="340" fill="#FFF3E0" stroke="#E65100" stroke-width="2" />
|
||||||
|
<text x="250" y="112" text-anchor="middle" font-weight="bold" font-size="14">DRY-RUN</text>
|
||||||
|
<text x="250" y="130" text-anchor="middle" font-size="13" fill="#6F4A19">(direction = draining)</text>
|
||||||
|
|
||||||
|
<text x="100" y="162" font-size="13">upstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||||
|
<text x="100" y="188" font-size="13" fill="#E65100">downstream children — <tspan font-weight="bold">STOP</tspan></text>
|
||||||
|
<text x="100" y="214" font-size="13" fill="#E65100">machineGroups — <tspan font-weight="bold">STOP</tspan></text>
|
||||||
|
<text x="100" y="240" font-size="13" fill="#E65100">control loop — <tspan font-weight="bold">BLOCKED</tspan></text>
|
||||||
|
|
||||||
|
<line x1="100" y1="268" x2="400" y2="268" stroke="#E65100" stroke-dasharray="3 3" />
|
||||||
|
<text x="100" y="294" font-size="12" font-style="italic">safetyControllerActive = true</text>
|
||||||
|
<text x="100" y="316" font-size="12" font-style="italic">Pumps must stop before sucking air.</text>
|
||||||
|
|
||||||
|
<!-- OVERFILL box -->
|
||||||
|
<rect x="480" y="80" width="340" height="340" fill="#FFEBEE" stroke="#C62828" stroke-width="2" />
|
||||||
|
<text x="650" y="112" text-anchor="middle" font-weight="bold" font-size="14">OVERFILL</text>
|
||||||
|
<text x="650" y="130" text-anchor="middle" font-size="13" fill="#7A1919">(direction = filling)</text>
|
||||||
|
|
||||||
|
<text x="500" y="162" font-size="13" fill="#C62828">upstream children — <tspan font-weight="bold">STOP</tspan> ⚠</text>
|
||||||
|
<text x="500" y="188" font-size="13">downstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||||
|
<text x="500" y="214" font-size="13">machineGroups — <tspan font-weight="bold">KEEP</tspan></text>
|
||||||
|
<text x="500" y="240" font-size="13">control loop — <tspan font-weight="bold">ACTIVE</tspan></text>
|
||||||
|
|
||||||
|
<line x1="500" y1="268" x2="800" y2="268" stroke="#C62828" stroke-dasharray="3 3" />
|
||||||
|
<text x="500" y="294" font-size="12" font-style="italic">Level control keeps commanding downstream MGC.</text>
|
||||||
|
<text x="500" y="324" font-size="12" font-style="italic" fill="#C62828">⚠ "upstream STOP" is only correct in a cascaded layout.</text>
|
||||||
|
<text x="500" y="342" font-size="12" font-style="italic" fill="#C62828">In a gravity-sewer station the inflow can't be</text>
|
||||||
|
<text x="500" y="360" font-size="12" font-style="italic" fill="#C62828">stopped — log the spill instead.</text>
|
||||||
|
|
||||||
|
<!-- Triggers block -->
|
||||||
|
<text x="80" y="470" font-weight="bold" font-size="13">Triggers (either condition fires the rule):</text>
|
||||||
|
<text x="100" y="498" font-size="12">• vol < triggerLowVol (triggerLowVol = minVol × (1 + pct/100))</text>
|
||||||
|
<text x="100" y="520" font-size="12">• vol > triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)</text>
|
||||||
|
<text x="100" y="542" font-size="12">• remainingTime < timeleftToFullOrEmptyThresholdSeconds (if enabled)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |