Compare commits
79 Commits
4e098eefaa
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f01b0bcb19 | ||
|
|
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.
|
||||
58
CONTRACT.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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: 'registerChild', payload: <node.id>, positionVsParent, distance }`
|
||||
to the upstream parent.
|
||||
|
||||
## 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.
|
||||
|
||||
479
examples/01-Basic.json
Normal file
@@ -0,0 +1,479 @@
|
||||
[
|
||||
{
|
||||
"id": "77f00aef1c966167",
|
||||
"type": "tab",
|
||||
"label": "PumpingStation - Basic",
|
||||
"disabled": false,
|
||||
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
|
||||
},
|
||||
{
|
||||
"id": "aa3381b896eb2cfb",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "Pumping Station (Process Cell)",
|
||||
"style": {
|
||||
"label": true,
|
||||
"stroke": "#000000",
|
||||
"fill": "#0c99d9",
|
||||
"fill-opacity": "0.10"
|
||||
},
|
||||
"nodes": [
|
||||
"8e78b6607deb33a7"
|
||||
],
|
||||
"x": 534,
|
||||
"y": 351.5,
|
||||
"w": 232,
|
||||
"h": 97
|
||||
},
|
||||
{
|
||||
"id": "4996420d47442fad",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "1. Control mode",
|
||||
"style": {
|
||||
"stroke": "#666666",
|
||||
"fill": "#ffdf7f",
|
||||
"fill-opacity": "0.15",
|
||||
"label": true,
|
||||
"color": "#333333"
|
||||
},
|
||||
"nodes": [
|
||||
"1155bbbde7c65363",
|
||||
"e9bea0f95b557f5d"
|
||||
],
|
||||
"x": 94,
|
||||
"y": 119,
|
||||
"w": 272,
|
||||
"h": 122
|
||||
},
|
||||
{
|
||||
"id": "a9f9b38b0e00c1d7",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "2. Flow signals (inflow / outflow)",
|
||||
"style": {
|
||||
"stroke": "#666666",
|
||||
"fill": "#ffdf7f",
|
||||
"fill-opacity": "0.15",
|
||||
"label": true,
|
||||
"color": "#333333"
|
||||
},
|
||||
"nodes": [
|
||||
"7b2b5eb919b1ab15",
|
||||
"3350187815774b95"
|
||||
],
|
||||
"x": 94,
|
||||
"y": 279,
|
||||
"w": 262,
|
||||
"h": 122
|
||||
},
|
||||
{
|
||||
"id": "42bf82c87d05f498",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "3. Operator demand (manual mode only)",
|
||||
"style": {
|
||||
"stroke": "#666666",
|
||||
"fill": "#ffdf7f",
|
||||
"fill-opacity": "0.15",
|
||||
"label": true,
|
||||
"color": "#333333"
|
||||
},
|
||||
"nodes": [
|
||||
"48c2262c345c46b9"
|
||||
],
|
||||
"x": 94,
|
||||
"y": 479,
|
||||
"w": 261,
|
||||
"h": 82
|
||||
},
|
||||
{
|
||||
"id": "234bdce20170061a",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "4. Calibration",
|
||||
"style": {
|
||||
"stroke": "#666666",
|
||||
"fill": "#ffdf7f",
|
||||
"fill-opacity": "0.15",
|
||||
"label": true,
|
||||
"color": "#333333"
|
||||
},
|
||||
"nodes": [
|
||||
"463eefdd54df89a5",
|
||||
"2e0642275899fc79"
|
||||
],
|
||||
"x": 94,
|
||||
"y": 599,
|
||||
"w": 272,
|
||||
"h": 122
|
||||
},
|
||||
{
|
||||
"id": "f4ba4542514ed853",
|
||||
"type": "group",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "Expected outputs",
|
||||
"style": {
|
||||
"stroke": "#666666",
|
||||
"fill": "#d1d1d1",
|
||||
"fill-opacity": "0.2",
|
||||
"label": true,
|
||||
"color": "#333333"
|
||||
},
|
||||
"nodes": [
|
||||
"b2450e5ee2eebfaa",
|
||||
"386af1ad8aa8ed12",
|
||||
"c27c2655f199b530"
|
||||
],
|
||||
"x": 874,
|
||||
"y": 299,
|
||||
"w": 252,
|
||||
"h": 202
|
||||
},
|
||||
{
|
||||
"id": "b30af582f935bcb7",
|
||||
"type": "comment",
|
||||
"z": "77f00aef1c966167",
|
||||
"name": "PumpingStation — Basic (Tier 1)",
|
||||
"info": "Single pumpingStation node driven by inject buttons. Shows the canonical msg.topic command surface.\n\nDefault controlMode = levelbased. Switch to manual to honour set.demand.\n\nHOW TO USE\n1. Deploy the flow.\n2. (optional) Click \"set.mode = manual\" if you want set.demand to forward; otherwise leave it on levelbased and the ramp drives demand from level.\n3. Click \"set.inflow = 60 m³/h\" to push wastewater into the basin.\n4. Watch the basin fill on Port 0 (level, volume rise) and Port 1 (InfluxDB-shaped payload).\n5. In manual mode: click \"set.demand = 40\" — the value surfaces as `manualDemand` on Port 0/1 and in the node status badge.\n6. Click \"calibrate volume 25 m³\" or \"calibrate level 1.5 m\" to snap the predicted-volume integrator.\n\nPORTS\n- Port 0: process output (changed fields only)\n- Port 1: InfluxDB-shaped {measurement, fields, tags, timestamp}\n- Port 2: parent registration (child handshake)",
|
||||
"x": 650,
|
||||
"y": 300,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "1155bbbde7c65363",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "4996420d47442fad",
|
||||
"name": "set.mode = manual",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "manual",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.mode",
|
||||
"x": 230,
|
||||
"y": 160,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e9bea0f95b557f5d",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "4996420d47442fad",
|
||||
"name": "set.mode = levelbased",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "levelbased",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.mode",
|
||||
"x": 240,
|
||||
"y": 200,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "7b2b5eb919b1ab15",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "a9f9b38b0e00c1d7",
|
||||
"name": "set.inflow = 60 m3/h",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "60",
|
||||
"vt": "num"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.inflow",
|
||||
"x": 240,
|
||||
"y": 360,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "48c2262c345c46b9",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "42bf82c87d05f498",
|
||||
"name": "set.demand = 40 %",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "40",
|
||||
"vt": "num"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.demand",
|
||||
"x": 230,
|
||||
"y": 520,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "463eefdd54df89a5",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "234bdce20170061a",
|
||||
"name": "calibrate volume 25 m3",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "25",
|
||||
"vt": "num"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "cmd.calibrate.volume",
|
||||
"x": 240,
|
||||
"y": 640,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2e0642275899fc79",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "234bdce20170061a",
|
||||
"name": "calibrate level 1.5 m",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "1.5",
|
||||
"vt": "num"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "cmd.calibrate.level",
|
||||
"x": 240,
|
||||
"y": 680,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2450e5ee2eebfaa",
|
||||
"type": "debug",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "f4ba4542514ed853",
|
||||
"name": "Port 0: Process",
|
||||
"active": true,
|
||||
"tosidebar": true,
|
||||
"console": false,
|
||||
"tostatus": false,
|
||||
"complete": "payload",
|
||||
"targetType": "msg",
|
||||
"x": 980,
|
||||
"y": 340,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "386af1ad8aa8ed12",
|
||||
"type": "debug",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "f4ba4542514ed853",
|
||||
"name": "Port 1: InfluxDB",
|
||||
"active": true,
|
||||
"tosidebar": true,
|
||||
"console": false,
|
||||
"tostatus": false,
|
||||
"complete": "true",
|
||||
"targetType": "full",
|
||||
"x": 980,
|
||||
"y": 400,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "c27c2655f199b530",
|
||||
"type": "debug",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "f4ba4542514ed853",
|
||||
"name": "Port 2: Parent reg",
|
||||
"active": true,
|
||||
"tosidebar": true,
|
||||
"console": false,
|
||||
"tostatus": false,
|
||||
"complete": "true",
|
||||
"targetType": "full",
|
||||
"x": 990,
|
||||
"y": 460,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "8e78b6607deb33a7",
|
||||
"type": "pumpingStation",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "aa3381b896eb2cfb",
|
||||
"name": "",
|
||||
"simulator": false,
|
||||
"basinVolume": 50,
|
||||
"basinHeight": 4,
|
||||
"inflowLevel": 1.5,
|
||||
"outflowLevel": 0.2,
|
||||
"overflowLevel": 3.8,
|
||||
"defaultFluid": "wastewater",
|
||||
"inletPipeDiameter": 0.3,
|
||||
"outletPipeDiameter": 0.3,
|
||||
"pipelineLength": 80,
|
||||
"maxDischargeHead": 24,
|
||||
"staticHead": 12,
|
||||
"maxInflowRate": 200,
|
||||
"temperatureReferenceDegC": 15,
|
||||
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
||||
"enableDryRunProtection": true,
|
||||
"enableHighVolumeSafety": true,
|
||||
"enableOverfillProtection": true,
|
||||
"dryRunThresholdPercent": 2,
|
||||
"highVolumeSafetyThresholdPercent": 98,
|
||||
"overfillThresholdPercent": 98,
|
||||
"minHeightBasedOn": "outlet",
|
||||
"processOutputFormat": "process",
|
||||
"dbaseOutputFormat": "influxdb",
|
||||
"refHeight": "NAP",
|
||||
"basinBottomRef": 1,
|
||||
"uuid": "",
|
||||
"supplier": "",
|
||||
"category": "",
|
||||
"assetType": "",
|
||||
"model": "",
|
||||
"unit": "",
|
||||
"enableLog": false,
|
||||
"logLevel": "error",
|
||||
"positionVsParent": "atEquipment",
|
||||
"positionIcon": "⊥",
|
||||
"hasDistance": false,
|
||||
"distance": "",
|
||||
"controlMode": "levelbased",
|
||||
"levelCurveType": "linear",
|
||||
"logCurveFactor": 9,
|
||||
"enableShiftedRamp": false,
|
||||
"shiftLevel": 0,
|
||||
"shiftArmPercent": 95,
|
||||
"startLevel": 1,
|
||||
"stopLevel": 0.5,
|
||||
"minLevel": 0.20400000000000001,
|
||||
"maxLevel": 3.8,
|
||||
"flowSetpoint": null,
|
||||
"flowDeadband": null,
|
||||
"x": 650,
|
||||
"y": 400,
|
||||
"wires": [
|
||||
[
|
||||
"b2450e5ee2eebfaa"
|
||||
],
|
||||
[
|
||||
"386af1ad8aa8ed12"
|
||||
],
|
||||
[
|
||||
"c27c2655f199b530"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3350187815774b95",
|
||||
"type": "inject",
|
||||
"z": "77f00aef1c966167",
|
||||
"g": "a9f9b38b0e00c1d7",
|
||||
"name": "set.outflow= 80 m3/h",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.outflow",
|
||||
"payload": "80",
|
||||
"payloadType": "num",
|
||||
"x": 230,
|
||||
"y": 320,
|
||||
"wires": [
|
||||
[
|
||||
"8e78b6607deb33a7"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ef77c1819422a098",
|
||||
"type": "global-config",
|
||||
"env": [],
|
||||
"modules": {
|
||||
"EVOLV": "1.0.29"
|
||||
}
|
||||
}
|
||||
]
|
||||
1136
examples/02-Dashboard.json
Normal file
86
examples/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 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.
|
||||
|
||||
## Regenerating
|
||||
|
||||
The current example JSON files are hand-maintained. If you re-introduce a
|
||||
generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it
|
||||
rather than editing the JSON directly.
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "Control module",
|
||||
"main": "pumpingStation.js",
|
||||
"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": {
|
||||
"type": "git",
|
||||
|
||||
@@ -8,22 +8,50 @@
|
||||
| **Control Module** | `#a9daee` | zwart |
|
||||
|
||||
-->
|
||||
<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/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<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
|
||||
RED.nodes.registerType("pumpingStation", {
|
||||
category: "EVOLV",
|
||||
color: "#0c99d9", // color for the node based on the S88 schema
|
||||
color: "#8B4513",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
|
||||
// Define station-specific properties
|
||||
simulator: { value: false },
|
||||
basinVolume: { value: 1 }, // m³, total empty basin
|
||||
basinHeight: { value: 1 }, // m, floor to top
|
||||
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
heightOverflow: { value: 0.9 }, // m, overflow elevation
|
||||
basinVolume: { value: 50 }, // m³, total empty basin
|
||||
basinHeight: { value: 4 }, // m, floor to top
|
||||
inflowLevel: { value: 1.5 }, // m, bottom/invert of inlet pipe above floor
|
||||
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
|
||||
overflowLevel: { value: 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
|
||||
refHeight: { value: "NAP" }, // reference height
|
||||
@@ -47,7 +75,23 @@
|
||||
hasDistance: { value: false },
|
||||
distance: { value: 0 },
|
||||
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";
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
const waitForMenuData = () => {
|
||||
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 ------------------- //
|
||||
oneditprepare: function () {
|
||||
window.PSEditor.oneditprepare.call(this);
|
||||
},
|
||||
oneditsave: function () {
|
||||
const node = 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 || "";
|
||||
window.PSEditor.oneditsave.call(this);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -115,7 +119,7 @@
|
||||
|
||||
<script type="text/html" data-template-name="pumpingStation">
|
||||
|
||||
<!-- Simulator toggle -->
|
||||
<h4>Simulation</h4>
|
||||
<div class="form-row">
|
||||
<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;" />
|
||||
@@ -123,34 +127,400 @@
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Basin parameters</h4>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Basin geometry -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
|
||||
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
|
||||
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
<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" />
|
||||
<span class="ps-unit">m³</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#333" data-couples-line="ps-line-basinHeight">
|
||||
<div><label>basinHeight</label><div class="ps-sub">floor → rim</div></div>
|
||||
<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>
|
||||
|
||||
<!-- Inlet/Outlet elevations -->
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Control Strategy</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
|
||||
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
|
||||
<select id="node-input-controlMode">
|
||||
<option value="levelbased">Level-based</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
|
||||
|
||||
<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 class="form-row">
|
||||
<label for="node-input-enableShiftedRamp" style="width:auto;">
|
||||
<input type="checkbox" id="node-input-enableShiftedRamp" style="width:auto;vertical-align:middle;margin-right:6px;" />
|
||||
Enable shifted ramp (hysteresis)
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
<!--
|
||||
============================================================
|
||||
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 class="form-row">
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Reference data -->
|
||||
<h4>Reference</h4>
|
||||
|
||||
<!-- Reference data — basinBottomRef moved into basin side-panel above. -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
||||
<select id="node-input-refHeight" style="width:60%;">
|
||||
@@ -158,9 +528,55 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Safety</h4>
|
||||
|
||||
<!-- Safety settings -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
<label for="node-input-enableDryRunProtection">
|
||||
<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>
|
||||
|
||||
<!-- 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 path = require('path');
|
||||
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
|
||||
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 };
|
||||
111
src/commands/handlers.js
Normal file
@@ -0,0 +1,111 @@
|
||||
'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);
|
||||
};
|
||||
|
||||
exports.setInflow = (source, msg) => {
|
||||
// Payload is either a number (legacy q_in shape) or
|
||||
// { value, unit, timestamp } (richer object form).
|
||||
const p = msg.payload;
|
||||
let value;
|
||||
let unit;
|
||||
let timestamp;
|
||||
if (p !== null && typeof p === 'object') {
|
||||
value = Number(p.value);
|
||||
unit = p.unit;
|
||||
timestamp = p.timestamp || Date.now();
|
||||
} else {
|
||||
value = Number(p);
|
||||
unit = msg?.unit;
|
||||
timestamp = msg?.timestamp || Date.now();
|
||||
}
|
||||
source.setManualInflow(value, timestamp, unit);
|
||||
};
|
||||
|
||||
exports.setOutflow = (source, msg) => {
|
||||
// Manual q_out — basin-docs dashboard injects a drain rate without
|
||||
// wiring a real pump. Same payload shape as q_in.
|
||||
const p = msg.payload;
|
||||
let value;
|
||||
let unit;
|
||||
let timestamp;
|
||||
if (p !== null && typeof p === 'object') {
|
||||
value = Number(p.value);
|
||||
unit = p.unit;
|
||||
timestamp = p.timestamp || Date.now();
|
||||
} else {
|
||||
value = Number(p);
|
||||
unit = msg?.unit;
|
||||
timestamp = msg?.timestamp || Date.now();
|
||||
}
|
||||
source.setManualOutflow(value, timestamp, unit);
|
||||
};
|
||||
|
||||
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 or numeric string.
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volume', default: 'm3' },
|
||||
description: 'Calibrate the predicted-volume integrator to a known basin volume.',
|
||||
handler: handlers.calibrateVolume,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.calibrate.level',
|
||||
aliases: ['calibratePredictedLevel'],
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'length', default: 'm' },
|
||||
description: 'Calibrate the predicted-volume integrator to a known basin level.',
|
||||
handler: handlers.calibrateLevel,
|
||||
},
|
||||
{
|
||||
topic: 'set.inflow',
|
||||
aliases: ['q_in'],
|
||||
// any: number, numeric string, or { value, unit, timestamp } object.
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
description: 'Push a measured inflow value into the basin balance.',
|
||||
handler: handlers.setInflow,
|
||||
},
|
||||
{
|
||||
topic: 'set.outflow',
|
||||
aliases: ['q_out'],
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
description: 'Push a measured outflow value into the basin balance.',
|
||||
handler: handlers.setOutflow,
|
||||
},
|
||||
{
|
||||
topic: 'set.demand',
|
||||
aliases: ['Qd'],
|
||||
payloadSchema: { type: 'any' },
|
||||
units: { measure: 'volumeFlowRate', default: '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;
|
||||
266
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');
|
||||
const Specific = require("./specificClass");
|
||||
class nodeClass extends BaseNodeAdapter {
|
||||
static DomainClass = PumpingStation;
|
||||
static commands = commands;
|
||||
// Tick-driven: predicted-volume integrator needs delta-time per second.
|
||||
static tickInterval = 1000;
|
||||
static statusInterval = 1000;
|
||||
|
||||
class nodeClass {
|
||||
/**
|
||||
* 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, {
|
||||
buildDomainConfig(uiConfig) {
|
||||
return {
|
||||
basin: {
|
||||
volume: uiConfig.basinVolume,
|
||||
height: uiConfig.basinHeight,
|
||||
heightInlet: uiConfig.heightInlet,
|
||||
heightOutlet: uiConfig.heightOutlet,
|
||||
heightOverflow: uiConfig.heightOverflow,
|
||||
inflowLevel: uiConfig.inflowLevel,
|
||||
outflowLevel: uiConfig.outflowLevel,
|
||||
overflowLevel: uiConfig.overflowLevel,
|
||||
inletPipeDiameter: uiConfig.inletPipeDiameter,
|
||||
outletPipeDiameter: uiConfig.outletPipeDiameter,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: uiConfig.refHeight,
|
||||
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
||||
basinBottomRef: uiConfig.basinBottomRef,
|
||||
}
|
||||
});
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
maxInflowRate: uiConfig.maxInflowRate,
|
||||
staticHead: uiConfig.staticHead,
|
||||
maxDischargeHead: uiConfig.maxDischargeHead,
|
||||
pipelineLength: uiConfig.pipelineLength,
|
||||
defaultFluid: uiConfig.defaultFluid,
|
||||
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
||||
},
|
||||
control: {
|
||||
mode: uiConfig.controlMode,
|
||||
levelbased: {
|
||||
minLevel: uiConfig.minLevel,
|
||||
startLevel: uiConfig.startLevel,
|
||||
stopLevel: uiConfig.stopLevel,
|
||||
holdLevel: uiConfig.holdLevel,
|
||||
maxLevel: uiConfig.maxLevel,
|
||||
// Editor names the field levelCurveType; runtime uses curveType.
|
||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||
logCurveFactor: uiConfig.logCurveFactor,
|
||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||
shiftLevel: uiConfig.shiftLevel,
|
||||
shiftArmPercent: uiConfig.shiftArmPercent,
|
||||
deadZoneKeepAlivePercent: uiConfig.deadZoneKeepAlivePercent,
|
||||
},
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
||||
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
||||
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
|
||||
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
|
||||
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
||||
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
||||
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds,
|
||||
},
|
||||
output: {
|
||||
process: uiConfig.processOutputFormat,
|
||||
dbase: uiConfig.dbaseOutputFormat,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the core logic and store as source.
|
||||
*/
|
||||
_setupSpecificClass() {
|
||||
this.source = new Specific(this.config);
|
||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind Node-RED status updates.
|
||||
*/
|
||||
_bindEvents() {
|
||||
|
||||
}
|
||||
|
||||
// init registration msg
|
||||
_registerChild() {
|
||||
setTimeout(() => {
|
||||
this.node.send([
|
||||
null,
|
||||
null,
|
||||
{ topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null},
|
||||
]);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
_updateNodeStatus() {
|
||||
const ps = this.source;
|
||||
try {
|
||||
// --- 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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// any time based functions here
|
||||
_startTickLoop() {
|
||||
setTimeout(() => {
|
||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
||||
|
||||
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
|
||||
this._statusInterval = setInterval(() => {
|
||||
const status = this._updateNodeStatus();
|
||||
this.node.status(status);
|
||||
}, 1000);
|
||||
|
||||
}, 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();
|
||||
});
|
||||
// Test-only entrypoint mirroring the basin-docs config-mapping surface.
|
||||
// Lets `NodeClass.prototype._loadConfig.call({name:'pumpingStation'}, ui, node)`
|
||||
// produce the merged config without instantiating a full Node-RED adapter.
|
||||
// Production wiring goes through BaseNodeAdapter; this is a thin shim.
|
||||
_loadConfig(uiConfig, node) {
|
||||
const cfgMgr = new configManager();
|
||||
const name = this.name || 'pumpingStation';
|
||||
const domain = nodeClass.prototype.buildDomainConfig.call(this, uiConfig);
|
||||
this.defaultConfig = cfgMgr.getConfig(name);
|
||||
this.config = cfgMgr.buildConfig(name, uiConfig, node && node.id, domain);
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
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, []);
|
||||
});
|
||||
103
test/integration/basic-dashboard-flow.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
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, 14);
|
||||
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, 14);
|
||||
assert.equal(out[0].payload, 'levelbased');
|
||||
assert.equal(out[1].payload, 'filling');
|
||||
assert.equal(out[2].payload, '3.25 m');
|
||||
assert.equal(out[3].payload, '32.50 m³');
|
||||
assert.equal(out[4].payload, '65.00 %');
|
||||
assert.equal(out[5].payload, '25.0 %');
|
||||
assert.deepEqual(out[7], { topic: 'Level', payload: 3.25 });
|
||||
assert.deepEqual(out[8], { topic: 'Volume', payload: 32.5 });
|
||||
assert.deepEqual(out[9], { topic: 'Volume %', payload: 65 });
|
||||
assert.deepEqual(out[10], { topic: 'Inflow', payload: 18 });
|
||||
assert.deepEqual(out[11], { topic: 'Outflow', payload: 7.2 });
|
||||
assert.deepEqual(out[12], { topic: 'Net', payload: 10.8 });
|
||||
assert.ok(Array.isArray(out[13].payload));
|
||||
});
|
||||
|
||||
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const parser = flow.find((n) => n.id === '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();
|
||||
}
|
||||
});
|
||||
949
tools/build-examples.js
Normal file
@@ -0,0 +1,949 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* build-examples.js — regenerate the three example flows for pumpingStation.
|
||||
*
|
||||
* Source of truth for the Tier 1/2/3 example flows under examples/.
|
||||
* Follows EVOLV/.claude/rules/node-red-flow-layout.md:
|
||||
* - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800]
|
||||
* - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9,
|
||||
* Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd)
|
||||
* - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*)
|
||||
* - ui-chart objects carry every mandatory key (interpolation, yAxisProperty,
|
||||
* xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any
|
||||
* causes FlowFuse to render the chart blank with no error.
|
||||
*
|
||||
* Only canonical pumpingStation topic names are used (per CONTRACT.md):
|
||||
* set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level.
|
||||
*
|
||||
* Run from repo root or any cwd:
|
||||
* node nodes/pumpingStation/tools/build-examples.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUT_DIR = path.join(__dirname, '..', 'examples');
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Layout constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800];
|
||||
const S88 = {
|
||||
AR: '#0f52a5',
|
||||
PC: '#0c99d9',
|
||||
UN: '#50a8d9',
|
||||
EM: '#86bbdd',
|
||||
CM: '#a9daee',
|
||||
neutral: '#dddddd',
|
||||
};
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1',
|
||||
'#D62728', '#FF9896', '#9467BD', '#C5B0D5',
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function tab(id, label, info) {
|
||||
return { id, type: 'tab', label, disabled: false, info: info || '' };
|
||||
}
|
||||
|
||||
function comment(id, z, name, x, y) {
|
||||
return { id, type: 'comment', z, name, info: '', x, y, wires: [] };
|
||||
}
|
||||
|
||||
function linkOut(id, z, name, x, y, links) {
|
||||
return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] };
|
||||
}
|
||||
|
||||
function linkIn(id, z, name, x, y, links, downstream) {
|
||||
return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] };
|
||||
}
|
||||
|
||||
function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) {
|
||||
const o = opts || {};
|
||||
return {
|
||||
id, type: 'inject', z, name,
|
||||
props: [
|
||||
{ p: 'topic', vt: 'str' },
|
||||
{ p: 'payload', v: String(payload), vt: payloadType },
|
||||
],
|
||||
topic,
|
||||
repeat: o.repeat || '',
|
||||
crontab: '',
|
||||
once: !!o.once,
|
||||
onceDelay: o.onceDelay || '',
|
||||
x, y,
|
||||
wires: [wires || []],
|
||||
};
|
||||
}
|
||||
|
||||
function fn(id, z, name, code, x, y, wires, outputs) {
|
||||
return {
|
||||
id, type: 'function', z, name,
|
||||
func: code,
|
||||
outputs: outputs || 1,
|
||||
noerr: 0,
|
||||
initialize: '',
|
||||
finalize: '',
|
||||
libs: [],
|
||||
x, y,
|
||||
wires: wires || [[]],
|
||||
};
|
||||
}
|
||||
|
||||
function debugNode(id, z, name, x, y, complete, targetType, active) {
|
||||
return {
|
||||
id, type: 'debug', z, name,
|
||||
active: active !== false,
|
||||
tosidebar: true,
|
||||
console: false,
|
||||
tostatus: false,
|
||||
complete: complete || 'payload',
|
||||
targetType: targetType || 'msg',
|
||||
x, y, wires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function group(id, z, name, color, nodes, bbox) {
|
||||
return {
|
||||
id, type: 'group', z, name,
|
||||
style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' },
|
||||
nodes,
|
||||
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
|
||||
};
|
||||
}
|
||||
|
||||
function bboxOf(nodeList, ids, pad) {
|
||||
const p = pad == null ? 20 : pad;
|
||||
const ns = nodeList.filter((n) => ids.includes(n.id));
|
||||
const xs = ns.map((n) => n.x || 0);
|
||||
const ys = ns.map((n) => n.y || 0);
|
||||
const minX = Math.min(...xs) - p;
|
||||
const minY = Math.min(...ys) - p - 20;
|
||||
const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p;
|
||||
const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p;
|
||||
return { x: minX, y: minY, w, h };
|
||||
}
|
||||
|
||||
/* Build a fully-specified pumpingStation node. Every config field is set
|
||||
* explicitly per rule §9 (no schema-default reliance for operational
|
||||
* parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m,
|
||||
* overflow at 3.2 m. Level thresholds chosen so levelbased control activates
|
||||
* mid-tank and saturates near overflow.
|
||||
*/
|
||||
function pumpingStationNode(id, z, name, x, y, wires) {
|
||||
return {
|
||||
id, type: 'pumpingStation', z, name,
|
||||
simulator: false,
|
||||
basinVolume: 50,
|
||||
basinHeight: 3.5,
|
||||
inflowLevel: 3.0,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 3.2,
|
||||
defaultFluid: 'wastewater',
|
||||
inletPipeDiameter: 0.3,
|
||||
outletPipeDiameter: 0.3,
|
||||
pipelineLength: 80,
|
||||
maxDischargeHead: 24,
|
||||
staticHead: 12,
|
||||
maxInflowRate: 200,
|
||||
temperatureReferenceDegC: 15,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
enableDryRunProtection: true,
|
||||
enableOverfillProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
overfillThresholdPercent: 98,
|
||||
minHeightBasedOn: 'outlet',
|
||||
processOutputFormat: 'process',
|
||||
dbaseOutputFormat: 'influxdb',
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 1,
|
||||
uuid: 'example-ps-001',
|
||||
supplier: 'WBD-RD',
|
||||
category: 'station',
|
||||
assetType: 'pumpingstation',
|
||||
model: 'demo-50m3',
|
||||
unit: 'm3/h',
|
||||
enableLog: true,
|
||||
logLevel: 'info',
|
||||
positionVsParent: 'atEquipment',
|
||||
positionIcon: '',
|
||||
hasDistance: false,
|
||||
distance: '',
|
||||
distanceUnit: 'm',
|
||||
distanceDescription: '',
|
||||
controlMode: 'levelbased',
|
||||
startLevel: 1.2,
|
||||
minLevel: 0.4,
|
||||
maxLevel: 2.8,
|
||||
flowSetpoint: null,
|
||||
flowDeadband: null,
|
||||
x, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
function measurementLevelNode(id, z, name, x, y, wires) {
|
||||
return {
|
||||
id, type: 'measurement', z, name,
|
||||
mode: 'analog',
|
||||
channels: '[]',
|
||||
scaling: false,
|
||||
i_min: 0, i_max: 0, i_offset: 0,
|
||||
o_min: 0, o_max: 1,
|
||||
simulator: true,
|
||||
smooth_method: 'mean',
|
||||
count: 5,
|
||||
processOutputFormat: 'process',
|
||||
dbaseOutputFormat: 'influxdb',
|
||||
uuid: 'example-level-001',
|
||||
supplier: 'vega',
|
||||
category: 'sensor',
|
||||
assetType: 'level',
|
||||
model: 'VEGAPULS-31',
|
||||
unit: 'm',
|
||||
assetTagNumber: 'LT-001',
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
positionVsParent: 'atEquipment',
|
||||
positionIcon: '',
|
||||
hasDistance: false,
|
||||
distance: 0,
|
||||
distanceUnit: 'm',
|
||||
distanceDescription: '',
|
||||
x, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
function machineGroupControlNode(id, z, name, x, y, wires) {
|
||||
return {
|
||||
id, type: 'machineGroupControl', z, name,
|
||||
enableLog: true,
|
||||
logLevel: 'info',
|
||||
positionVsParent: 'atEquipment',
|
||||
positionIcon: '',
|
||||
hasDistance: false,
|
||||
distance: '',
|
||||
distanceUnit: 'm',
|
||||
x, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
function rotatingMachineNode(id, z, name, uuid, x, y, wires) {
|
||||
return {
|
||||
id, type: 'rotatingMachine', z, name,
|
||||
speed: '1',
|
||||
startup: '2', warmup: '1', shutdown: '2', cooldown: '1',
|
||||
movementMode: 'staticspeed',
|
||||
machineCurve: '',
|
||||
uuid,
|
||||
supplier: 'hidrostal',
|
||||
category: 'pump',
|
||||
assetType: 'pump-centrifugal',
|
||||
model: 'hidrostal-H05K-S03R',
|
||||
unit: 'm3/h',
|
||||
curvePressureUnit: 'mbar',
|
||||
curveFlowUnit: 'm3/h',
|
||||
curvePowerUnit: 'kW',
|
||||
curveControlUnit: '%',
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
positionVsParent: 'atEquipment',
|
||||
positionIcon: '',
|
||||
hasDistance: false,
|
||||
distance: '',
|
||||
distanceUnit: 'm',
|
||||
distanceDescription: '',
|
||||
x, y,
|
||||
wires: wires || [[], [], []],
|
||||
};
|
||||
}
|
||||
|
||||
/* FlowFuse ui-chart with every required key (per layout rule §4). */
|
||||
function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) {
|
||||
return {
|
||||
id, type: 'ui-chart', z, group, name, label,
|
||||
order, width: 12, height: 6,
|
||||
chartType: 'line',
|
||||
category: 'topic',
|
||||
categoryType: 'msg',
|
||||
xAxisLabel: 'time',
|
||||
xAxisType: 'time',
|
||||
xAxisProperty: '',
|
||||
xAxisPropertyType: 'timestamp',
|
||||
xAxisFormat: '',
|
||||
xAxisFormatType: 'auto',
|
||||
yAxisLabel,
|
||||
yAxisProperty: 'payload',
|
||||
yAxisPropertyType: 'msg',
|
||||
xmin: '', xmax: '', ymin: '', ymax: '',
|
||||
bins: 10,
|
||||
action: 'append',
|
||||
stackSeries: false,
|
||||
pointShape: 'circle',
|
||||
pointRadius: 4,
|
||||
interpolation: 'linear',
|
||||
showLegend: true,
|
||||
className: '',
|
||||
removeOlder: '15',
|
||||
removeOlderUnit: '60',
|
||||
removeOlderPoints: '200',
|
||||
colors: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS,
|
||||
textColor: ['#666666'],
|
||||
textColorDefault: true,
|
||||
gridColor: ['#e5e5e5'],
|
||||
gridColorDefault: true,
|
||||
x, y, wires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function uiText(id, z, group, name, label, order, x, y, format) {
|
||||
return {
|
||||
id, type: 'ui-text', z, group, name, label,
|
||||
order, width: 4, height: 1,
|
||||
format: format || '{{msg.payload}}',
|
||||
layout: 'row-spread',
|
||||
x, y, wires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) {
|
||||
return {
|
||||
id, type: 'ui-slider', z, group, name, label,
|
||||
order, width: 6, height: 1,
|
||||
passthru: true,
|
||||
outs: 'end',
|
||||
topic,
|
||||
topicType: 'str',
|
||||
min, max, step,
|
||||
icon: '',
|
||||
thumbLabel: 'always',
|
||||
showValue: true,
|
||||
className: '',
|
||||
x, y, wires: [[]],
|
||||
};
|
||||
}
|
||||
|
||||
function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) {
|
||||
return {
|
||||
id, type: 'ui-dropdown', z, group, name, label,
|
||||
order, width: 6, height: 1,
|
||||
passthru: true,
|
||||
multiple: false,
|
||||
options: options.map((o) => ({ label: o, value: o, type: 'str' })),
|
||||
payload: '',
|
||||
topic,
|
||||
topicType: 'str',
|
||||
x, y,
|
||||
wires: [wires || []],
|
||||
};
|
||||
}
|
||||
|
||||
function uiBase(id) {
|
||||
return {
|
||||
id, type: 'ui-base',
|
||||
name: 'EVOLV Demo',
|
||||
path: '/dashboard',
|
||||
appIcon: '',
|
||||
includeClientData: true,
|
||||
acceptsClientConfig: ['ui-notification', 'ui-control'],
|
||||
showPathInSidebar: false,
|
||||
headerContent: 'page',
|
||||
navigationStyle: 'default',
|
||||
titleBarStyle: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
function uiTheme(id) {
|
||||
return {
|
||||
id, type: 'ui-theme',
|
||||
name: 'EVOLV Theme',
|
||||
colors: {
|
||||
surface: '#ffffff', primary: '#0c99d9', bgPage: '#eeeeee',
|
||||
groupBg: '#ffffff', groupOutline: '#cccccc',
|
||||
},
|
||||
sizes: {
|
||||
density: 'default', pagePadding: '14px', groupGap: '14px',
|
||||
groupBorderRadius: '6px', widgetGap: '12px',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uiPage(id, base, theme, name, path, order) {
|
||||
return {
|
||||
id, type: 'ui-page', name, ui: base, path,
|
||||
icon: 'water',
|
||||
layout: 'grid', theme,
|
||||
breakpoints: [{ name: 'Default', px: '0', cols: '12' }],
|
||||
order, className: '',
|
||||
};
|
||||
}
|
||||
|
||||
function uiGroup(id, page, name, width, height, order) {
|
||||
return {
|
||||
id, type: 'ui-group', name, page, width, height, order,
|
||||
showTitle: true, className: '',
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tier 1 — 01-Basic.json */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildBasic() {
|
||||
const Z = 'ps_basic_tab';
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(tab(Z, 'PumpingStation - Basic',
|
||||
'Tier 1: single pumpingStation node driven by inject nodes only. ' +
|
||||
'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.'));
|
||||
|
||||
nodes.push(comment('ps_basic_title', Z,
|
||||
'PumpingStation - Basic\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' +
|
||||
'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' +
|
||||
'only when set.mode = manual.\n\n' +
|
||||
'HOW TO USE:\n' +
|
||||
' 1. Deploy the flow.\n' +
|
||||
' 2. Click "set.mode = manual" so set.demand is honoured.\n' +
|
||||
' 3. Click "set.inflow = 60 m3/h" to push wastewater into the basin.\n' +
|
||||
' 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n' +
|
||||
' 5. Click "calibrate volume 25 m3" to jump straight to half-full.\n\n' +
|
||||
'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' +
|
||||
'warning - fresh flows use the canonical names.', 600, 40));
|
||||
|
||||
// Lane 0: link-in placeholders (none for Tier 1 - all inputs are local).
|
||||
// Lane 2..3: inject nodes (we keep them in lane 1 for proximity).
|
||||
const injectMode = inject('ps_basic_inj_mode', Z, 'set.mode = manual', 'set.mode', 'manual', 'str', 200, 160, ['ps_basic_node']);
|
||||
const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode', 'levelbased', 'str', 220, 200, ['ps_basic_node']);
|
||||
const injectInflow = inject('ps_basic_inj_inflow', Z, 'set.inflow = 60 m3/h', 'set.inflow', '60', 'num', 200, 260, ['ps_basic_node']);
|
||||
const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 %', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node']);
|
||||
const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']);
|
||||
const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']);
|
||||
nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl);
|
||||
|
||||
// Lane 5 (PC): the pumpingStation itself.
|
||||
const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300,
|
||||
[['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]);
|
||||
nodes.push(ps);
|
||||
|
||||
// Lane 6: format/merge function for Port 0.
|
||||
const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format',
|
||||
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||
"const cache = context.get('c') || {};\n" +
|
||||
"Object.assign(cache, p);\n" +
|
||||
"context.set('c', cache);\n" +
|
||||
"function pick(prefix) {\n" +
|
||||
" for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n" +
|
||||
" const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n" +
|
||||
" } return null;\n" +
|
||||
"}\n" +
|
||||
"const vol = pick('volume.predicted.atequipment');\n" +
|
||||
"const lvl = pick('level.predicted.atequipment');\n" +
|
||||
"const flIn = pick('flow.predicted.in');\n" +
|
||||
"msg.payload = {\n" +
|
||||
" state: cache.state || 'unknown',\n" +
|
||||
" controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
|
||||
" direction: cache.direction || 'n/a',\n" +
|
||||
" percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n" +
|
||||
" volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n" +
|
||||
" volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n" +
|
||||
" level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n" +
|
||||
" inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n" +
|
||||
" timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n" +
|
||||
" timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n" +
|
||||
"};\nreturn msg;",
|
||||
LANE_X[6], 280, [['ps_basic_dbg_process']]);
|
||||
nodes.push(formatFn);
|
||||
|
||||
// Lane 7: debug taps.
|
||||
nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true));
|
||||
nodes.push(debugNode('ps_basic_dbg_influx', Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false));
|
||||
nodes.push(debugNode('ps_basic_dbg_parent', Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true));
|
||||
|
||||
// Wrap the station + its formatter in a Process Cell group box.
|
||||
const psGroupIds = ['ps_basic_node', 'ps_basic_format'];
|
||||
nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds,
|
||||
bboxOf(nodes, psGroupIds, 30)));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tier 2 — 02-Integration.json */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildIntegration() {
|
||||
const TAB_PROC = 'ps_int_proc';
|
||||
const TAB_SETUP = 'ps_int_setup';
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(tab(TAB_PROC, 'Process Plant',
|
||||
'Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. ' +
|
||||
'Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics.'));
|
||||
nodes.push(tab(TAB_SETUP, 'Setup',
|
||||
'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.'));
|
||||
|
||||
/* ---------- Process Plant tab ---------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_int_title', TAB_PROC,
|
||||
'PumpingStation - Integration\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' +
|
||||
'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' +
|
||||
'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40));
|
||||
|
||||
/* Link-ins on L0 receive from the Setup tab. */
|
||||
const linInMode = linkIn('lin_setup_mode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 500, [], ['ps_int_station']);
|
||||
const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']);
|
||||
const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']);
|
||||
nodes.push(linInMode, linInInflow, linInMgcMode);
|
||||
|
||||
/* L2: level measurement (Control Module). */
|
||||
const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor',
|
||||
LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]);
|
||||
nodes.push(levelMeas);
|
||||
// Simulator measurement injector for the level sensor (push a varying level so PS sees something).
|
||||
const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']);
|
||||
nodes.push(levelInj);
|
||||
|
||||
/* L3: two rotatingMachine pumps (Equipment Module). */
|
||||
const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
|
||||
LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]);
|
||||
const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
|
||||
LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]);
|
||||
nodes.push(pumpA, pumpB);
|
||||
|
||||
/* L4: MGC (Unit). */
|
||||
const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group',
|
||||
LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]);
|
||||
nodes.push(mgc);
|
||||
|
||||
/* L5: pumpingStation (Process Cell). */
|
||||
const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station',
|
||||
LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]);
|
||||
nodes.push(station);
|
||||
|
||||
/* L6: formatter for the station's Port 0. */
|
||||
const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format',
|
||||
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
|
||||
"function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
|
||||
"const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" +
|
||||
"msg.payload = {\n" +
|
||||
" state: cache.state || 'unknown',\n" +
|
||||
" controlMode: cache.controlMode || cache.mode || 'n/a',\n" +
|
||||
" direction: cache.direction || 'n/a',\n" +
|
||||
" percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n" +
|
||||
" volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n" +
|
||||
" volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n" +
|
||||
" level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n" +
|
||||
" inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
|
||||
" outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n" +
|
||||
" childCount: cache.childCount != null ? cache.childCount : 'n/a'\n" +
|
||||
"};\nreturn msg;",
|
||||
LANE_X[6], 520, [['ps_int_dbg_process']]);
|
||||
nodes.push(formatFn);
|
||||
|
||||
/* L7: debug taps for the various ports. */
|
||||
nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process', LANE_X[7], 480, 'payload', 'msg', true));
|
||||
nodes.push(debugNode('ps_int_dbg_influx', TAB_PROC, 'PS Port 1: InfluxDB', LANE_X[7], 540, 'true', 'full', false));
|
||||
nodes.push(debugNode('ps_int_dbg_mgc', TAB_PROC, 'MGC Port 0', LANE_X[7], 360, 'payload', 'msg', true));
|
||||
nodes.push(debugNode('ps_int_dbg_pa', TAB_PROC, 'Pump A Port 0', LANE_X[7], 320, 'payload', 'msg', false));
|
||||
nodes.push(debugNode('ps_int_dbg_pb', TAB_PROC, 'Pump B Port 0', LANE_X[7], 400, 'payload', 'msg', false));
|
||||
nodes.push(debugNode('ps_int_dbg_level', TAB_PROC, 'Level Port 0', LANE_X[7], 700, 'payload', 'msg', false));
|
||||
|
||||
/* Group boxes. */
|
||||
const pumpAIds = ['pump_a', 'ps_int_dbg_pa'];
|
||||
const pumpBIds = ['pump_b', 'ps_int_dbg_pb'];
|
||||
const mgcIds = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode'];
|
||||
const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow'];
|
||||
const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level'];
|
||||
nodes.push(group('grp_pumpa', TAB_PROC, 'Pump A (EM)', S88.EM, pumpAIds, bboxOf(nodes, pumpAIds, 25)));
|
||||
nodes.push(group('grp_pumpb', TAB_PROC, 'Pump B (EM)', S88.EM, pumpBIds, bboxOf(nodes, pumpBIds, 25)));
|
||||
nodes.push(group('grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, mgcIds, bboxOf(nodes, mgcIds, 25)));
|
||||
nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25)));
|
||||
nodes.push(group('grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, levelIds, bboxOf(nodes, levelIds, 25)));
|
||||
|
||||
/* ---------- Setup tab ----------------------------------------- */
|
||||
|
||||
nodes.push(comment('setup_title', TAB_SETUP,
|
||||
'Deploy-time setup\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' +
|
||||
'set.demand topics across cross-tab channels into the Process Plant tab.',
|
||||
LANE_X[2], 40));
|
||||
|
||||
const setMode = inject('setup_inj_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'], { once: true, onceDelay: '0.5' });
|
||||
const setMgc = inject('setup_inj_mgcmode', TAB_SETUP, 'MGC set.mode = auto', 'set.mode', 'auto', 'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' });
|
||||
const setInflow = inject('setup_inj_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0' });
|
||||
nodes.push(setMode, setMgc, setInflow);
|
||||
|
||||
const loutMode = linkOut('lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_setup_mode']);
|
||||
const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode', LANE_X[7], 220, ['lin_setup_mgcmode']);
|
||||
const loutInflow = linkOut('lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 280, ['lin_setup_inflow']);
|
||||
nodes.push(loutMode, loutMgcMode, loutInflow);
|
||||
|
||||
// Setup tab group.
|
||||
const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow',
|
||||
'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow'];
|
||||
nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tier 3 — 03-Dashboard.json */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildDashboard() {
|
||||
const TAB_PROC = 'ps_dash_proc';
|
||||
const TAB_UI = 'ps_dash_ui';
|
||||
const TAB_SETUP = 'ps_dash_setup';
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(tab(TAB_PROC, 'Process Plant',
|
||||
'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.'));
|
||||
nodes.push(tab(TAB_UI, 'Dashboard UI',
|
||||
'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.'));
|
||||
nodes.push(tab(TAB_SETUP, 'Setup',
|
||||
'Once-true injects: initial mode + initial inflow seed.'));
|
||||
|
||||
/* ---------- FlowFuse dashboard scaffolding -------------------- */
|
||||
|
||||
nodes.push(uiBase('ps_dash_base'));
|
||||
nodes.push(uiTheme('ps_dash_theme'));
|
||||
nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1));
|
||||
nodes.push(uiGroup('ps_dash_grp_ctrl', 'ps_dash_page', 'Controls', 6, 1, 1));
|
||||
nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status', 6, 1, 2));
|
||||
nodes.push(uiGroup('ps_dash_grp_trend', 'ps_dash_page', 'Live Trends', 12, 1, 3));
|
||||
|
||||
/* ---------- Process Plant tab --------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_dash_proc_title', TAB_PROC,
|
||||
'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' +
|
||||
'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.',
|
||||
600, 40));
|
||||
|
||||
/* L0 link-ins: setup + dashboard commands. */
|
||||
const linModeProc = linkIn('lin_proc_mode', TAB_PROC, 'cmd:ps-mode', LANE_X[0], 480, [], ['ps_dash_station']);
|
||||
const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand', LANE_X[0], 540, [], ['ps_dash_station']);
|
||||
const linSetupMode = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 420, [], ['ps_dash_station']);
|
||||
const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']);
|
||||
nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow);
|
||||
|
||||
/* L2 level sensor with simulator. */
|
||||
const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor',
|
||||
LANE_X[2], 700, [[], [], ['ps_dash_station']]);
|
||||
nodes.push(levelMeas);
|
||||
nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num',
|
||||
LANE_X[0], 700, ['ps_dash_meas_level']));
|
||||
|
||||
/* L3 pumps. */
|
||||
const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a',
|
||||
LANE_X[3], 320, [[], [], ['ps_dash_mgc']]);
|
||||
const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b',
|
||||
LANE_X[3], 400, [[], [], ['ps_dash_mgc']]);
|
||||
nodes.push(pumpA, pumpB);
|
||||
|
||||
/* L4 MGC. */
|
||||
const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group',
|
||||
LANE_X[4], 360, [[], [], ['ps_dash_station']]);
|
||||
nodes.push(mgc);
|
||||
|
||||
/* L5 pumpingStation. */
|
||||
const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station',
|
||||
LANE_X[5], 520, [['ps_dash_trend_split'], [], []]);
|
||||
nodes.push(station);
|
||||
|
||||
/* L6 trend-split fn: one output per chart + one output for the status text widgets.
|
||||
* Outputs:
|
||||
* 0 -> chart_flow ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h})
|
||||
* 1 -> chart_level ({topic: 'Level', payload: m})
|
||||
* 2 -> chart_volpct ({topic: 'Volume%', payload: %})
|
||||
* 3 -> text_status (compact state string)
|
||||
* 4 -> text_perc (percControl)
|
||||
* 5 -> text_direction (direction)
|
||||
* 6 -> text_timetoempty(timeToEmpty)
|
||||
*/
|
||||
const trendCode =
|
||||
"const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" +
|
||||
"const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" +
|
||||
"function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" +
|
||||
"const flowIn = pick('flow.predicted.in');\n" +
|
||||
"const flowOut = pick('flow.predicted.out');\n" +
|
||||
"const level = pick('level.predicted.atequipment');\n" +
|
||||
"const volPct = Number(cache.volumePercent);\n" +
|
||||
"const ts = Date.now();\n" +
|
||||
"const flowMsgs = [];\n" +
|
||||
"if (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\n" +
|
||||
"if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" +
|
||||
"const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" +
|
||||
"const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" +
|
||||
"const volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" +
|
||||
"const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" +
|
||||
"const percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" +
|
||||
"const dirStr = cache.direction || 'n/a';\n" +
|
||||
"const tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" +
|
||||
"return [\n" +
|
||||
" flowOut1,\n" +
|
||||
" levelOut,\n" +
|
||||
" volOut,\n" +
|
||||
" { payload: stateStr },\n" +
|
||||
" { payload: percStr },\n" +
|
||||
" { payload: dirStr },\n" +
|
||||
" { payload: tEmpty }\n" +
|
||||
"];";
|
||||
const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode,
|
||||
LANE_X[6], 520,
|
||||
[
|
||||
['lout_evt_flow'],
|
||||
['lout_evt_level'],
|
||||
['lout_evt_volpct'],
|
||||
['lout_evt_state'],
|
||||
['lout_evt_perc'],
|
||||
['lout_evt_dir'],
|
||||
['lout_evt_tempty'],
|
||||
], 7);
|
||||
nodes.push(trendSplit);
|
||||
|
||||
/* L7 link-outs into the Dashboard UI tab. */
|
||||
const loutFlow = linkOut('lout_evt_flow', TAB_PROC, 'evt:flow', LANE_X[7], 420, ['lin_ui_flow']);
|
||||
const loutLevel = linkOut('lout_evt_level', TAB_PROC, 'evt:level', LANE_X[7], 460, ['lin_ui_level']);
|
||||
const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']);
|
||||
const loutState = linkOut('lout_evt_state', TAB_PROC, 'evt:state', LANE_X[7], 540, ['lin_ui_state']);
|
||||
const loutPerc = linkOut('lout_evt_perc', TAB_PROC, 'evt:perc', LANE_X[7], 580, ['lin_ui_perc']);
|
||||
const loutDir = linkOut('lout_evt_dir', TAB_PROC, 'evt:dir', LANE_X[7], 620, ['lin_ui_dir']);
|
||||
const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']);
|
||||
nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty);
|
||||
|
||||
/* Process tab groups. */
|
||||
const procStationIds = ['ps_dash_station', 'ps_dash_trend_split',
|
||||
'lin_proc_mode', 'lin_proc_demand', 'lin_proc_setupmode', 'lin_proc_setupinflow',
|
||||
'lout_evt_flow', 'lout_evt_level', 'lout_evt_volpct', 'lout_evt_state', 'lout_evt_perc', 'lout_evt_dir', 'lout_evt_tempty'];
|
||||
const procPumpAIds = ['ps_dash_pump_a'];
|
||||
const procPumpBIds = ['ps_dash_pump_b'];
|
||||
const procMgcIds = ['ps_dash_mgc'];
|
||||
const procLevelIds = ['ps_dash_meas_level', 'ps_dash_inj_level'];
|
||||
nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_pa', TAB_PROC, 'Pump A (EM)', S88.EM, procPumpAIds, bboxOf(nodes, procPumpAIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_pb', TAB_PROC, 'Pump B (EM)', S88.EM, procPumpBIds, bboxOf(nodes, procPumpBIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, procMgcIds, bboxOf(nodes, procMgcIds, 25)));
|
||||
nodes.push(group('ps_dash_grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, procLevelIds, bboxOf(nodes, procLevelIds, 25)));
|
||||
|
||||
/* ---------- Dashboard UI tab ---------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_dash_ui_title', TAB_UI,
|
||||
'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' +
|
||||
'Sliders on L2 emit cmd:* back to Process Plant.\n' +
|
||||
'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.',
|
||||
600, 40));
|
||||
|
||||
/* L0 link-ins from the process side. */
|
||||
nodes.push(linkIn('lin_ui_flow', TAB_UI, 'evt:flow', LANE_X[0], 220, [], ['ui_chart_flow']));
|
||||
nodes.push(linkIn('lin_ui_level', TAB_UI, 'evt:level', LANE_X[0], 320, [], ['ui_chart_level']));
|
||||
nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct']));
|
||||
nodes.push(linkIn('lin_ui_state', TAB_UI, 'evt:state', LANE_X[0], 520, [], ['ui_text_state']));
|
||||
nodes.push(linkIn('lin_ui_perc', TAB_UI, 'evt:perc', LANE_X[0], 560, [], ['ui_text_perc']));
|
||||
nodes.push(linkIn('lin_ui_dir', TAB_UI, 'evt:dir', LANE_X[0], 600, [], ['ui_text_dir']));
|
||||
nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty']));
|
||||
|
||||
/* L4 charts and text widgets. */
|
||||
nodes.push(uiChart('ui_chart_flow', TAB_UI, 'ps_dash_grp_trend', 'Flow trend', 'Flow (m³/h)', 1, 'm³/h', LANE_X[4], 220));
|
||||
nodes.push(uiChart('ui_chart_level', TAB_UI, 'ps_dash_grp_trend', 'Level trend', 'Level (m)', 2, 'm', LANE_X[4], 320));
|
||||
nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %', 'Volume (%)', 3, '%', LANE_X[4], 420));
|
||||
nodes.push(uiText( 'ui_text_state', TAB_UI, 'ps_dash_grp_status','State', 'Station state',1, LANE_X[4], 520));
|
||||
nodes.push(uiText( 'ui_text_perc', TAB_UI, 'ps_dash_grp_status','percControl', 'Control %', 2, LANE_X[4], 560));
|
||||
nodes.push(uiText( 'ui_text_dir', TAB_UI, 'ps_dash_grp_status','direction', 'Direction', 3, LANE_X[4], 600));
|
||||
nodes.push(uiText( 'ui_text_tempty', TAB_UI, 'ps_dash_grp_status','timeToEmpty', 'Time to empty',4, LANE_X[4], 640));
|
||||
|
||||
/* L2 controls: dropdown for mode + slider for demand. */
|
||||
const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl',
|
||||
'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode',
|
||||
['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']);
|
||||
const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl',
|
||||
'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5);
|
||||
nodes.push(modeDropdown, demandSlider);
|
||||
// Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation).
|
||||
demandSlider.wires = [['ui_wrap_demand']];
|
||||
|
||||
/* L4 wrappers: enforce the canonical topic on the outgoing msg. */
|
||||
const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode',
|
||||
"msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;",
|
||||
LANE_X[4], 160, [['lout_cmd_mode']]);
|
||||
const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand',
|
||||
"msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||
LANE_X[4], 220, [['lout_cmd_demand']]);
|
||||
nodes.push(wrapMode, wrapDemand);
|
||||
|
||||
/* L7 link-outs to the process plant. */
|
||||
nodes.push(linkOut('lout_cmd_mode', TAB_UI, 'cmd:ps-mode', LANE_X[7], 160, ['lin_proc_mode']));
|
||||
nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand']));
|
||||
|
||||
/* UI tab groups (mirror the dashboard groups). */
|
||||
const uiCtrlIds = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand',
|
||||
'lout_cmd_mode', 'lout_cmd_demand'];
|
||||
const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty',
|
||||
'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty'];
|
||||
const uiTrendIds = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct',
|
||||
'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct'];
|
||||
nodes.push(group('grp_ui_ctrl', TAB_UI, 'Controls (PC)', S88.PC, uiCtrlIds, bboxOf(nodes, uiCtrlIds, 25)));
|
||||
nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)', S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25)));
|
||||
nodes.push(group('grp_ui_trend', TAB_UI, 'Live trends (PC)', S88.PC, uiTrendIds, bboxOf(nodes, uiTrendIds, 25)));
|
||||
|
||||
/* ---------- Setup tab ----------------------------------------- */
|
||||
|
||||
nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'Initialises set.mode = levelbased and seeds an inflow at deploy time.',
|
||||
LANE_X[2], 40));
|
||||
|
||||
nodes.push(inject('ps_dash_setup_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str',
|
||||
LANE_X[0], 160, ['ps_dash_lout_setup_mode'], { once: true, onceDelay: '0.5' }));
|
||||
nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num',
|
||||
LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0' }));
|
||||
|
||||
nodes.push(linkOut('ps_dash_lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_proc_setupmode']));
|
||||
nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow']));
|
||||
|
||||
const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow',
|
||||
'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow'];
|
||||
nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25)));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* README */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const README = `# pumpingStation - Example Flows
|
||||
|
||||
Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the
|
||||
canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`,
|
||||
\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases
|
||||
(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`,
|
||||
\`calibratePredictedLevel\`, \`registerChild\`) still work but log a
|
||||
one-time deprecation warning; these fresh flows use the canonical names only.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Tier | Tabs | Purpose |
|
||||
|---|---|---|---|
|
||||
| \`01-Basic.json\` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. |
|
||||
| \`02-Integration.json\` | 2 | Process Plant + Setup | Adds a \`measurement\` level child and a \`machineGroupControl\` parent with two \`rotatingMachine\` pumps. Demonstrates the Phase-2 parent/child handshake. |
|
||||
| \`03-Dashboard.json\` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node-RED with the EVOLV package installed (so the \`pumpingStation\`,
|
||||
\`measurement\`, \`machineGroupControl\`, and \`rotatingMachine\` node
|
||||
types are registered).
|
||||
- For \`03-Dashboard.json\`: \`@flowfuse/node-red-dashboard\` (Dashboard 2.0).
|
||||
|
||||
## How to load
|
||||
|
||||
\`\`\`bash
|
||||
# Drop a file into a running Node-RED instance using its Admin API.
|
||||
curl -X POST -H 'Content-Type: application/json' \\
|
||||
--data @nodes/pumpingStation/examples/01-Basic.json \\
|
||||
http://localhost:1880/flows
|
||||
\`\`\`
|
||||
|
||||
Or in the editor: **Menu -> Import -> select file -> Import**. The flows
|
||||
import into their own tabs and can be deployed immediately.
|
||||
|
||||
## 01-Basic - what to try
|
||||
|
||||
1. Deploy.
|
||||
2. Inject \`set.mode = manual\`.
|
||||
3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the
|
||||
formatted Port 0 payload in the debug sidebar.
|
||||
4. Inject \`set.demand = 40 %\` - in manual mode this would feed any
|
||||
registered children; here there are no pump children so it is logged
|
||||
and shown on Port 0.
|
||||
5. Inject \`cmd.calibrate.volume = 25 m3\` to jump the predicted-volume
|
||||
integrator to half-full.
|
||||
|
||||
## 02-Integration - what to try
|
||||
|
||||
1. Deploy. The Setup tab fires \`set.mode = levelbased\` to the station
|
||||
and \`set.mode = auto\` to the MGC.
|
||||
2. The two pumps register with the MGC via Port 2; the MGC and the level
|
||||
sensor register with the station via Port 2. Watch the registration
|
||||
debug taps to confirm.
|
||||
3. The level inject pushes a 1.6 m measurement so the station sees a
|
||||
non-zero starting level. Setup also seeds \`set.inflow = 60 m3/h\`.
|
||||
4. The station's \`controlMode = levelbased\` then drives the MGC, which
|
||||
dispatches to Pump A / Pump B.
|
||||
|
||||
## 03-Dashboard - what to try
|
||||
|
||||
1. Deploy.
|
||||
2. Open the dashboard at \`http://localhost:1880/dashboard/page/pumping-station\`.
|
||||
3. Use the **Control mode** dropdown to switch between \`manual\`,
|
||||
\`levelbased\`, \`flowbased\`, \`none\`.
|
||||
4. In manual mode, drag the **Manual demand** slider - the demand cascades
|
||||
to the MGC and on to the pumps.
|
||||
5. The three charts (flow, level, volume %) plot live data; the four text
|
||||
widgets show state, percControl, direction, and time-to-empty.
|
||||
|
||||
## Layout conventions
|
||||
|
||||
These flows follow the EVOLV layout rule set in
|
||||
\`.claude/rules/node-red-flow-layout.md\`:
|
||||
|
||||
- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI
|
||||
(\`ui-*\` widgets) / Setup (once-true injects).
|
||||
- Cross-tab wiring via **named link out / link in channels**:
|
||||
\`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`,
|
||||
\`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`,
|
||||
\`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`.
|
||||
- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`,
|
||||
driven by each node's S88 level (Process Cell on L5, Unit on L4,
|
||||
Equipment on L3, Control Module on L2).
|
||||
- **Group boxes** wrap each parent + its direct children, coloured by the
|
||||
parent's S88 level.
|
||||
|
||||
## Regenerating
|
||||
|
||||
These flows are generated from \`tools/build-examples.js\`. Edit the
|
||||
generator, never the JSON, then:
|
||||
|
||||
\`\`\`bash
|
||||
node nodes/pumpingStation/tools/build-examples.js
|
||||
\`\`\`
|
||||
|
||||
The script writes \`01-Basic.json\`, \`02-Integration.json\`, and
|
||||
\`03-Dashboard.json\` into this directory.
|
||||
`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function writeFlow(filename, builder) {
|
||||
const flow = builder();
|
||||
const dest = path.join(OUT_DIR, filename);
|
||||
fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8');
|
||||
console.log(`wrote ${dest} (${flow.length} nodes)`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFlow('01-Basic.json', buildBasic);
|
||||
writeFlow('02-Integration.json', buildIntegration);
|
||||
writeFlow('03-Dashboard.json', buildDashboard);
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8');
|
||||
console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`);
|
||||
}
|
||||
|
||||
main();
|
||||
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 |
|
||||
164
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 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 `units: { measure, default }` declaration. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||
|---|---|---|---|---|
|
||||
| `set.mode` | `changemode` | `string` | — | Switch the station between auto / manual control modes. |
|
||||
| `child.register` | `registerChild` | `string` | — | Register a child node (machine group, measurement, …) with this station. |
|
||||
| `cmd.calibrate.volume` | `calibratePredictedVolume` | any | `volume` (default `m3`) | Calibrate the predicted-volume integrator to a known basin volume. |
|
||||
| `cmd.calibrate.level` | `calibratePredictedLevel` | any | `length` (default `m`) | Calibrate the predicted-volume integrator to a known basin level. |
|
||||
| `set.inflow` | `q_in` | any | `volumeFlowRate` (default `m3/h`) | Push a measured inflow value into the basin balance. |
|
||||
| `set.outflow` | `q_out` | any | `volumeFlowRate` (default `m3/h`) | Push a measured outflow value into the basin balance. |
|
||||
| `set.demand` | `Qd` | any | `volumeFlowRate` (default `m3/h`) | Operator outflow demand setpoint for the station. |
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
|
||||
---
|
||||
|
||||
## Data model — `getOutput()` shape
|
||||
|
||||
Keys composed each tick by `specificClass.getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
|
||||
|
||||
<!-- BEGIN AUTOGEN: data-model -->
|
||||
|
||||
| Key | Type | Unit | Sample |
|
||||
|---|---|---|---|
|
||||
| `direction` | string | — | `"steady"` |
|
||||
| `dryRunLevel` | number | — | `0.20400000000000001` |
|
||||
| `dryRunSafetyVol` | number | — | `0.20400000000000001` |
|
||||
| `flowSource` | null | — | `null` |
|
||||
| `heightBasin` | number | m | `1` |
|
||||
| `highVolumeSafetyLevel` | number | — | `2.45` |
|
||||
| `highVolumeSafetyVol` | number | — | `2.45` |
|
||||
| `inflowLevel` | number | m | `2` |
|
||||
| `inletPipeDiameter` | number | — | `0.4` |
|
||||
| `maxVol` | number | m3 | `1` |
|
||||
| `maxVolAtOverflow` | number | m3 | `2.5` |
|
||||
| `minHeightBasedOn` | string | — | `"outlet"` |
|
||||
| `minVol` | number | m3 | `0.2` |
|
||||
| `minVolAtInflow` | number | m3 | `2` |
|
||||
| `minVolAtOutflow` | number | m3 | `0.2` |
|
||||
| `outflowLevel` | number | m | `0.2` |
|
||||
| `outletPipeDiameter` | number | — | `0.4` |
|
||||
| `overflowLevel` | number | m | `2.5` |
|
||||
| `percControl` | number | % | `0` |
|
||||
| `predictedOverflowRate` | number | — | `0` |
|
||||
| `predictedOverflowVolume` | number | — | `0` |
|
||||
| `predictedUnderflowVolume` | number | — | `0` |
|
||||
| `surfaceArea` | number | m2 | `1` |
|
||||
| `timeleft` | null | s | `null` |
|
||||
| `volEmptyBasin` | number | m3 | `1` |
|
||||
| `volume.predicted.atequipment.wikigen-pumpingstation-id` | number | m3 | `0.2` |
|
||||
|
||||
<!-- END AUTOGEN: data-model -->
|
||||
|
||||
Sample values come from a stub instantiation in `wikiGen` — in a live deployment the volume key is shaped `volume.<variant>.<position>.<childId>` per the standard [Measurement Key Shape](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions#measurement-key-shape).
|
||||
|
||||
---
|
||||
|
||||
## Configuration schema — 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 |