Compare commits
42 Commits
1ebbcb62cc
...
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 |
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
|
||||
17
CLAUDE.md
@@ -21,3 +21,20 @@ Key points for this node:
|
||||
- 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,23 +8,32 @@
|
||||
| **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
|
||||
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
overflowLevel: { 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
|
||||
@@ -35,9 +44,11 @@
|
||||
temperatureReferenceDegC: { value: 15 },
|
||||
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
|
||||
enableDryRunProtection: { value: true },
|
||||
enableOverfillProtection: { value: true },
|
||||
enableHighVolumeSafety: { value: true },
|
||||
enableOverfillProtection: { value: true }, // deprecated alias
|
||||
dryRunThresholdPercent: { value: 2 },
|
||||
overfillThresholdPercent: { value: 98 },
|
||||
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" },
|
||||
@@ -67,10 +78,18 @@
|
||||
distanceDescription: { value: "" },
|
||||
|
||||
// control strategy
|
||||
controlMode: { value: "none" },
|
||||
startLevel: { value: null },
|
||||
minLevel: { value: null },
|
||||
maxLevel: { value: null },
|
||||
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 }
|
||||
|
||||
@@ -86,152 +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-inflowLevel");
|
||||
document.getElementById("node-input-outflowLevel");
|
||||
document.getElementById("node-input-overflowLevel");
|
||||
document.getElementById("node-input-refHeight");
|
||||
document.getElementById("node-input-basinBottomRef");
|
||||
|
||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
||||
if (refHeightEl) {
|
||||
refHeightEl.value = this.refHeight || "NAP";
|
||||
}
|
||||
|
||||
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
|
||||
if (minHeightBasedOnEl) {
|
||||
minHeightBasedOnEl.value = this.minHeightBasedOn;
|
||||
}
|
||||
|
||||
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
|
||||
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
|
||||
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
|
||||
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) { return; }
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!this.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (overfillToggle && overfillPercent) {
|
||||
overfillToggle.checked = !!this.enableOverfillProtection;
|
||||
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
|
||||
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
|
||||
toggleInput(overfillToggle, overfillPercent);
|
||||
}
|
||||
|
||||
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
|
||||
if (timeLeftInput) {
|
||||
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
|
||||
? this.timeleftToFullOrEmptyThresholdSeconds
|
||||
: 0;
|
||||
}
|
||||
|
||||
// control mode toggle UI
|
||||
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 = this.controlMode || 'none';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
const setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
setNumberField('node-input-startLevel', this.startLevel);
|
||||
setNumberField('node-input-minLevel', this.minLevel);
|
||||
setNumberField('node-input-maxLevel', this.maxLevel);
|
||||
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
|
||||
setNumberField('node-input-flowDeadband', this.flowDeadband);
|
||||
|
||||
// Live-compute derived safety levels so the operator can see
|
||||
// what the % will actually trip at. Mirrors the code formula
|
||||
// in specificClass._validateThresholdOrdering.
|
||||
const fNum = (id) => parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
const updateDerivedLevels = () => {
|
||||
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
|
||||
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
|
||||
const dryRunPct = fNum('dryRunThresholdPercent');
|
||||
const overfillPct = fNum('overfillThresholdPercent');
|
||||
const overflow = fNum('overflowLevel');
|
||||
const dryRunLvl = Number.isFinite(refLow) && Number.isFinite(dryRunPct)
|
||||
? refLow * (1 + dryRunPct / 100) : null;
|
||||
const overfillLvl = Number.isFinite(overflow) && Number.isFinite(overfillPct)
|
||||
? overflow * (overfillPct / 100) : null;
|
||||
const dryEl = document.getElementById('derived-dryRunLevel');
|
||||
const ovfEl = document.getElementById('derived-overfillLevel');
|
||||
if (dryEl) dryEl.textContent = dryRunLvl != null ? `→ dryRunLevel ≈ ${dryRunLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
|
||||
if (ovfEl) ovfEl.textContent = overfillLvl != null ? `→ overfillLevel ≈ ${overfillLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
|
||||
};
|
||||
['inflowLevel','outflowLevel','overflowLevel','minHeightBasedOn','dryRunThresholdPercent','overfillThresholdPercent']
|
||||
.forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) { el.addEventListener('input', updateDerivedLevels); el.addEventListener('change', updateDerivedLevels); }
|
||||
});
|
||||
setTimeout(updateDerivedLevels, 50);
|
||||
|
||||
//------------------- 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.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
|
||||
node.simulator = document.getElementById("node-input-simulator").checked;
|
||||
|
||||
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
|
||||
.forEach(field => {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
});
|
||||
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
||||
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
|
||||
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
|
||||
|
||||
// control strategy
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.minLevel = parseNum('node-input-minLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
node.flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
node.flowDeadband = parseNum('node-input-flowDeadband');
|
||||
|
||||
window.PSEditor.oneditsave.call(this);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -250,30 +128,205 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Basin Geometry</h4>
|
||||
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">All heights measured from the basin floor (0 m).</p>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<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 -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-inflowLevel"><i class="fa fa-long-arrow-up"></i> Inlet (bottom of pipe, m)</label>
|
||||
<input type="number" id="node-input-inflowLevel" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-outflowLevel"><i class="fa fa-long-arrow-down"></i> Outlet (top of pipe, m)</label>
|
||||
<input type="number" id="node-input-outflowLevel" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-overflowLevel"><i class="fa fa-tint"></i> Overflow (weir crest, m)</label>
|
||||
<input type="number" id="node-input-overflowLevel" min="0" step="0.01" />
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -281,50 +334,193 @@
|
||||
<div class="form-row">
|
||||
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
|
||||
<select id="node-input-controlMode">
|
||||
<option value="none">None / Manual</option>
|
||||
<option value="levelbased">Level-based</option>
|
||||
<option value="flowbased">Flow-based</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="ps-mode-levelbased" class="ps-mode-section">
|
||||
<div class="form-row">
|
||||
<label for="node-input-minLevel">minLevel (m)</label>
|
||||
<input type="number" id="node-input-minLevel" placeholder="m" />
|
||||
<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-startLevel">startLevel (m)</label>
|
||||
<input type="number" id="node-input-startLevel" placeholder="m" />
|
||||
<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 class="form-row">
|
||||
<label for="node-input-maxLevel">maxLevel (m)</label>
|
||||
<input type="number" id="node-input-maxLevel" placeholder="m" />
|
||||
<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 id="ps-mode-flowbased" class="ps-mode-section" style="display:none">
|
||||
<div class="form-row">
|
||||
<label for="node-input-flowSetpoint">Flow setpoint</label>
|
||||
<input type="number" id="node-input-flowSetpoint" placeholder="m3/h" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-flowDeadband">Deadband</label>
|
||||
<input type="number" id="node-input-flowDeadband" placeholder="m3/h" />
|
||||
</div>
|
||||
<div id="ps-mode-manual" class="ps-mode-section" style="display:none">
|
||||
<p style="font-size:12px;color:#777;margin:0;">Manual mode accepts external <code>Qd</code> demand commands and does not compute demand from basin level.</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Reference</h4>
|
||||
|
||||
<!-- Reference data -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
|
||||
<select id="node-input-minHeightBasedOn" style="width:60%;">
|
||||
<option value="inlet">Inlet Elevation</option>
|
||||
<option value="outlet">Outlet Elevation</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 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%;">
|
||||
@@ -332,21 +528,11 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin floor above datum (m)</label>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Safety</h4>
|
||||
|
||||
<!-- Safety settings -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
|
||||
<input type="number" id="node-input-timeleftToFullOrEmptyThresholdSeconds" min="0" step="1" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableDryRunProtection">
|
||||
<i class="fa fa-shield"></i> Dry-run Protection
|
||||
@@ -361,16 +547,16 @@
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableOverfillProtection">
|
||||
<i class="fa fa-exclamation-triangle"></i> Overfill Protection
|
||||
<label for="node-input-enableHighVolumeSafety">
|
||||
<i class="fa fa-exclamation-triangle"></i> High-volume Safety
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Stop filling when approaching overflow</span>
|
||||
<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-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label>
|
||||
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-overfillLevel" style="margin-left:8px;color:#777;font-size:12px;">→ overfillLevel ≈ — m</span>
|
||||
<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>
|
||||
|
||||
@@ -387,6 +573,7 @@
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="frost">frost</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
@@ -49,6 +49,7 @@ module.exports = {
|
||||
| `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` |
|
||||
|
||||
@@ -54,6 +54,10 @@ function evalExpectation(ex, records) {
|
||||
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})` };
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
//
|
||||
// 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
|
||||
// startLevel and maxLevel) at roughly the point where demand matches
|
||||
// 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: 1200,
|
||||
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 },
|
||||
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 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableOverfillProtection: true,
|
||||
overfillThresholdPercent: 98,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
@@ -44,7 +44,7 @@ module.exports = {
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.0); // start at the bottom of the RAMP zone
|
||||
ps.calibratePredictedLevel(2.0); // start at the mode start level, below the rising ramp
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
@@ -55,6 +55,7 @@ module.exports = {
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
|
||||
// level rises toward overflow then recedes.
|
||||
// Storm surge — inflow triples briefly, pumps should increase demand as
|
||||
// the level enters the rising ramp.
|
||||
//
|
||||
// Expectation: during the surge (t=300..600), demand reaches 100% and
|
||||
// level may transiently climb above maxLevel. Overflow safety should
|
||||
// fire if the surge overwhelms pump capacity; dry-run should not fire.
|
||||
// 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. Overfill safety may engage.',
|
||||
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 },
|
||||
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 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableOverfillProtection: true,
|
||||
overfillThresholdPercent: 95,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 95,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
@@ -55,6 +55,6 @@ module.exports = {
|
||||
{ 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 saturates at 100% during surge', type: 'max_demand_bounded', value: 100 },
|
||||
{ name: 'demand remains bounded during surge', type: 'max_demand_bounded', value: 100 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,18 +12,18 @@ module.exports = {
|
||||
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 },
|
||||
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 },
|
||||
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 50,
|
||||
enableOverfillProtection: false,
|
||||
overfillThresholdPercent: 98,
|
||||
enableHighVolumeSafety: false,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
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;
|
||||
289
src/nodeClass.js
@@ -1,261 +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,
|
||||
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,
|
||||
maxInflowRate: uiConfig.maxInflowRate,
|
||||
staticHead: uiConfig.staticHead,
|
||||
maxDischargeHead: uiConfig.maxDischargeHead,
|
||||
pipelineLength: uiConfig.pipelineLength,
|
||||
defaultFluid: uiConfig.defaultFluid,
|
||||
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
||||
},
|
||||
control:{
|
||||
control: {
|
||||
mode: uiConfig.controlMode,
|
||||
levelbased:{
|
||||
minLevel:uiConfig.minLevel,
|
||||
startLevel:uiConfig.startLevel,
|
||||
maxLevel:uiConfig.maxLevel
|
||||
}
|
||||
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:{
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const pickVariant = (type, prefer = ['measured', 'predicted'], position = 'atEquipment', unit) => {
|
||||
for (const variant of prefer) {
|
||||
const chain = ps.measurements.type(type).variant(variant).position(position);
|
||||
const value = unit ? chain.getCurrentValue(unit) : chain.getCurrentValue();
|
||||
if (value != null) return { value, variant };
|
||||
}
|
||||
return { value: null, variant: null };
|
||||
};
|
||||
|
||||
const vol = pickVariant('volume', ['measured', 'predicted'], 'atEquipment', 'm3');
|
||||
const volPercent = pickVariant('volumePercent', ['measured','predicted'], 'atEquipment'); // already unitless
|
||||
const level = pickVariant('level', ['measured', 'predicted'], 'atEquipment', 'm');
|
||||
const netFlow = pickVariant('netFlowRate', ['measured', 'predicted'], 'atEquipment', 'm3/h');
|
||||
|
||||
const maxVolBeforeOverflow = ps.basin?.maxVolAtOverflow ?? ps.basin?.maxVol ?? 0;
|
||||
const currentVolume = vol.value ?? 0;
|
||||
const currentvolPercent = volPercent.value ?? 0;
|
||||
const netFlowM3h = netFlow.value ?? 0;
|
||||
|
||||
const direction = ps.state?.direction ?? 'unknown';
|
||||
const secondsRemaining = ps.state?.seconds ?? null;
|
||||
const timeRemainingMinutes = secondsRemaining != null ? Math.round(secondsRemaining / 60) : null;
|
||||
|
||||
const badgePieces = [];
|
||||
badgePieces.push(`${currentvolPercent.toFixed(1)}% `);
|
||||
badgePieces.push(
|
||||
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)} m³`
|
||||
);
|
||||
badgePieces.push(`net: ${netFlowM3h.toFixed(0)} m³/h`);
|
||||
if (timeRemainingMinutes != null) {
|
||||
badgePieces.push(`t≈${timeRemainingMinutes} min)`);
|
||||
}
|
||||
|
||||
const { symbol, fill } = (() => {
|
||||
switch (direction) {
|
||||
case 'filling': return { symbol: '⬆️', fill: 'blue' };
|
||||
case 'draining': return { symbol: '⬇️', fill: 'orange' };
|
||||
case 'steady': return { symbol: '⏸️', fill: 'green' };
|
||||
default: return { symbol: '❔', fill: 'grey' };
|
||||
}
|
||||
})();
|
||||
|
||||
badgePieces[0] = `${symbol} ${badgePieces[0]}`;
|
||||
|
||||
return {
|
||||
fill,
|
||||
shape: 'dot',
|
||||
text: badgePieces.join(' | ')
|
||||
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds,
|
||||
},
|
||||
output: {
|
||||
process: uiConfig.processOutputFormat,
|
||||
dbase: uiConfig.dbaseOutputFormat,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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.source.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.source.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 'changemode':
|
||||
this.source.changeMode(msg.payload);
|
||||
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;
|
||||
}
|
||||
case 'calibratePredictedVolume': {
|
||||
const injectedVol = parseFloat(msg.payload);
|
||||
this.source.calibratePredictedVolume(injectedVol);
|
||||
break;
|
||||
}
|
||||
case 'calibratePredictedLevel': {
|
||||
const injectedLevel = parseFloat(msg.payload);
|
||||
this.source.calibratePredictedLevel(injectedLevel);
|
||||
break;
|
||||
}
|
||||
case 'q_in': {
|
||||
// payload can be number or { value, unit, timestamp }
|
||||
const val = Number(msg.payload);
|
||||
const unit = msg?.unit;
|
||||
const ts = msg?.timestamp || Date.now();
|
||||
this.source.setManualInflow(val, ts, unit);
|
||||
break;
|
||||
}
|
||||
case 'Qd': {
|
||||
// Manual demand: operator sets the target output via a
|
||||
// dashboard slider. Only accepted when PS is in 'manual'
|
||||
// mode — mirrors how rotatingMachine gates commands by
|
||||
// mode (virtualControl vs auto).
|
||||
const demand = Number(msg.payload);
|
||||
if (!Number.isFinite(demand)) {
|
||||
this.source.logger.warn(`Invalid Qd value: ${msg.payload}`);
|
||||
break;
|
||||
}
|
||||
if (this.source.mode === 'manual') {
|
||||
this.source.forwardDemandToChildren(demand).catch((err) =>
|
||||
this.source.logger.error(`Failed to forward demand: ${err.message}`)
|
||||
);
|
||||
} else {
|
||||
this.source.logger.debug(
|
||||
`Qd ignored in ${this.source.mode} mode. Switch to manual to use the demand slider.`
|
||||
);
|
||||
}
|
||||
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);
|
||||
this.node.status({}); // clear node status badge
|
||||
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;
|
||||
1281
src/specificClass.js
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);
|
||||
});
|
||||
@@ -4,8 +4,36 @@
|
||||
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 = {
|
||||
@@ -27,6 +55,8 @@ function makeConfig(overrides = {}) {
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 4.5,
|
||||
inletPipeDiameter: 0.4,
|
||||
outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
@@ -36,12 +66,13 @@ function makeConfig(overrides = {}) {
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false,
|
||||
enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
@@ -54,6 +85,39 @@ function makeConfig(overrides = {}) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
|
||||
return {
|
||||
config: {
|
||||
general: { id: name, name },
|
||||
functionality: { positionVsParent: position },
|
||||
asset: { type },
|
||||
},
|
||||
measurements: new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('level child subscription records one sample per event for level-rate fallback', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const child = makeMeasurementChild();
|
||||
|
||||
ps._subscribeMeasurement(child);
|
||||
child.measurements.type('level').variant('measured').position('atequipment')
|
||||
.value(1.0, 1000, 'm');
|
||||
child.measurements.type('level').variant('measured').position('atequipment')
|
||||
.value(1.1, 3000, 'm');
|
||||
|
||||
const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
|
||||
assert.deepEqual(series.values, [1.0, 1.1]);
|
||||
|
||||
const net = ps.flowAggregator.selectBestNetFlow();
|
||||
assert.equal(net.source, 'level:measured');
|
||||
assert.equal(net.direction, 'filling');
|
||||
assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
|
||||
});
|
||||
|
||||
test('Basin geometry — derived values', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
@@ -80,6 +144,10 @@ test('Basin geometry — derived values', async (t) => {
|
||||
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) => {
|
||||
@@ -131,6 +199,21 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
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 },
|
||||
@@ -211,54 +294,176 @@ test('Calibration — predicted volume and level', async (t) => {
|
||||
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
let turnOffCalls = 0;
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => { turnOffCalls++; },
|
||||
handleInput: async () => {},
|
||||
};
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(turnOffCalls, 1);
|
||||
assert.equal(mock._calls.turnOff, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
|
||||
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => { throw new Error('should not be called in dead zone'); },
|
||||
};
|
||||
const mock = registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 42); // unchanged
|
||||
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('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
|
||||
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();
|
||||
// lerp(3, [2,4], [0,100]) = 50
|
||||
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);
|
||||
assert.equal(demands.length, 1);
|
||||
assert.ok(Math.abs(demands[0] - 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());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
registerMockGroup(ps, 'mgc1');
|
||||
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl >= 100);
|
||||
@@ -275,6 +480,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
|
||||
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();
|
||||
@@ -293,3 +502,155 @@ test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||
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)
|
||||
@@ -1,18 +0,0 @@
|
||||
# pumpingStation — Documentation
|
||||
|
||||
All docs and diagrams for this node live in this folder so they version-lock with the code they describe.
|
||||
|
||||
## Pages
|
||||
|
||||
- **[Functional Description](functional-description.md)** — operator-facing reference derived from `src/specificClass.js`: basin model, net-flow selection, safety interlocks, registration topology.
|
||||
- **[Control modes](modes/README.md)** — one page per control mode (`levelbased`, `flowbased`, …) describing how the mode uses the shared basin model to compute demand.
|
||||
|
||||
## Diagrams
|
||||
|
||||
Editable draw.io sources live in [`diagrams/`](diagrams/). See [`diagrams/README.md`](diagrams/README.md) for the editing workflow — open `.drawio` files in [draw.io](https://app.diagrams.net/), export to `.drawio.svg`, commit both.
|
||||
|
||||
The basin model is the shared canvas ([`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg)); per-mode transfer-function diagrams live under [`diagrams/modes/`](diagrams/modes/).
|
||||
|
||||
## Part of
|
||||
|
||||
This node is a git submodule of [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV). The EVOLV superproject has its own [`wiki/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki) with platform-level docs (architecture, concepts, shared manuals).
|
||||
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 |
@@ -1,15 +1,15 @@
|
||||
# Diagrams
|
||||
|
||||
Editable source diagrams for the pumpingStation wiki. Each diagram is a **`.drawio` + `.drawio.svg` pair**, so anyone can edit the source in [draw.io](https://app.diagrams.net/) without touching any Markdown.
|
||||
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.
|
||||
|
||||
## Why two files?
|
||||
## File roles
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `<name>.drawio` | Native draw.io XML. The canonical source. |
|
||||
| `<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. |
|
||||
|
||||
Checking both in means the wiki renders for everyone, and the next editor picks up from exactly where the last one left off.
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ Checking both in means the wiki renders for everyone, and the next editor picks
|
||||
git clone https://gitea.wbd-rd.nl/RnD/pumpingStation.git
|
||||
cd pumpingStation/wiki/diagrams
|
||||
```
|
||||
2. **Open** the `.drawio` file in draw.io:
|
||||
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.
|
||||
@@ -26,9 +26,9 @@ Checking both in means the wiki renders for everyone, and the next editor picks
|
||||
- `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** both files:
|
||||
5. **Commit & push** the edited SVG, plus the `.drawio` file if one exists:
|
||||
```bash
|
||||
git add wiki/diagrams/<name>.drawio wiki/diagrams/<name>.drawio.svg
|
||||
git add wiki/diagrams/<name>.drawio.svg
|
||||
git commit -m "Update <name>: <what changed>"
|
||||
git push
|
||||
```
|
||||
@@ -50,22 +50,23 @@ Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up
|
||||
|
||||
| Diagram | Shows |
|
||||
|---|---|
|
||||
| `basin-model` | Physical basin cross-section — walls, pipes at their real heights, control thresholds cutting across, zone labels |
|
||||
| `control-zones` | Vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
|
||||
| `safety-rules` | Dry-run vs overfill rule asymmetry — which children stop, which keep running |
|
||||
| `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 → Save As…` → `wiki/diagrams/<name>.drawio`.
|
||||
4. `File → Export as → SVG…` with **Include a copy of my diagram** checked → save as `wiki/diagrams/<name>.drawio.svg`.
|
||||
5. Reference from the wiki page with ``.
|
||||
6. Add an entry to the table above.
|
||||
7. Commit all three files together (`.drawio`, `.drawio.svg`, updated `.md`).
|
||||
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
|
||||
|
||||
The `.drawio` files and their matching `.drawio.svg` exports committed here are **placeholders** — layout is approximate, colors and fonts are defaults, no fine alignment. They're meant to be a starting point; open them in draw.io and refine.
|
||||
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.
|
||||
|
||||
Both formats are round-trippable: open either the `.drawio` or the `.drawio.svg` in draw.io and it will load the editable model. (The SVG has the drawio XML embedded in a `content="…"` attribute on the root `<svg>` element — that's what lets draw.io re-open its own SVG exports.)
|
||||
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.
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<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="basin-model" id="basinModel">
|
||||
<mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="900" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="Basin model — physical layout + control thresholds" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="20" width="500" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="tank" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E6F2FF;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="80" width="260" height="520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="deadvol" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#9FC5E8;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="302" y="550" width="256" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="freeboard_label" value="freeboard" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="90" width="240" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_line" value="" style="endArrow=none;html=1;strokeColor=#B22222;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="145" as="sourcePoint" />
|
||||
<mxPoint x="620" y="145" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="overflow_label_l" value="heightOverflow" style="text;html=1;fontSize=12;align=right;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="130" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_label_r" value="spill → measure" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="130" width="140" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_line" value="" style="endArrow=none;html=1;strokeColor=#D68910;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="200" as="sourcePoint" />
|
||||
<mxPoint x="620" y="200" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_label_l" value="maxFlowLevel" style="text;html=1;fontSize=12;align=right;fontColor=#D68910;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="185" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="scaling_label" value="SCALING RANGE (levelbased: demand ramps 0→100%)" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="255" width="240" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="startlevel_line" value="" style="endArrow=none;html=1;strokeColor=#1E8449;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="345" as="sourcePoint" />
|
||||
<mxPoint x="620" y="345" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="startlevel_label_l" value="startLevel" style="text;html=1;fontSize=12;align=right;fontColor=#1E8449;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="330" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="deadzone_label" value="DEAD ZONE (hysteresis — keep last cmd)" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="360" width="240" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inflow_arrow" value="" style="endArrow=classic;html=1;strokeColor=#1F4E79;strokeWidth=3;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="150" y="410" as="sourcePoint" />
|
||||
<mxPoint x="300" y="410" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="inflow_label" value="INFLOW" style="text;html=1;fontSize=13;fontStyle=1;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="90" y="395" width="70" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inlet_label" value="heightInlet" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="570" y="400" width="90" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stoplevel_line" value="" style="endArrow=none;html=1;strokeColor=#6C3483;dashed=1;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="250" y="465" as="sourcePoint" />
|
||||
<mxPoint x="620" y="465" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="stoplevel_label_l" value="stopLevel" style="text;html=1;fontSize=12;align=right;fontColor=#6C3483;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="450" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stoplevel_label_r" value="unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="450" width="160" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="buffer_label" value="BUFFER" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="490" width="240" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outflow_arrow" value="" style="endArrow=classic;html=1;strokeColor=#1F4E79;strokeWidth=3;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="560" y="540" as="sourcePoint" />
|
||||
<mxPoint x="720" y="540" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="outflow_label" value="OUTFLOW" style="text;html=1;fontSize=13;fontStyle=1;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="525" width="80" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_label_l" value="heightOutlet" style="text;html=1;fontSize=12;align=right;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="525" width="100" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_label_r" value="dry-run trip" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="550" width="120" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="deadvol_label" value="dead volume" style="text;html=1;fontSize=11;fontStyle=2;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="310" y="560" width="240" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="floor_label" value="floor (0)" style="text;html=1;fontSize=11;align=right;" vertex="1" parent="1">
|
||||
<mxGeometry x="190" y="590" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="basin_label" value="heightBasin" style="text;html=1;fontSize=11;align=right;" vertex="1" parent="1">
|
||||
<mxGeometry x="180" y="70" width="60" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
|
Before Width: | Height: | Size: 661 KiB After Width: | Height: | Size: 686 KiB |
@@ -1,102 +0,0 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,58 +0,0 @@
|
||||
<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 (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 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 (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. ⚠ "upstream STOP" is only correct in a cascaded layout. In a gravity-sewer station the inflow can'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 < triggerLowVol (triggerLowVol = minVol × (1 + pct/100)) • vol > triggerHighVol (triggerHighVol = maxVolOverflow × pct/100) • remainingTime < 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>
|
||||
@@ -1,330 +0,0 @@
|
||||
---
|
||||
title: pumpingStation — Functional Description
|
||||
node: pumpingStation
|
||||
updated: 2026-04-22
|
||||
status: draft
|
||||
---
|
||||
|
||||
# pumpingStation — Functional Description
|
||||
|
||||
The `pumpingStation` node models an S88 **Process Cell**: a wet-well basin with inflow and outflow, wrapped around one or more pump controllers. Every second it recomputes the basin's water balance, picks the most trustworthy net-flow source, runs its safety interlocks, and finally commands its children (individual pumps, `machineGroupControl`, or nested pumping stations) so the level stays inside the safe operating band.
|
||||
|
||||
This page is the operator-facing reference, derived from [`src/specificClass.js`](../src/specificClass.js). For the 3-tier code layout see [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md); for the atomic pump model see the [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki).
|
||||
|
||||
> **Diagrams on this page are editable.** Sources live in [`diagrams/`](diagrams/) — open the `.drawio` file in [draw.io](https://app.diagrams.net/), export to SVG, commit. See [`diagrams/README.md`](diagrams/README.md).
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Node category | EVOLV |
|
||||
| S88 level | Process Cell (`#0c99d9`, lane L5) |
|
||||
| Inputs | 1 (message-driven) |
|
||||
| Outputs | 3 — `process` / `dbase` / `parent` |
|
||||
| Tick period | 1 s |
|
||||
| Basin model | Rectangular prismatic — `volume = level × surfaceArea` |
|
||||
| Canonical units (internal) | Pa, m³/s, W, K, m, m³ |
|
||||
| Control modes implemented | `levelbased`, `manual` (placeholders for `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid`) |
|
||||
| Default flow dead-band | `1e-4 m³/s` (≈ 0.36 m³/h) |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Construct.** The node merges the user's editor config over the schema defaults, creates the measurement store, and seeds the predicted volume at the basin's operational floor (`minVol`).
|
||||
2. **Register children.** Sensors, pumps, machine groups, and nested stations register via the Port-2 handshake. The station subscribes only to the *highest-level aggregator* for predicted flow to avoid double-counting (MGC if present, otherwise the individual pump).
|
||||
3. **Tick loop (1 s).** `_updatePredictedVolume → _selectBestNetFlow → _safetyController → _controlLogic → state snapshot → output`.
|
||||
|
||||
## Editor configuration
|
||||
|
||||
Every field on the pumpingStation editor maps directly to the config schema in `generalFunctions/src/configs/pumpingStation.json`.
|
||||
|
||||
### Basin geometry (section `basin`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Basin Volume (m³)** | `1` | Total geometric volume of the empty basin (floor to rim). |
|
||||
| **Basin Height (m)** | `1` | Physical wall height from floor to rim. |
|
||||
| **Inlet Elevation (m)** | `2` | Centre of the inlet pipe, measured from the floor. |
|
||||
| **Outlet Elevation (m)** | `0.2` | Centre of the pump-suction pipe, measured from the floor. |
|
||||
| **Overflow Level (m)** | `2.5` | Overflow-weir crest, measured from the floor. Above this → overfill safety. |
|
||||
|
||||
Constant cross-section is assumed: `surfaceArea = volume / height`. All derived volumes (`minVolAtOutflow`, `minVolAtInflow`, `maxVolAtOverflow`, `maxVol`) are computed once in `initBasinProperties()` and kept on `station.basin`.
|
||||
|
||||
### Hydraulics (section `hydraulics`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Minimum Height Based On** | `outlet` | `outlet` → `minVol = outflowLevel × area` (includes the buffer). `inlet` → `minVol = inflowLevel × area` (buffer treated as unavailable). |
|
||||
| **Reference Height** | `NAP` | Vertical datum: `NAP` / `EVRF` / `EGM2008`. Metadata only — not used in math today. |
|
||||
| **Basin Bottom (m Refheight)** | `0` | Absolute elevation of the basin floor, for cross-basin comparisons. |
|
||||
|
||||
### Control (section `control`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Control mode** | `levelbased` | Active control strategy. Schema enumerates seven modes; today `levelbased` is fully implemented, `manual` forwards demand via `Qd`, others are placeholders. |
|
||||
| **minLevel (m)** | `1` | Below this level → unconditional MGC shutdown. |
|
||||
| **startLevel (m)** | `1` | Bottom of the linear scaling range (0 % demand — ramp starts here). |
|
||||
| **maxLevel (m)** | `4` | Top of the linear scaling range (100 % demand). Typically ≈ `overflowLevel`. |
|
||||
| **Flow setpoint** | `0` | Flow-based target (m³/h). Placeholder until `flowbased` is wired. |
|
||||
| **Deadband** | `0` | Flow-based deadband (m³/h). Placeholder. |
|
||||
|
||||
### Safety (section `safety`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
|
||||
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
|
||||
| **Low Volume Threshold (%)** | `2` | Dry-run trigger: `triggerLowVol = minVol × (1 + pct/100)`. |
|
||||
| **Enable Overfill Protection** | `true` | If on, upstream inflows are shut down once volume climbs above the overfill threshold while filling. |
|
||||
| **High Volume Threshold (%)** | `98` | Overfill trigger: `triggerHighVol = maxVolAtOverflow × pct/100`. |
|
||||
|
||||
### Output formats
|
||||
|
||||
- **Process Output** — format for Port 0 (`process` / `json` / `csv`).
|
||||
- **Database Output** — format for Port 1 (`influxdb` / `json` / `csv`).
|
||||
|
||||
> **Tip — always configure every field.** The pumpingStation mixes geometry and control thresholds freely. Leaving `overflowLevel` at the schema default of 2.5 m while sizing the basin for 10 m walls produces nonsensical fill-percentages and spurious safety events. See the [EVOLV flow-layout rules §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) for the completeness rule.
|
||||
|
||||
## Input topics
|
||||
|
||||
All commands enter on the single input port. `msg.topic` selects the handler; `msg.payload` carries the argument.
|
||||
|
||||
### `changemode`
|
||||
|
||||
```json
|
||||
{ "topic": "changemode", "payload": "manual" }
|
||||
```
|
||||
|
||||
Switches the active control strategy. The new mode must be in `config.control.allowedModes` — unknown values are rejected with a warning. Typical transitions: `levelbased ⇄ manual` for operator override during maintenance.
|
||||
|
||||
### `calibratePredictedVolume`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedVolume", "payload": 3.4 }
|
||||
```
|
||||
|
||||
Hard-reset the predicted volume time-series to the supplied value (m³). Also rewrites the predicted level (derived from the constant-area geometry) and resets the internal flow-integrator state. Use this when a trustworthy measured level becomes available.
|
||||
|
||||
### `calibratePredictedLevel`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedLevel", "payload": 1.8 }
|
||||
```
|
||||
|
||||
Same as above, but caller supplies a level (m). The predicted volume is recomputed via `volume = level × surfaceArea`.
|
||||
|
||||
### `q_in`
|
||||
|
||||
```json
|
||||
{ "topic": "q_in", "payload": 300, "unit": "l/s" }
|
||||
```
|
||||
|
||||
Inject a **manual inflow** into the basin. Registered as a predicted flow under the synthetic child `manual-qin` at position `in`. Useful when no physical inflow sensor is wired but the inflow is known externally (e.g. fed from a sewer model).
|
||||
|
||||
### `Qd`
|
||||
|
||||
```json
|
||||
{ "topic": "Qd", "payload": 75 }
|
||||
```
|
||||
|
||||
Forward a manual demand to every child aggregator (MGC first, then any direct pumps). **Only honoured when `config.control.mode === 'manual'`** — in any other mode the command is logged and discarded. Mirrors how `rotatingMachine` gates commands behind its mode field. The interpretation of the number depends on the child's scaling (`absolute` = m³/h, `normalized` = 0–100 %).
|
||||
|
||||
### `registerChild`
|
||||
|
||||
Internal. Child nodes (measurements, rotatingMachines, machineGroupControls, nested pumpingStations) emit this on their Port 2 a few hundred ms after deploy. The station resolves the Node-RED node id back to the source object and registers it via `childRegistrationUtils`.
|
||||
|
||||
## Output ports
|
||||
|
||||
### Port 0 — process data
|
||||
|
||||
Delta-compressed payload (only changed fields per tick). Keys follow the standard 4-segment format `<type>.<variant>.<position>.<childId>` plus a handful of top-level state fields merged in by `getOutput()`:
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `volume.predicted.atequipment.default` | Running predicted volume from the flow integrator (m³). |
|
||||
| `volume.measured.atequipment.default` | Volume derived from a `measured` level sensor (m³). |
|
||||
| `level.predicted.atequipment.default` | Predicted level = `volume / area` (m). |
|
||||
| `level.measured.<position>.<childId>` | Raw level sensor reading (m). |
|
||||
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolAtOverflow - minVol) × 100` (%). |
|
||||
| `flow.predicted.in.<childId>` | Inflow contribution from a registered child (m³/s internally; editor unit on output). |
|
||||
| `flow.predicted.out.<childId>` | Outflow contribution from a registered child. |
|
||||
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
|
||||
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
||||
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
|
||||
| `flowSource` | Which variant drove the current control cycle (`measured`, `predicted`, `level:predicted`, `null`). |
|
||||
| `timeleft` | Predicted seconds to overflow (while filling) or to dry-run (while draining). |
|
||||
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
|
||||
| `percControl` | Last demand (0–100+ %) forwarded to the machine group during level-based control. |
|
||||
|
||||
Consumers must cache and merge deltas — the example dashboard flows include a reusable function node that does exactly this.
|
||||
|
||||
### Port 1 — dbase (InfluxDB)
|
||||
|
||||
Line-protocol payload for the `telemetry` bucket. Tags stay low-cardinality (station name, asset type); fields carry the numeric state. See [EVOLV — InfluxDB Schema Design](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/concepts/influxdb-schema-design.md).
|
||||
|
||||
### Port 2 — parent
|
||||
|
||||
`{ topic: "registerChild", payload: <this-node-id>, positionVsParent, distance }` — fired once ~100 ms after deploy so an upstream cascade can discover this station. Nested stations use this to register with an outer `pumpingStation` parent.
|
||||
|
||||
## Basin model
|
||||
|
||||
The basin is modelled as a rectangular prism with constant cross-section. Everything derives from `volume = level × surfaceArea`.
|
||||
|
||||

|
||||
|
||||
*Editable source: [`diagrams/basin-model.drawio`](diagrams/basin-model.drawio). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
|
||||
|
||||
**Typical ordering** (bottom → top): `outflowLevel ≤ minLevel < inflowLevel < startLevel < maxLevel ≤ overflowLevel`.
|
||||
|
||||
> ⚠️ The comment block in `specificClass.js` currently says `startLevel ≤ inflowLevel` (inlet above startLevel). The physical convention is the opposite: pumps start *before* the water reaches the gravity inlet, so `inflowLevel < startLevel`. Worth fixing in the code comment next time that file is touched.
|
||||
|
||||
**minHeightBasedOn** — which pipe defines `minVol`, the operational floor used for the initial seed, the dry-run trigger, and the 0 % point of the fill percentage:
|
||||
|
||||
```
|
||||
outlet (default): inlet:
|
||||
|
||||
● maxVolAtOverflow ● maxVolAtOverflow
|
||||
│ │
|
||||
● inflowLevel ● inflowLevel ─── minVol
|
||||
│ │
|
||||
● outflowLevel ──── minVol ● outflowLevel
|
||||
│ │
|
||||
● floor ● floor
|
||||
|
||||
Buffer counts as usable stock. Buffer reserved; 0% fill
|
||||
starts at the inlet.
|
||||
```
|
||||
|
||||
## Net-flow selection
|
||||
|
||||
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
|
||||
|
||||
```
|
||||
priority source note
|
||||
|
||||
1 ────● measured.flow real sensors on inflow/outflow
|
||||
│
|
||||
2 ────● predicted.flow manual q_in + pump-curve outputs
|
||||
│
|
||||
3 ────● level:measured dL/dt × surfaceArea
|
||||
│
|
||||
4 ────● level:predicted dL/dt of the integrator
|
||||
│
|
||||
5 ────● steady (fallback) warn, return { value: 0, source: null }
|
||||
```
|
||||
|
||||
Both **measured** and **predicted** variants are always computed and stored, regardless of which one drives control. The active source surfaces on Port 0 as `flowSource`, so operators can watch sensor drift (measured diverges from predicted), validate the volume integrator, and diagnose "which source was active when X happened?".
|
||||
|
||||
The inflow / outflow alias map is deliberately wide so measurements (`upstream`/`downstream`) and predicted-flow subscriptions (`in`/`out`) both feed the same aggregator:
|
||||
|
||||
```js
|
||||
flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
|
||||
```
|
||||
|
||||
## Control logic
|
||||
|
||||
The `pumpingStation` supports multiple control modes. Each mode is a **policy that sets the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and produces a demand (0 – 100 %)** — the two safety thresholds (`dryRunLevel`, `overflowLevel`) are mode-independent and handled by the safety layer below.
|
||||
|
||||
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
|
||||
|
||||
| Mode | Status | Page |
|
||||
|---|---|---|
|
||||
| `levelbased` | ✅ implemented | [modes/levelbased.md](modes/levelbased.md) |
|
||||
| `manual` | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid` | 🚧 placeholder in code | — |
|
||||
|
||||
See [`modes/README.md`](modes/README.md) for the index and page template.
|
||||
|
||||
## Safety controller
|
||||
|
||||
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *overfill protects the basin from spilling*.
|
||||
|
||||

|
||||
|
||||
During overfill, level-based control naturally commands ≥100 % on the downstream MGC because the level is above `maxLevel`.
|
||||
|
||||
> ⚠️ **Known limitation — gravity-sewer context.** The "upstream STOP" action only makes sense in a **cascaded** station layout where the upstream equipment is an EVOLV-controllable pump or station. In a conventional wastewater wet-well the inflow is gravity-fed from the municipal sewer and **cannot be stopped** — attempting to would back up toilets. For that case the correct response to an overfill event is to **measure and log the spill over the weir** (for compliance reporting) and raise an alarm, while keeping downstream pumps at maximum demand. The current code fires `execSequence: shutdown` on upstream children regardless of what they are; that should be gated on "is the upstream actually controllable?" and supplemented with overflow-rate tracking. Tracked as follow-up work.
|
||||
|
||||
A missing volume reading is treated as a hard fault: every direct machine is sent `execSequence: shutdown` and `safetyControllerActive` latches. Calibrate predicted volume (`calibratePredictedVolume`) or wire a level measurement to recover.
|
||||
|
||||
## Registration — which children count as flow?
|
||||
|
||||
`_registerPredictedFlowChild` subscribes only to the *highest-level aggregator* to prevent double-counting.
|
||||
|
||||
```
|
||||
Without MGC: With MGC:
|
||||
|
||||
[ PumpingStation ] [ PumpingStation ]
|
||||
│ │ │ │
|
||||
│ │ │ [ MGC ]
|
||||
│ │ │ │ │ │
|
||||
● ● ● ● ● ●
|
||||
(each pump subscribed (only MGC is subscribed;
|
||||
directly) MGC aggregates its pumps)
|
||||
|
||||
N flow subscriptions. 1 flow subscription.
|
||||
Risk: double-count if an Pumps' flow is already
|
||||
MGC is added later. inside the MGC total.
|
||||
```
|
||||
|
||||
Measurement children register separately via `_registerMeasurementChild` and feed the `measured` variant — they never collide with the predicted-flow subscription. Nested `pumpingStation` children are always subscribed and expose their net flow at the parent's position.
|
||||
|
||||
## Node status badge
|
||||
|
||||
Updated every second by `_updateNodeStatus` in `nodeClass.js`:
|
||||
|
||||
```
|
||||
⬆️ 42.3% | V=4.57 / 10.80 m³ | net: 180 m³/h | t≈12 min
|
||||
```
|
||||
|
||||
| Symbol | Direction | Badge colour |
|
||||
|---|---|---|
|
||||
| ⬆️ | `filling` | blue |
|
||||
| ⬇️ | `draining` | orange |
|
||||
| ⏸️ | `steady` | green |
|
||||
| ❔ | `unknown` / missing measurements | grey |
|
||||
|
||||
## Example flow
|
||||
|
||||
The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pumpingstation-3pumps-dashboard/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/examples/pumpingstation-3pumps-dashboard). It wires three `rotatingMachine` pumps beneath an MGC beneath a `pumpingStation`, with the dashboard layout rule set (see the [EVOLV flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md)) — a useful template for any new station.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `fill %` exceeds 100 % or is negative | Basin geometry inconsistent — e.g. `overflowLevel > heightBasin`, or `outflowLevel > inflowLevel`. | Cross-check `0 < outflowLevel < inflowLevel < overflowLevel ≤ heightBasin` in the editor. |
|
||||
| Pumps never start in `levelbased` | Level is stuck in the DEAD ZONE between `minLevel` and `startLevel`, or `startLevel == maxLevel` so the scaling range collapses. | Widen the control band: move `startLevel` above `minLevel` and set `maxLevel ≈ overflowLevel`. |
|
||||
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
|
||||
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
|
||||
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |
|
||||
| Pumps keep running during overfill | Intended — overfill safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
|
||||
| Predicted volume drifts away from measured | Flow integrator has no reference — flows might have the wrong sign, or `unit` is mis-declared. | Call `calibratePredictedVolume` periodically from a measured level. |
|
||||
|
||||
## Running it locally
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://gitea.wbd-rd.nl/RnD/EVOLV.git
|
||||
cd EVOLV
|
||||
docker compose up -d
|
||||
# Node-RED: http://localhost:1880 InfluxDB: :8086 Grafana: :3000
|
||||
```
|
||||
|
||||
Then in Node-RED: **Import ▸ Examples ▸ EVOLV ▸ pumpingStation** (or open `examples/pumpingstation-3pumps-dashboard/flow.json`).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd nodes/pumpingStation
|
||||
npm test
|
||||
```
|
||||
|
||||
Unit tests live in `test/specificClass.test.js` — construction, basin derivation, measurement registration, net-flow selection, safety interlocks, and calibration.
|
||||
|
||||
## Related
|
||||
|
||||
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki) — atomic pump model beneath pumpingStation / MGC.
|
||||
- [measurement wiki](https://gitea.wbd-rd.nl/RnD/measurement/wiki) — sensor conditioning for inflow, outflow, level, and pressure inputs.
|
||||
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki) — how MGC coordinates multiple pumps.
|
||||
- [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md) — the entry → nodeClass → specificClass pattern.
|
||||
- [EVOLV — Group Optimization](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/group-optimization.md) — pump-group scheduling theory.
|
||||
- [EVOLV — flow-layout rules](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) — the lane / group / channel layout rules used by the demo flows.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Control modes
|
||||
|
||||
Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it uses the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC.
|
||||
|
||||
The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-independent and are enforced by the safety layer outside any mode. They never appear in a mode's policy.
|
||||
|
||||
## Template
|
||||
|
||||
Every mode page follows the same structure:
|
||||
|
||||
1. **At a glance** — one sentence + small fact table (inputs, output, status)
|
||||
2. **Diagram** — one or more, per tier (see below)
|
||||
3. **Inputs** — what signals the mode reads
|
||||
4. **Threshold policy** — how it uses / adjusts `minLevel`, `startLevel`, `maxLevel`
|
||||
5. **Demand formula** — pseudocode for Tier 1/2, objective function for Tier 3
|
||||
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
|
||||
7. **Related** — links to other modes + functional description
|
||||
|
||||
The three **tiers** classify modes by how dynamic the decision surface is:
|
||||
|
||||
| Tier | Curve | Example modes | Diagram type |
|
||||
|---|---|---|---|
|
||||
| **1** — static | Memoryless `demand = f(x)`; single curve | `levelbased`, `manual` | Single-curve transfer function |
|
||||
| **2** — parameterised | Shape fixed, curve moves with θ(t) | `flowbased`, `pressureBased`, `percentageBased`, `powerBased` | Transfer function + parameter overlay / family |
|
||||
| **3** — horizon-based | Optimisation, no fixed curve | `hybrid-optimal`, `mpc`, weather-aware | Block diagram of signal flow + scenario time-series |
|
||||
|
||||
## Implementation status
|
||||
|
||||
| Mode | Tier | Status | Page |
|
||||
|---|---|---|---|
|
||||
| `levelbased` | 1 | ✅ implemented | [levelbased.md](levelbased.md) |
|
||||
| `manual` | 1 | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased` | 2 | 🚧 code placeholder, template | [flowbased.md](flowbased.md) |
|
||||
| `pressureBased` | 2 | 🚧 code placeholder | — |
|
||||
| `percentageBased` | 2 | 🚧 code placeholder | — |
|
||||
| `powerBased` | 2 | 🚧 code placeholder, template | [powerbased.md](powerbased.md) |
|
||||
| `hybrid` | 3 | 🚧 code placeholder | — |
|
||||
| `mpc` | 3 | 🚧 not in code yet, template | [mpc.md](mpc.md) |
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
title: Flow-based mode
|
||||
mode: flowbased
|
||||
tier: 2
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Flow-based mode — *Tier 2 template*
|
||||
|
||||
> **Status — not yet implemented.** The `flowbased` entry is a placeholder in `_controlLogic`. This page reserves the shape and documents the intended design so all Tier-2 modes share the same layout.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 2 — parameterised transfer function |
|
||||
| Signal driving demand | measured outflow (actual pumps) |
|
||||
| Secondary inputs | integrator + derivative state (for PID) |
|
||||
| Output | demand 0–100 % via PID correction |
|
||||
| Thresholds adjusted at runtime? | No (but the demand can move independently of level) |
|
||||
| Use when | The station has a flow sensor on the outlet and you want to hold a target outflow rate regardless of basin level |
|
||||
|
||||
## Diagram
|
||||
|
||||
**Primary plot.** Demand vs *outflow-error* (not level!) is the meaningful transfer function for flow-based control. The curve is a classic PID surface — proportional slope times error, plus integral + derivative terms.
|
||||
|
||||
**Secondary plot.** Level still enters as gates (STOP below `minLevel`, don't overfill above `maxLevel`) — same thresholds as levelbased, but the mode doesn't *use* level to pick demand.
|
||||
|
||||
```
|
||||
Placeholder image — replace with:
|
||||
diagrams/modes/flowbased.drawio.svg (demand vs outflow-error, showing Kp slope)
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| measured outflow | sum of `flow.measured.*` at outflow positions | error = (flowSetpoint − measuredOutflow) |
|
||||
| `config.control.flowBased.flowSetpoint` | editor, static | target outflow in m³/h |
|
||||
| `config.control.flowBased.flowDeadband` | editor, static | zone around setpoint where PID output holds |
|
||||
| `config.control.flowBased.pid.{kp, ki, kd, ...}` | editor / schema | PID gains + rate limits |
|
||||
| current level | fallback → threshold gates | only used for `minLevel`/`maxLevel` bounds |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
The **control** thresholds (`minLevel`, `startLevel`, `maxLevel`) are still enforced but for different reasons than levelbased:
|
||||
|
||||
| Threshold | Role in flowbased |
|
||||
|---|---|
|
||||
| `minLevel` | If level drops below, force demand=0 regardless of PID output (prevents pump undercut) |
|
||||
| `startLevel` | unused — demand is driven by error, not level |
|
||||
| `maxLevel` | If level climbs above, force demand=100 regardless of PID output (prevents spill) |
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
error = flowSetpoint − measuredOutflow
|
||||
|
||||
if level < minLevel:
|
||||
demand = 0 # pump-undercut guard
|
||||
elif level > maxLevel:
|
||||
demand = 100 # anti-spill guard
|
||||
else:
|
||||
# normal PID branch
|
||||
P = Kp × error
|
||||
I += Ki × error × dt # with anti-windup clamp
|
||||
D = Kd × d(error)/dt # with low-pass filter
|
||||
demand = clamp(P + I + D, 0, 100) # with rate limits Δup/Δdown
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cold start, no prior outflow measurement.** PID state starts at 0; first error is `flowSetpoint`. Integral term will build up — rate-limit the demand ramp to avoid over-shoot.
|
||||
- **Sensor dropout on the outflow meter.** Fall back to predicted outflow (sum of pump curve predictions). Log a warning — PID on predicted-only is unreliable.
|
||||
- **Setpoint step change.** PID with derivative filter + rate limits handles this gracefully; without filter, the D-kick would saturate output.
|
||||
- **Safety layer interaction.** Same as levelbased — `dryRunLevel` and `overflowLevel` override the PID output. See [functional description § Safety](../functional-description.md#safety-controller).
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model + shared safety layer
|
||||
- [modes/README.md](README.md) — mode index + page template
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 reference implementation
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: Level-based mode
|
||||
mode: levelbased
|
||||
status: implemented
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Level-based mode
|
||||
|
||||
The simplest and most widely deployed control strategy. Demand is a direct, *static* piecewise-linear function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Signal driving demand | basin level (measured, predicted fallback) |
|
||||
| Output | demand 0–100 % forwarded to every MGC child |
|
||||
| Thresholds adjusted at runtime? | No — static from editor config |
|
||||
| Use when | Inflow is sewer-gravity (no smart metering) and operator wants a predictable, inspectable response |
|
||||
|
||||
## Diagram
|
||||
|
||||

|
||||
|
||||
*Editable source: [`../diagrams/modes/levelbased.drawio.svg`](../diagrams/modes/levelbased.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — it round-trips).*
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
|
||||
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
|
||||
| `config.control.levelbased.startLevel` | editor, static | where demand-ramp starts |
|
||||
| `config.control.levelbased.maxLevel` | editor, static | where demand saturates at 100 % |
|
||||
|
||||
The three control thresholds are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
|
||||
|
||||
## Threshold policy
|
||||
|
||||
| Threshold | Source | Adjustable at runtime? |
|
||||
|---|---|---|
|
||||
| `minLevel` | `config.control.levelbased.minLevel` | No |
|
||||
| `startLevel` | `config.control.levelbased.startLevel` | No |
|
||||
| `maxLevel` | `config.control.levelbased.maxLevel` | No |
|
||||
|
||||
That this policy is trivial (all static) is **the defining simplicity of this mode**. Modes like `powerBased` or future `weather-aware` variants will recompute these thresholds on the fly.
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
if level < minLevel:
|
||||
demand = 0
|
||||
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
||||
elif level < startLevel:
|
||||
demand = <previous demand> # dead zone — hold last command (hysteresis)
|
||||
elif level <= maxLevel:
|
||||
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
|
||||
else:
|
||||
demand = 100 % # saturated; MGC clamps internally if overshoot
|
||||
```
|
||||
|
||||
Where `lerp` is linear interpolation. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cold start with level in the dead zone.** `demand` has no prior value; it defaults to `0`. Pumps stay OFF until the level first crosses `startLevel` upward. Once it does, normal ramp-and-hold behaviour engages.
|
||||
- **Level sensor drops out mid-run.** `_selectBestNetFlow` falls back to predicted level (computed from the volume integrator) — the mode doesn't care which variant wins, it just reads the chosen level.
|
||||
- **Both sensor and predictor unavailable.** The mode's preconditions fail; `_controlLogic` logs a warning and exits without issuing a command. The last-known demand is held, which is safe.
|
||||
- **Level crosses `maxLevel` upward.** Demand saturates at 100 %. Level may still continue rising if inflow > station capacity — this is the scenario that trips the overflow-safety layer (see below).
|
||||
- **Level crosses `dryRunLevel` downward.** The **safety layer** (not this mode) force-shuts all downstream pumps regardless of what demand the mode is commanding. The mode's demand is effectively overridden until level climbs back above `dryRunLevel + hysteresis_margin`.
|
||||
- **Level crosses `overflowLevel` upward.** The safety layer logs the spill event and raises an alarm. The mode continues commanding at 100 % — which is what you want, because the pumps should keep draining as fast as physically possible. (See [functional description § Safety controller](../functional-description.md#safety-controller) for the gravity-sewer caveat.)
|
||||
|
||||
## Why this is worth migrating off of
|
||||
|
||||
Level-based is fine for steady-state sewer inflows. It has two known weaknesses:
|
||||
|
||||
1. **Predictable, not proactive.** It can't *pre-empty* the basin ahead of a forecasted storm or a power-price peak. Modes like `weather-aware` or `powerBased` can — by moving `startLevel` down or up at runtime.
|
||||
2. **Thresholds assume pump capacity is fixed.** If you add or remove pumps, the `startLevel ↔ maxLevel` band that gave smooth 0-100 % coverage no longer matches the new capacity. Flow-based and percentage-based modes are less brittle to capacity changes because they close the loop on *what you actually measure* (outflow or fill %) rather than *what you assume the level→capacity map is*.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model, net-flow selection, safety layer (shared across all modes)
|
||||
- [modes/README.md](README.md) — mode index + template
|
||||
- Other mode pages: *to be written* (`flowbased.md`, `pressurebased.md`, `percentagebased.md`, `powerbased.md`, `hybrid.md`, `manual.md`)
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
title: MPC (Model-Predictive Control)
|
||||
mode: mpc
|
||||
tier: 3
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# MPC mode — *Tier 3 template*
|
||||
|
||||
> **Status — not yet implemented.** Not even in the schema today. This page reserves the shape for when the time comes.
|
||||
|
||||
## Why this is Tier 3
|
||||
|
||||
The levelbased/flowbased/powerBased modes are all **memoryless or near-memoryless transfer functions**. You give them the current state; they give you a demand. You can draw them as 2D plots.
|
||||
|
||||
MPC is different. At each tick the controller solves an optimisation over a prediction horizon:
|
||||
|
||||
```
|
||||
minimise Σ cost(state(t+k), command(t+k)) for k = 0 .. N
|
||||
subject to forecast, physical limits, power budget, spill penalty, ...
|
||||
```
|
||||
|
||||
The *command* that's emitted at time `t` is merely the first step of that plan; next tick the forecast shifts and the optimiser re-runs. There's no fixed `demand = f(level)` curve — the curve is remade every tick.
|
||||
|
||||
That's why Tier-3 modes get **block diagrams + scenario time-series**, not transfer functions.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 3 — optimisation-based |
|
||||
| Signal driving demand | full state (level, flow, power) + **forecasts** (inflow, grid price, weather) |
|
||||
| Secondary inputs | cost weights, horizon length, solver config |
|
||||
| Output | demand + planned trajectory over horizon |
|
||||
| Thresholds adjusted at runtime? | Effectively yes — the optimiser treats them as soft constraints |
|
||||
| Use when | Available forecasts beat reactive control, or multi-objective optimisation is needed |
|
||||
|
||||
## Diagram 1 — signal flow (block diagram)
|
||||
|
||||
```
|
||||
Placeholder image — replace with:
|
||||
diagrams/modes/mpc-block.drawio.svg
|
||||
|
||||
Blocks:
|
||||
|
||||
[sensors] [inflow forecast] [grid price] [weather API]
|
||||
│ │ │ │
|
||||
└─────────────┴──────────────────┴──────────────┘
|
||||
│
|
||||
┌─────▼──────┐
|
||||
│ state + │
|
||||
│ forecast │
|
||||
│ bundle │
|
||||
└─────┬──────┘
|
||||
│
|
||||
┌─────▼───────────────────┐
|
||||
│ MPC solver │
|
||||
│ • horizon N │
|
||||
│ • cost weights w │
|
||||
│ • constraints C │
|
||||
│ • linearised model │
|
||||
└─────┬───────────────────┘
|
||||
│
|
||||
┌─────▼───────┐
|
||||
│ command[0] │ ── the step we act on now
|
||||
│ command[1] │
|
||||
│ ... │
|
||||
│ command[N] │ ── re-planned next tick
|
||||
└─────┬───────┘
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ safety layer clip │ ← dryRun / overflow always apply
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
demand → MGC
|
||||
```
|
||||
|
||||
## Diagram 2 — scenario time-series
|
||||
|
||||
A much more useful way to evaluate MPC is to plot *what it did* over a simulated scenario: level, planned vs actual demand, the cost function breakdown, the active constraints. The [simulations harness](../../simulations/README.md) is built for exactly this — MPC will need a dedicated scenario like `mpc-storm-with-forecast.js`.
|
||||
|
||||
```
|
||||
Placeholder — replace with:
|
||||
diagrams/modes/mpc-scenario.drawio.svg
|
||||
|
||||
Stacked time-series showing:
|
||||
1. basin level over time (with forecast shadow and horizon)
|
||||
2. demand over time (with the re-planning edges visible)
|
||||
3. cost breakdown: energy vs spill-penalty vs ramp-penalty
|
||||
4. active constraints over time (colored bands)
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current state | `measurements` container | initial condition for optimiser |
|
||||
| inflow forecast | external — sewer model / weather API | drives the cost integral |
|
||||
| grid-price forecast | external — market feed / schedule | weights energy cost |
|
||||
| cost weights `w` | config | trades off spill vs energy vs ramp |
|
||||
| horizon `N` | config | 15–60 minutes typical |
|
||||
| model parameters | config / learned | basin dynamics, pump curves |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
Levels appear in the optimiser as **soft constraints** (penalties in the cost function):
|
||||
|
||||
| Threshold | Role in MPC |
|
||||
|---|---|
|
||||
| `dryRunLevel`, `overflowLevel` | hard constraints — if the optimiser's plan crosses them, safety layer clips |
|
||||
| `minLevel`, `maxLevel` | soft constraints — penalty weight `w_level` applied to excursions |
|
||||
| `startLevel` | advisory only — optimiser doesn't inherently care, but may be used in cost weights for rule-of-thumb alignment with human expectations |
|
||||
|
||||
So unlike Tier-1/2 where thresholds directly gate the action, here they shape the objective.
|
||||
|
||||
## Demand formula
|
||||
|
||||
Not a formula — an optimisation problem:
|
||||
|
||||
```text
|
||||
state, forecast, constraints = gather_inputs()
|
||||
plan = mpc_solver.solve(
|
||||
state0 = state,
|
||||
forecast = forecast,
|
||||
horizon = N,
|
||||
model = basin_dynamics + pump_curves,
|
||||
cost = w_energy × Σ power(k)
|
||||
+ w_spill × Σ max(0, level(k) − overflowLevel)²
|
||||
+ w_undercut × Σ max(0, minLevel − level(k))²
|
||||
+ w_ramp × Σ (command(k) − command(k-1))²,
|
||||
constraints = pump_limits + power_budget + rate_limits,
|
||||
)
|
||||
demand = plan.command[0]
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Solver timeout.** Fall back to the previous plan's step, or to a levelbased curve as a safe default. Log.
|
||||
- **Bad forecast (persistent bias).** Optimiser can chase a wrong prediction for many ticks. Adaptive forecast bias correction, or a watchdog comparing forecast-vs-realised, is essential.
|
||||
- **Infeasibility.** If constraints can't be satisfied (e.g. power budget and maxLevel simultaneously during a severe storm), relax soft constraints in priority order (ramp first, then maxLevel, then energy) — never relax dryRun/overflow.
|
||||
- **Safety takeover.** The safety layer still overrides. MPC should *anticipate* safety trips in its cost function (big penalty for trajectories that invoke them), not hit them.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model + safety layer
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 — the "default" MPC falls back to
|
||||
- [modes/powerbased.md](powerbased.md) — Tier 2 — MPC generalises the clip idea into full optimisation
|
||||
- [simulations/README.md](../../simulations/README.md) — where MPC simulation scenarios will live
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
title: Power-based mode
|
||||
mode: powerBased
|
||||
tier: 2
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Power-based mode — *Tier 2 template*
|
||||
|
||||
> **Status — not yet implemented.** Placeholder. This page documents the intended shape of a grid-aware / netcongestion-aware station.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 2 — parameterised transfer function |
|
||||
| Signal driving demand | basin level (primary), **max-power budget** (clip) |
|
||||
| Secondary inputs | measured pump power, live grid-price / peak-hours signal |
|
||||
| Output | demand 0–100 % clipped so `Σ pump power ≤ maxPowerKW(t)` |
|
||||
| Thresholds adjusted at runtime? | `maxPowerKW(t)` yes — level thresholds no |
|
||||
| Use when | Grid has peak-hour tariffs or net-congestion caps |
|
||||
|
||||
## Diagram — the levelbased curve with a moving clip ceiling
|
||||
|
||||
```
|
||||
demand % ← dashed line: levelbased curve
|
||||
100 ┤ ╱ ─────── ← solid: clip at powerBudget(t)
|
||||
│ ╱ clip lowers
|
||||
│ ╱ during grid peak
|
||||
│ ╱ ─────────
|
||||
│ ╱ ╱
|
||||
│ ╱ ╱
|
||||
│ ╱ ╱
|
||||
0 ┼────────●───────●─────────────────────► level
|
||||
startLevel maxLevel
|
||||
|
||||
↑ the family of curves:
|
||||
clip=100% (grid idle),
|
||||
clip=70% (shoulder),
|
||||
clip=40% (peak).
|
||||
```
|
||||
|
||||
The *shape* stays levelbased; the *ceiling* drops when the grid is strained. That's the Tier-2 signature: same input axis, parameter shifts the curve.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current level | as in levelbased | primary input |
|
||||
| `config.control.powerBased.maxPowerKW` | editor, static | hard cap on station power |
|
||||
| `config.control.powerBased.powerControlMode` | `limit` / `optimize` | whether to just clip or to schedule |
|
||||
| live grid signal (future) | external topic or forecast | modulates the cap over time |
|
||||
| measured pump power | `power.measured.*` from children | real-time feedback against the cap |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
Level thresholds (`minLevel`, `startLevel`, `maxLevel`) are **identical to levelbased** — they define the shape of the underlying curve. What's new is a runtime-varying ceiling `demandCap(t)` derived from the power budget.
|
||||
|
||||
`demandCap(t) = 100 × (maxPowerKW(t) / nominalStationPowerAtFull)` — where `maxPowerKW(t)` may come from config (static `limit` mode) or an external grid-price feed (dynamic).
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
rawDemand = levelbasedDemand(level) # the underlying Tier-1 curve
|
||||
demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
|
||||
demand = min(rawDemand, demandCap)
|
||||
```
|
||||
|
||||
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If overfill safety trips, it overrides the clip (safety wins).
|
||||
- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning.
|
||||
- **Grid exit from peak while basin is nearly full.** demandCap jumps back to 100; PID is memoryless so demand rises in one tick to match rawDemand.
|
||||
- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md)
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 reference (the curve that powerBased clips)
|
||||
- [modes/flowbased.md](flowbased.md) — other Tier-2 example with different control variable
|
||||