Compare commits
9 Commits
basin-docs
...
b825ac1d6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b825ac1d6d | ||
|
|
530f84ae5b | ||
|
|
5f1c9ae2ff | ||
|
|
ef81013e96 | ||
|
|
e991ea64ef | ||
|
|
ed22f01932 | ||
|
|
d2384b1a2d | ||
|
|
52d3889fbc | ||
|
|
7afcd6e54a |
57
CONTRACT.md
Normal file
57
CONTRACT.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 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.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.
|
||||||
340
examples/01-Basic.json
Normal file
340
examples/01-Basic.json
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ps_basic_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "PumpingStation - Basic",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Tier 1: single pumpingStation node driven by inject nodes only. Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "PumpingStation - Basic\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nA 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\noverflow at 3.2 m). controlMode = levelbased, manual demand allowed\nonly when set.mode = manual.\n\nHOW TO USE:\n 1. Deploy the flow.\n 2. Click \"set.mode = manual\" so set.demand is honoured.\n 3. Click \"set.inflow = 60 m3/h\" to push wastewater into the basin.\n 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n 5. Click \"calibrate volume 25 m3\" to jump straight to half-full.\n\nAliases (changemode, q_in, Qd, …) still work but log a deprecation\nwarning - fresh flows use the canonical names.",
|
||||||
|
"info": "",
|
||||||
|
"x": 600,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_mode",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.mode = manual",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "manual",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.mode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_mode_lvl",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.mode = levelbased",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "levelbased",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.mode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_inflow",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.inflow = 60 m3/h",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "60",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.inflow",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 260,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_demand",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "set.demand = 40 %",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "40",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.demand",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 200,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_calvol",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "calibrate volume 25 m3",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "25",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "cmd.calibrate.volume",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_inj_callvl",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "calibrate level 1.5 m",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "1.5",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "cmd.calibrate.level",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 220,
|
||||||
|
"y": 400,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_node"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_node",
|
||||||
|
"type": "pumpingStation",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Pumping Station",
|
||||||
|
"simulator": false,
|
||||||
|
"basinVolume": 50,
|
||||||
|
"basinHeight": 3.5,
|
||||||
|
"inflowLevel": 3,
|
||||||
|
"outflowLevel": 0.2,
|
||||||
|
"overflowLevel": 3.2,
|
||||||
|
"defaultFluid": "wastewater",
|
||||||
|
"inletPipeDiameter": 0.3,
|
||||||
|
"outletPipeDiameter": 0.3,
|
||||||
|
"pipelineLength": 80,
|
||||||
|
"maxDischargeHead": 24,
|
||||||
|
"staticHead": 12,
|
||||||
|
"maxInflowRate": 200,
|
||||||
|
"temperatureReferenceDegC": 15,
|
||||||
|
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
||||||
|
"enableDryRunProtection": true,
|
||||||
|
"enableOverfillProtection": true,
|
||||||
|
"dryRunThresholdPercent": 2,
|
||||||
|
"overfillThresholdPercent": 98,
|
||||||
|
"minHeightBasedOn": "outlet",
|
||||||
|
"processOutputFormat": "process",
|
||||||
|
"dbaseOutputFormat": "influxdb",
|
||||||
|
"refHeight": "NAP",
|
||||||
|
"basinBottomRef": 1,
|
||||||
|
"uuid": "example-ps-001",
|
||||||
|
"supplier": "WBD-RD",
|
||||||
|
"category": "station",
|
||||||
|
"assetType": "pumpingstation",
|
||||||
|
"model": "demo-50m3",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"controlMode": "levelbased",
|
||||||
|
"startLevel": 1.2,
|
||||||
|
"minLevel": 0.4,
|
||||||
|
"maxLevel": 2.8,
|
||||||
|
"flowSetpoint": null,
|
||||||
|
"flowDeadband": null,
|
||||||
|
"x": 1320,
|
||||||
|
"y": 300,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_format"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ps_basic_dbg_influx"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ps_basic_dbg_parent"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_format",
|
||||||
|
"type": "function",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Merge deltas + format",
|
||||||
|
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {};\nObject.assign(cache, p);\ncontext.set('c', cache);\nfunction pick(prefix) {\n for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n } return null;\n}\nconst vol = pick('volume.predicted.atequipment');\nconst lvl = pick('level.predicted.atequipment');\nconst flIn = pick('flow.predicted.in');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 1560,
|
||||||
|
"y": 280,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_basic_dbg_process"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_dbg_process",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Port 0: Process",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 240,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_dbg_influx",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Port 1: InfluxDB",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 320,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_basic_dbg_parent",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Port 2: Parent reg",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 380,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_ps_basic",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_basic_tab",
|
||||||
|
"name": "Pumping Station (PC)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#0c99d9",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"ps_basic_node",
|
||||||
|
"ps_basic_format"
|
||||||
|
],
|
||||||
|
"x": 1290,
|
||||||
|
"y": 230,
|
||||||
|
"w": 500,
|
||||||
|
"h": 140
|
||||||
|
}
|
||||||
|
]
|
||||||
686
examples/02-Integration.json
Normal file
686
examples/02-Integration.json
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ps_int_proc",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "Process Plant",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "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."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_setup",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "Setup",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Deploy-time once-true injects that initialise control modes on the EVOLV nodes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "PumpingStation - Integration\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nL0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\nPumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\nCross-tab channels: setup:* drive once-true initialisation from the Setup tab.",
|
||||||
|
"info": "",
|
||||||
|
"x": 600,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lin_setup_mode",
|
||||||
|
"type": "link in",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "setup:to-ps-mode",
|
||||||
|
"links": [],
|
||||||
|
"x": 120,
|
||||||
|
"y": 500,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_station"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lin_setup_inflow",
|
||||||
|
"type": "link in",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "setup:to-ps-inflow",
|
||||||
|
"links": [],
|
||||||
|
"x": 120,
|
||||||
|
"y": 560,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_station"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lin_setup_mgcmode",
|
||||||
|
"type": "link in",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "setup:to-mgc-mode",
|
||||||
|
"links": [],
|
||||||
|
"x": 120,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_mgc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meas_level",
|
||||||
|
"type": "measurement",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Basin level sensor",
|
||||||
|
"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": 600,
|
||||||
|
"y": 700,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_dbg_level"
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
"ps_int_station"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_inj_level",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "sim level 1.6 m",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "1.6",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "measurement",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "",
|
||||||
|
"x": 120,
|
||||||
|
"y": 700,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"meas_level"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pump_a",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump A",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "2",
|
||||||
|
"warmup": "1",
|
||||||
|
"shutdown": "2",
|
||||||
|
"cooldown": "1",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-a",
|
||||||
|
"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": 840,
|
||||||
|
"y": 320,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_dbg_pa"
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
"ps_int_mgc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pump_b",
|
||||||
|
"type": "rotatingMachine",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump B",
|
||||||
|
"speed": "1",
|
||||||
|
"startup": "2",
|
||||||
|
"warmup": "1",
|
||||||
|
"shutdown": "2",
|
||||||
|
"cooldown": "1",
|
||||||
|
"movementMode": "staticspeed",
|
||||||
|
"machineCurve": "",
|
||||||
|
"uuid": "example-pump-b",
|
||||||
|
"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": 840,
|
||||||
|
"y": 400,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_dbg_pb"
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
"ps_int_mgc"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_mgc",
|
||||||
|
"type": "machineGroupControl",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump Group",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"x": 1080,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_dbg_mgc"
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
"ps_int_station"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_station",
|
||||||
|
"type": "pumpingStation",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pumping Station",
|
||||||
|
"simulator": false,
|
||||||
|
"basinVolume": 50,
|
||||||
|
"basinHeight": 3.5,
|
||||||
|
"inflowLevel": 3,
|
||||||
|
"outflowLevel": 0.2,
|
||||||
|
"overflowLevel": 3.2,
|
||||||
|
"defaultFluid": "wastewater",
|
||||||
|
"inletPipeDiameter": 0.3,
|
||||||
|
"outletPipeDiameter": 0.3,
|
||||||
|
"pipelineLength": 80,
|
||||||
|
"maxDischargeHead": 24,
|
||||||
|
"staticHead": 12,
|
||||||
|
"maxInflowRate": 200,
|
||||||
|
"temperatureReferenceDegC": 15,
|
||||||
|
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
||||||
|
"enableDryRunProtection": true,
|
||||||
|
"enableOverfillProtection": true,
|
||||||
|
"dryRunThresholdPercent": 2,
|
||||||
|
"overfillThresholdPercent": 98,
|
||||||
|
"minHeightBasedOn": "outlet",
|
||||||
|
"processOutputFormat": "process",
|
||||||
|
"dbaseOutputFormat": "influxdb",
|
||||||
|
"refHeight": "NAP",
|
||||||
|
"basinBottomRef": 1,
|
||||||
|
"uuid": "example-ps-001",
|
||||||
|
"supplier": "WBD-RD",
|
||||||
|
"category": "station",
|
||||||
|
"assetType": "pumpingstation",
|
||||||
|
"model": "demo-50m3",
|
||||||
|
"unit": "m3/h",
|
||||||
|
"enableLog": true,
|
||||||
|
"logLevel": "info",
|
||||||
|
"positionVsParent": "atEquipment",
|
||||||
|
"positionIcon": "",
|
||||||
|
"hasDistance": false,
|
||||||
|
"distance": "",
|
||||||
|
"distanceUnit": "m",
|
||||||
|
"distanceDescription": "",
|
||||||
|
"controlMode": "levelbased",
|
||||||
|
"startLevel": 1.2,
|
||||||
|
"minLevel": 0.4,
|
||||||
|
"maxLevel": 2.8,
|
||||||
|
"flowSetpoint": null,
|
||||||
|
"flowDeadband": null,
|
||||||
|
"x": 1320,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_format"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ps_int_dbg_influx"
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_format",
|
||||||
|
"type": "function",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Merge deltas + format",
|
||||||
|
"func": "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\nfunction 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; }\nconst vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\nmsg.payload = {\n state: cache.state || 'unknown',\n controlMode: cache.controlMode || cache.mode || 'n/a',\n direction: cache.direction || 'n/a',\n percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n childCount: cache.childCount != null ? cache.childCount : 'n/a'\n};\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 1560,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"ps_int_dbg_process"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_dbg_process",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "PS Port 0: Process",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 480,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_dbg_influx",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "PS Port 1: InfluxDB",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "true",
|
||||||
|
"targetType": "full",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 540,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_dbg_mgc",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "MGC Port 0",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 360,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_dbg_pa",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump A Port 0",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 320,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_dbg_pb",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump B Port 0",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 400,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps_int_dbg_level",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Level Port 0",
|
||||||
|
"active": false,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 1800,
|
||||||
|
"y": 700,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_pumpa",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump A (EM)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#86bbdd",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"pump_a",
|
||||||
|
"ps_int_dbg_pa"
|
||||||
|
],
|
||||||
|
"x": 815,
|
||||||
|
"y": 275,
|
||||||
|
"w": 1210,
|
||||||
|
"h": 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_pumpb",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump B (EM)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#86bbdd",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"pump_b",
|
||||||
|
"ps_int_dbg_pb"
|
||||||
|
],
|
||||||
|
"x": 815,
|
||||||
|
"y": 355,
|
||||||
|
"w": 1210,
|
||||||
|
"h": 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_mgc",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pump Group MGC (UN)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#50a8d9",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"ps_int_mgc",
|
||||||
|
"ps_int_dbg_mgc",
|
||||||
|
"lin_setup_mgcmode"
|
||||||
|
],
|
||||||
|
"x": 95,
|
||||||
|
"y": 315,
|
||||||
|
"w": 1930,
|
||||||
|
"h": 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_station",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Pumping Station (PC)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#0c99d9",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"ps_int_station",
|
||||||
|
"ps_int_format",
|
||||||
|
"ps_int_dbg_process",
|
||||||
|
"ps_int_dbg_influx",
|
||||||
|
"lin_setup_mode",
|
||||||
|
"lin_setup_inflow"
|
||||||
|
],
|
||||||
|
"x": 95,
|
||||||
|
"y": 435,
|
||||||
|
"w": 1930,
|
||||||
|
"h": 190
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_level",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_int_proc",
|
||||||
|
"name": "Level Sensor (CM)",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#a9daee",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"meas_level",
|
||||||
|
"ps_int_inj_level",
|
||||||
|
"ps_int_dbg_level"
|
||||||
|
],
|
||||||
|
"x": 95,
|
||||||
|
"y": 655,
|
||||||
|
"w": 1930,
|
||||||
|
"h": 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "setup_title",
|
||||||
|
"type": "comment",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "Deploy-time setup\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nFires once after each deploy: pushes the canonical set.mode / set.inflow /\nset.demand topics across cross-tab channels into the Process Plant tab.",
|
||||||
|
"info": "",
|
||||||
|
"x": 600,
|
||||||
|
"y": 40,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "setup_inj_mode",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "set.mode = levelbased",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "levelbased",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.mode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": true,
|
||||||
|
"onceDelay": "0.5",
|
||||||
|
"x": 120,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"lout_setup_mode"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "setup_inj_mgcmode",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "MGC set.mode = auto",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "auto",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.mode",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": true,
|
||||||
|
"onceDelay": "0.5",
|
||||||
|
"x": 120,
|
||||||
|
"y": 220,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"lout_setup_mgcmode"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "setup_inj_inflow",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "seed inflow 60 m3/h",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "payload",
|
||||||
|
"v": "60",
|
||||||
|
"vt": "num"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topic": "set.inflow",
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": true,
|
||||||
|
"onceDelay": "1.0",
|
||||||
|
"x": 120,
|
||||||
|
"y": 280,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"lout_setup_inflow"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lout_setup_mode",
|
||||||
|
"type": "link out",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "setup:to-ps-mode",
|
||||||
|
"mode": "link",
|
||||||
|
"links": [
|
||||||
|
"lin_setup_mode"
|
||||||
|
],
|
||||||
|
"x": 1800,
|
||||||
|
"y": 160,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lout_setup_mgcmode",
|
||||||
|
"type": "link out",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "setup:to-mgc-mode",
|
||||||
|
"mode": "link",
|
||||||
|
"links": [
|
||||||
|
"lin_setup_mgcmode"
|
||||||
|
],
|
||||||
|
"x": 1800,
|
||||||
|
"y": 220,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lout_setup_inflow",
|
||||||
|
"type": "link out",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "setup:to-ps-inflow",
|
||||||
|
"mode": "link",
|
||||||
|
"links": [
|
||||||
|
"lin_setup_inflow"
|
||||||
|
],
|
||||||
|
"x": 1800,
|
||||||
|
"y": 280,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grp_setup",
|
||||||
|
"type": "group",
|
||||||
|
"z": "ps_int_setup",
|
||||||
|
"name": "Deploy-time setup",
|
||||||
|
"style": {
|
||||||
|
"label": true,
|
||||||
|
"stroke": "#000000",
|
||||||
|
"fill": "#dddddd",
|
||||||
|
"fill-opacity": "0.10"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
"setup_inj_mode",
|
||||||
|
"setup_inj_mgcmode",
|
||||||
|
"setup_inj_inflow",
|
||||||
|
"lout_setup_mode",
|
||||||
|
"lout_setup_mgcmode",
|
||||||
|
"lout_setup_inflow"
|
||||||
|
],
|
||||||
|
"x": 95,
|
||||||
|
"y": 115,
|
||||||
|
"w": 1930,
|
||||||
|
"h": 230
|
||||||
|
}
|
||||||
|
]
|
||||||
1325
examples/03-Dashboard.json
Normal file
1325
examples/03-Dashboard.json
Normal file
File diff suppressed because it is too large
Load Diff
99
examples/README.md
Normal file
99
examples/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 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.
|
||||||
57
examples/standalone-demo.js
Normal file
57
examples/standalone-demo.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Standalone PumpingStation demo — run with `node examples/standalone-demo.js`.
|
||||||
|
* Builds a station + one pump, calibrates predicted volume, ticks once.
|
||||||
|
* Useful for sanity-checking the orchestrator without Node-RED.
|
||||||
|
*/
|
||||||
|
const PumpingStation = require('../src/specificClass');
|
||||||
|
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
|
||||||
|
|
||||||
|
function createPumpingStationConfig(name) {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
logging: { enabled: true, logLevel: 'debug' },
|
||||||
|
name,
|
||||||
|
id: `${name}-${Date.now()}`,
|
||||||
|
flowThreshold: 1e-4,
|
||||||
|
},
|
||||||
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller' },
|
||||||
|
basin: { volume: 43.75, height: 10, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 3.2 },
|
||||||
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0 },
|
||||||
|
safety: { enableDryRunProtection: false, enableOverfillProtection: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMachineConfig(name, position) {
|
||||||
|
return {
|
||||||
|
general: { name, logging: { enabled: false, logLevel: 'debug' } },
|
||||||
|
functionality: { softwareType: 'machine', positionVsParent: position },
|
||||||
|
asset: { supplier: 'Hydrostal', type: 'pump', category: 'centrifugal', model: 'hidrostal-H05K-S03R' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMachineStateConfig() {
|
||||||
|
return {
|
||||||
|
general: { logging: { enabled: true, logLevel: 'debug' } },
|
||||||
|
movement: { speed: 1 },
|
||||||
|
time: { starting: 2, warmingup: 3, stopping: 2, coolingdown: 3 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function demo() {
|
||||||
|
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
|
||||||
|
const pump1 = new RotatingMachine(createMachineConfig('Pump1', 'downstream'), createMachineStateConfig());
|
||||||
|
|
||||||
|
station.childRegistrationUtils.registerChild(pump1, 'machine');
|
||||||
|
|
||||||
|
setInterval(() => station.tick(), 1000);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
console.log('Initial state:', station.state);
|
||||||
|
station.setManualInflow(300, Date.now(), 'l/s');
|
||||||
|
station.calibratePredictedVolume(3.4);
|
||||||
|
|
||||||
|
console.log('Station state:', station.state);
|
||||||
|
console.log('Station output:', station.getOutput());
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error('Demo failed:', err);
|
||||||
|
});
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
"description": "Control module",
|
"description": "Control module",
|
||||||
"main": "pumpingStation.js",
|
"main": "pumpingStation.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node pumpingStation.js"
|
"test": "node --test test/",
|
||||||
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||||
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||||
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
-->
|
-->
|
||||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||||
|
|
||||||
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
|
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
|
||||||
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
|
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
|
||||||
<script src="/pumpingStation/editor/index.js"></script>
|
<script src="/pumpingStation/editor/index.js"></script>
|
||||||
|
|||||||
99
src/basin/BasinGeometry.js
Normal file
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;
|
||||||
88
src/basin/thresholdValidator.js
Normal file
88
src/basin/thresholdValidator.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// 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 ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
|
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
|
||||||
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
['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 };
|
||||||
106
src/commands/handlers.js
Normal file
106
src/commands/handlers.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'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);
|
||||||
|
const demand = Number(msg.payload);
|
||||||
|
if (!Number.isFinite(demand)) {
|
||||||
|
log?.warn?.(`set.demand: invalid Qd value '${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
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
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
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 };
|
||||||
225
src/control/levelBased.js
Normal file
225
src/control/levelBased.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// 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
|
||||||
|
// [inflowLevel, maxLevel] using linear or log shape.
|
||||||
|
// 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;
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(machineGroups).map((group) =>
|
||||||
|
group.handleInput('parent', percentControl).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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._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. Up-curve mapping. Foot stays at inflowLevel (the basin's
|
||||||
|
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
|
||||||
|
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
|
||||||
|
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
|
||||||
|
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
||||||
|
|
||||||
|
// 4. 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) {
|
||||||
|
// While engaged via stopLevel hysteresis AND inside the dead band
|
||||||
|
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
|
||||||
|
// single pump running.
|
||||||
|
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'levelbased',
|
||||||
|
run,
|
||||||
|
_scaleLevelToFlowPercent,
|
||||||
|
_curveShape,
|
||||||
|
_applyMachineGroupLevelControl,
|
||||||
|
_applyMachineLevelControl,
|
||||||
|
};
|
||||||
36
src/control/manual.js
Normal file
36
src/control/manual.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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, logger } = ctx;
|
||||||
|
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||||
|
|
||||||
|
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(machineGroups).map((group) =>
|
||||||
|
group.handleInput('parent', demand).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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'manual',
|
||||||
|
run,
|
||||||
|
forwardDemand,
|
||||||
|
};
|
||||||
91
src/measurement/calibration.js
Normal file
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,
|
||||||
|
};
|
||||||
265
src/measurement/flowAggregator.js
Normal file
265
src/measurement/flowAggregator.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||||
|
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||||
|
|
||||||
|
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
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).unit(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;
|
||||||
278
src/nodeClass.js
278
src/nodeClass.js
@@ -1,46 +1,16 @@
|
|||||||
|
const { BaseNodeAdapter, configManager } = require('generalFunctions');
|
||||||
|
const PumpingStation = require('./specificClass');
|
||||||
|
const commands = require('./commands');
|
||||||
|
|
||||||
const { outputUtils, configManager } = require('generalFunctions');
|
class nodeClass extends BaseNodeAdapter {
|
||||||
const Specific = require("./specificClass");
|
static DomainClass = PumpingStation;
|
||||||
|
static commands = commands;
|
||||||
|
// Tick-driven: predicted-volume integrator needs delta-time per second.
|
||||||
|
static tickInterval = 1000;
|
||||||
|
static statusInterval = 1000;
|
||||||
|
|
||||||
class nodeClass {
|
buildDomainConfig(uiConfig) {
|
||||||
/**
|
return {
|
||||||
* Create a node.
|
|
||||||
* @param {object} uiConfig - Node-RED node configuration.
|
|
||||||
* @param {object} RED - Node-RED runtime API.
|
|
||||||
* @param {object} nodeInstance - The Node-RED node instance.
|
|
||||||
* @param {string} nameOfNode - The name of the node, used for
|
|
||||||
*/
|
|
||||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
|
||||||
|
|
||||||
// Preserve RED reference for HTTP endpoints if needed
|
|
||||||
this.node = nodeInstance;
|
|
||||||
this.RED = RED;
|
|
||||||
this.name = nameOfNode;
|
|
||||||
|
|
||||||
// Load default & UI config
|
|
||||||
this._loadConfig(uiConfig,this.node);
|
|
||||||
|
|
||||||
// Instantiate core class
|
|
||||||
this._setupSpecificClass();
|
|
||||||
|
|
||||||
// Wire up event and lifecycle handlers
|
|
||||||
this._bindEvents();
|
|
||||||
this._registerChild();
|
|
||||||
this._startTickLoop();
|
|
||||||
this._attachInputHandler();
|
|
||||||
this._attachCloseHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and merge default config with user-defined settings.
|
|
||||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
|
||||||
*/
|
|
||||||
_loadConfig(uiConfig,node) {
|
|
||||||
const cfgMgr = new configManager();
|
|
||||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
|
||||||
|
|
||||||
// Build config: base sections + pumpingStation-specific domain config
|
|
||||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
|
||||||
basin: {
|
basin: {
|
||||||
volume: uiConfig.basinVolume,
|
volume: uiConfig.basinVolume,
|
||||||
height: uiConfig.basinHeight,
|
height: uiConfig.basinHeight,
|
||||||
@@ -61,228 +31,48 @@ class nodeClass {
|
|||||||
defaultFluid: uiConfig.defaultFluid,
|
defaultFluid: uiConfig.defaultFluid,
|
||||||
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
||||||
},
|
},
|
||||||
control:{
|
control: {
|
||||||
mode: uiConfig.controlMode,
|
mode: uiConfig.controlMode,
|
||||||
levelbased:{
|
levelbased: {
|
||||||
minLevel:uiConfig.minLevel,
|
minLevel: uiConfig.minLevel,
|
||||||
startLevel:uiConfig.startLevel,
|
startLevel: uiConfig.startLevel,
|
||||||
stopLevel: uiConfig.stopLevel,
|
stopLevel: uiConfig.stopLevel,
|
||||||
maxLevel:uiConfig.maxLevel,
|
maxLevel: uiConfig.maxLevel,
|
||||||
|
// Editor names the field levelCurveType; runtime uses curveType.
|
||||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||||
logCurveFactor: uiConfig.logCurveFactor,
|
logCurveFactor: uiConfig.logCurveFactor,
|
||||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||||
shiftLevel: uiConfig.shiftLevel,
|
shiftLevel: uiConfig.shiftLevel,
|
||||||
shiftArmPercent: uiConfig.shiftArmPercent
|
shiftArmPercent: uiConfig.shiftArmPercent,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
safety:{
|
safety: {
|
||||||
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
||||||
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
||||||
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
|
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
|
||||||
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
|
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
|
||||||
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
||||||
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
||||||
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
|
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
process: uiConfig.processOutputFormat,
|
process: uiConfig.processOutputFormat,
|
||||||
dbase: uiConfig.dbaseOutputFormat
|
dbase: uiConfig.dbaseOutputFormat,
|
||||||
}
|
},
|
||||||
});
|
|
||||||
|
|
||||||
// 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(' | ')
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-only entrypoint mirroring the basin-docs config-mapping surface.
|
||||||
|
// Lets `NodeClass.prototype._loadConfig.call({name:'pumpingStation'}, ui, node)`
|
||||||
// any time based functions here
|
// produce the merged config without instantiating a full Node-RED adapter.
|
||||||
_startTickLoop() {
|
// Production wiring goes through BaseNodeAdapter; this is a thin shim.
|
||||||
setTimeout(() => {
|
_loadConfig(uiConfig, node) {
|
||||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
const cfgMgr = new configManager();
|
||||||
|
const name = this.name || 'pumpingStation';
|
||||||
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
|
const domain = nodeClass.prototype.buildDomainConfig.call(this, uiConfig);
|
||||||
this._statusInterval = setInterval(() => {
|
this.defaultConfig = cfgMgr.getConfig(name);
|
||||||
const status = this._updateNodeStatus();
|
this.config = cfgMgr.buildConfig(name, uiConfig, node && node.id, domain);
|
||||||
this.node.status(status);
|
return this.config;
|
||||||
}, 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 'q_out': {
|
|
||||||
const val = Number(msg.payload);
|
|
||||||
const unit = msg?.unit;
|
|
||||||
const ts = msg?.timestamp || Date.now();
|
|
||||||
this.source.setManualOutflow(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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
156
src/safety/safetyController.js
Normal file
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;
|
||||||
1600
src/specificClass.js
1600
src/specificClass.js
File diff suppressed because it is too large
Load Diff
106
test/basic/BasinGeometry.basic.test.js
Normal file
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');
|
||||||
|
});
|
||||||
106
test/basic/calibration.basic.test.js
Normal file
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
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)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
130
test/basic/control-levelBased.basic.test.js
Normal file
130
test/basic/control-levelBased.basic.test.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// 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 = { handleInput: [], turnOff: 0 };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
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.handleInput.length, 0, 'no demand sent in stop zone');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// basin-docs behavior: between minLevel and the active ramp foot, demand
|
||||||
|
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
|
||||||
|
// only the explicit minLevel hard-stop path skips handleInput.
|
||||||
|
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
|
||||||
|
const ctx = makeCtx(1.5);
|
||||||
|
const state = { percControl: 17 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
|
||||||
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
|
||||||
|
const ctx = makeCtx(2);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 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 handleInput("parent", percControl)', async () => {
|
||||||
|
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 50);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
|
||||||
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
64
test/basic/control-manual.basic.test.js
Normal file
64
test/basic/control-manual.basic.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// 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 manual = require('../../src/control/manual');
|
||||||
|
|
||||||
|
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", demand) on every machine group', async () => {
|
||||||
|
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
||||||
|
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
||||||
|
|
||||||
|
await manual.forwardDemand(ctx, 50);
|
||||||
|
|
||||||
|
for (const g of Object.values(groups)) {
|
||||||
|
assert.equal(g._calls.handleInput.length, 1);
|
||||||
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {}, 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');
|
||||||
|
});
|
||||||
141
test/basic/flowAggregator.basic.test.js
Normal file
141
test/basic/flowAggregator.basic.test.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// 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.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
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() }));
|
||||||
|
});
|
||||||
230
test/basic/safetyController.basic.test.js
Normal file
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);
|
||||||
|
});
|
||||||
@@ -6,6 +6,31 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
const PumpingStation = require('../../src/specificClass');
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
|
||||||
|
// machineGroups is a registry-backed getter (declareChildGetter) — direct
|
||||||
|
// assignment is no longer possible. Tests inject mock groups through the
|
||||||
|
// real registration handshake so the registry remains the source of truth.
|
||||||
|
function registerMockGroup(ps, id, behavior = {}) {
|
||||||
|
const calls = { handleInput: [], turnOff: 0 };
|
||||||
|
const mock = {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
|
||||||
|
asset: { category: 'controller' },
|
||||||
|
},
|
||||||
|
measurements: {
|
||||||
|
emitter: { on: () => {} },
|
||||||
|
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
|
||||||
|
},
|
||||||
|
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: {...} }.
|
// Standard config shape. Override any section by passing { section: {...} }.
|
||||||
function makeConfig(overrides = {}) {
|
function makeConfig(overrides = {}) {
|
||||||
const base = {
|
const base = {
|
||||||
@@ -229,70 +254,46 @@ test('Calibration — predicted volume and level', async (t) => {
|
|||||||
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||||
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
let turnOffCalls = 0;
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.machineGroups['mgc1'] = {
|
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => { turnOffCalls++; },
|
|
||||||
handleInput: async () => {},
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(turnOffCalls, 1);
|
assert.equal(mock._calls.turnOff, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
ps.percControl = 42; // simulated previous demand
|
ps.percControl = 42; // simulated previous demand
|
||||||
const demands = [];
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.machineGroups['mgc1'] = {
|
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async (_src, d) => { demands.push(d); },
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(demands[0], 0);
|
assert.equal(mock._calls.handleInput[0][1], 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
const demands = [];
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.machineGroups['mgc1'] = {
|
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async (_src, d) => { demands.push(d); },
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
assert.equal(ps.percControl, 0);
|
assert.equal(ps.percControl, 0);
|
||||||
assert.equal(demands[0], 0);
|
assert.equal(mock._calls.handleInput[0][1], 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
const demands = [];
|
const mock = registerMockGroup(ps, 'mgc1');
|
||||||
ps.machineGroups['mgc1'] = {
|
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async (_src, d) => { demands.push(d); },
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
// lerp(3.5, [3,4], [0,100]) = 50
|
// lerp(3.5, [3,4], [0,100]) = 50
|
||||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||||
assert.equal(demands.length, 1);
|
assert.equal(mock._calls.handleInput.length, 1);
|
||||||
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
|
assert.ok(Math.abs(mock._calls.handleInput[0][1] - 50) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
ps.machineGroups['mgc1'] = {
|
registerMockGroup(ps, 'mgc1');
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async () => {},
|
|
||||||
};
|
|
||||||
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
||||||
ps.calibratePredictedLevel(3.8);
|
ps.calibratePredictedLevel(3.8);
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
@@ -317,11 +318,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
ps.machineGroups['mgc1'] = {
|
registerMockGroup(ps, 'mgc1');
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async () => {},
|
|
||||||
};
|
|
||||||
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
|
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
|
||||||
ps.calibratePredictedLevel(3.5);
|
ps.calibratePredictedLevel(3.5);
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
@@ -363,11 +360,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
ps.machineGroups['mgc1'] = {
|
registerMockGroup(ps, 'mgc1');
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async () => {},
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(3.85);
|
ps.calibratePredictedLevel(3.85);
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
await ps._controlLevelBased('draining');
|
await ps._controlLevelBased('draining');
|
||||||
@@ -391,11 +384,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
ps.machineGroups['mgc1'] = {
|
registerMockGroup(ps, 'mgc1');
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async () => {},
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
|
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
|
||||||
await ps._controlLevelBased('filling');
|
await ps._controlLevelBased('filling');
|
||||||
assert.ok(ps.percControl > 50);
|
assert.ok(ps.percControl > 50);
|
||||||
@@ -404,11 +393,7 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
|||||||
|
|
||||||
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
ps.machineGroups['mgc1'] = {
|
registerMockGroup(ps, 'mgc1');
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async () => {},
|
|
||||||
};
|
|
||||||
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||||
await ps._controlLevelBased();
|
await ps._controlLevelBased();
|
||||||
assert.ok(ps.percControl >= 100);
|
assert.ok(ps.percControl >= 100);
|
||||||
|
|||||||
124
test/basic/thresholdValidator.basic.test.js
Normal file
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, []);
|
||||||
|
});
|
||||||
@@ -50,17 +50,34 @@ function makeConfig() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
// Build a PS with a fake MGC that captures every demand sent to it,
|
||||||
// and a clock we control so _updatePredictedVolume integrates over a
|
// and a clock we control so _updatePredictedVolume integrates over a
|
||||||
// known dt regardless of wall-clock.
|
// known dt regardless of wall-clock.
|
||||||
function buildHarness() {
|
function buildHarness() {
|
||||||
const ps = new PumpingStation(makeConfig());
|
const ps = new PumpingStation(makeConfig());
|
||||||
const demands = [];
|
const demands = [];
|
||||||
ps.machineGroups['mgc1'] = {
|
registerMockGroup(ps, 'mgc1', demands);
|
||||||
config: { general: { name: 'mgc1' } },
|
|
||||||
turnOffAllMachines: () => {},
|
|
||||||
handleInput: async (_src, d) => { demands.push(d); },
|
|
||||||
};
|
|
||||||
// Seed level at startLevel so the run begins idle.
|
// Seed level at startLevel so the run begins idle.
|
||||||
ps.calibratePredictedLevel(2.0);
|
ps.calibratePredictedLevel(2.0);
|
||||||
// Override Date.now via a controllable clock that advances `step()`.
|
// Override Date.now via a controllable clock that advances `step()`.
|
||||||
|
|||||||
949
tools/build-examples.js
Normal file
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();
|
||||||
333
wiki/Home.md
Normal file
333
wiki/Home.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# pumpingStation
|
||||||
|
|
||||||
|
> **Reflects code as of `530f84a` · regenerated `2026-05-11` via `npm run wiki:all`**
|
||||||
|
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||||
|
|
||||||
|
## 1. What this node is
|
||||||
|
|
||||||
|
**pumpingStation** is an S88 Process Cell that owns a wet-well basin and orchestrates the pumps that drain it. It tracks measured and predicted volume, evaluates safety interlocks (dry-run, overfill), and dispatches a control strategy that hands a demand setpoint to one or more downstream machine groups or individual pumps. Stateful (control mode) and tick-driven (1 s integrator). See [`wiki/functional-description.md`](functional-description) for the full behaviour spec.
|
||||||
|
|
||||||
|
## 2. Position in the platform
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
meas_lvl[measurement<br/>type=level<br/>position=atequipment]:::ctrl
|
||||||
|
meas_in[measurement<br/>type=flow<br/>position=upstream]:::ctrl
|
||||||
|
ps[pumpingStation<br/>Process Cell]:::pc
|
||||||
|
mgc[machineGroupControl<br/>Unit]:::unit
|
||||||
|
pump[rotatingMachine<br/>Equipment]:::equip
|
||||||
|
|
||||||
|
meas_lvl -->|level.measured.atequipment| ps
|
||||||
|
meas_in -->|flow.measured.upstream| ps
|
||||||
|
pump -->|child.register| mgc
|
||||||
|
mgc -->|child.register| ps
|
||||||
|
mgc -->|flow.predicted.downstream| ps
|
||||||
|
ps -->|set.demand| mgc
|
||||||
|
classDef pc fill:#0c99d9,color:#fff
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
S88 colours: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md §10.1`.
|
||||||
|
|
||||||
|
## 3. Capability matrix
|
||||||
|
|
||||||
|
| Capability | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Predicts basin volume from net flow | ✅ | Integrator seeded from `basin.minVol`; recomputes level each tick. |
|
||||||
|
| Accepts measured level / volume / pressure / flow | ✅ | Routed via `measurementRouter` on child registration. |
|
||||||
|
| Level-based control strategy | ✅ | Linear or log ramp between `startLevel` and `maxLevel`. |
|
||||||
|
| Flow-based control strategy | ✅ | PID against `flowSetpoint`. |
|
||||||
|
| Manual demand passthrough | ✅ | `set.demand` only honoured in `manual` mode. |
|
||||||
|
| Dry-run safety interlock | ✅ | Shuts downstream pumps when volume < `minVol` while draining. Blocks control. |
|
||||||
|
| Overfill safety interlock | ✅ | Shuts upstream equipment when volume > threshold while filling. Control keeps running. |
|
||||||
|
| No-data panic | ✅ | Shuts ALL machines and blocks control when no volume reading is available. |
|
||||||
|
| Cascaded sub-stations | ⚠️ | Accepted via `pumpingstation` softwareType but not exercised in production. |
|
||||||
|
| pressureBased / powerBased / hybrid modes | ❌ | Enumerated in schema but not dispatched — only `levelbased`, `flowbased`, `manual`. |
|
||||||
|
|
||||||
|
## 4. Code map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
|
||||||
|
nc["buildDomainConfig()<br/>static DomainClass, commands<br/>static tickInterval = 1000 ms"]
|
||||||
|
end
|
||||||
|
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
|
||||||
|
sc["PumpingStation.configure()<br/>declares ChildRouter rules<br/>tick() → flowAggregator → safety → control"]
|
||||||
|
end
|
||||||
|
subgraph concerns["src/ concern modules"]
|
||||||
|
basin["basin/<br/>BasinGeometry · thresholdValidator"]
|
||||||
|
measurement["measurement/<br/>flowAggregator · measurementRouter · calibration"]
|
||||||
|
control["control/<br/>levelBased · flowBased · manual · dispatch"]
|
||||||
|
safety["safety/<br/>SafetyController"]
|
||||||
|
commands["commands/<br/>topic registry · handlers"]
|
||||||
|
end
|
||||||
|
nc --> sc
|
||||||
|
sc --> basin
|
||||||
|
sc --> measurement
|
||||||
|
sc --> control
|
||||||
|
sc --> safety
|
||||||
|
nc --> commands
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Owns | Read first if you're changing… |
|
||||||
|
|---|---|---|
|
||||||
|
| `basin/` | Geometry, volume↔level conversion, threshold ordering | Capacity, level↔volume math, fill %. |
|
||||||
|
| `measurement/` | Net-flow aggregation, predicted-volume integrator, calibration | Predicted volume / time-to-full. |
|
||||||
|
| `control/` | Strategy dispatch (`levelbased`, `flowbased`, `manual`) | Demand calculation, mode behaviour. |
|
||||||
|
| `safety/` | Dry-run + overfill rules, pump-shutdown side-effects | Safety envelope, alarm reactions. |
|
||||||
|
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
|
||||||
|
|
||||||
|
## 5. Topic contract
|
||||||
|
|
||||||
|
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
|
||||||
|
## 6. Child registration
|
||||||
|
|
||||||
|
Mirrors the `ChildRouter` declarations in `specificClass.js → configure()`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph kids["accepted children (softwareType)"]
|
||||||
|
m["measurement"]:::ctrl
|
||||||
|
mach["machine<br/>(rotatingMachine)"]:::equip
|
||||||
|
mgc["machinegroup<br/>(machineGroupControl)"]:::unit
|
||||||
|
sub["pumpingstation<br/>(sub-station)"]:::pc
|
||||||
|
end
|
||||||
|
m -->|"<type>.measured.<position>"| route1[_subscribeMeasurement<br/>→ measurementRouter]
|
||||||
|
mach -->|flow.predicted.out| route2[_subscribePredictedFlow<br/>+ flowAggregator]
|
||||||
|
mgc -->|flow.predicted.out| route2
|
||||||
|
sub -->|flow.predicted.out| route2
|
||||||
|
route1 --> tick[tick / integrator]
|
||||||
|
route2 --> tick
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef pc fill:#0c99d9,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
| softwareType | onRegister side-effect | Subscribed events |
|
||||||
|
|---|---|---|
|
||||||
|
| `measurement` | `_subscribeMeasurement(child)` — writes to MeasurementContainer by type + position. | `<type>.measured.<position>` for any type (level, flow, pressure, …). |
|
||||||
|
| `machine` | Added to `this.machines`. **Skipped when a `machinegroup` is present** — avoids double-counting predicted flow. | `flow.predicted.<in\|out>` per `positionVsParent`. |
|
||||||
|
| `machinegroup` | Added to `this.machineGroups`. | `flow.predicted.<in\|out>`. |
|
||||||
|
| `pumpingstation` | Added to `this.stations`. | `flow.predicted.<in\|out>`. |
|
||||||
|
|
||||||
|
## 7. Lifecycle — what one tick does
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant child as measurement / pump child
|
||||||
|
participant ps as pumpingStation
|
||||||
|
participant fa as flowAggregator
|
||||||
|
participant sf as safetyController
|
||||||
|
participant ctl as control strategy
|
||||||
|
participant out as Port-0 output
|
||||||
|
|
||||||
|
child->>ps: data event (level.measured.atequipment / flow.predicted.out)
|
||||||
|
ps->>ps: ChildRouter dispatches to _subscribeMeasurement / _subscribePredictedFlow
|
||||||
|
Note over ps: every 1000 ms (static tickInterval = 1000)
|
||||||
|
ps->>fa: tick() — net flow · ETA · predicted volume integrator
|
||||||
|
ps->>sf: evaluate({direction, secondsRemaining})
|
||||||
|
alt no-volume-data panic
|
||||||
|
sf-->>ps: blocked=true, reason='no-volume-data'
|
||||||
|
sf-->>ps: ALL machines shut down
|
||||||
|
else dry-run (vol < minVol AND draining)
|
||||||
|
sf-->>ps: blocked=true, reason='dry-run'
|
||||||
|
sf-->>ps: downstream machines + machineGroups shut down
|
||||||
|
else overfill (vol > threshold AND filling)
|
||||||
|
sf-->>ps: blocked=false, reason='overfill'
|
||||||
|
sf-->>ps: upstream machines + child stations shut down
|
||||||
|
ps->>ctl: dispatch(mode, ctx, controlState)
|
||||||
|
ctl-->>ps: percControl updated — pumps keep draining
|
||||||
|
else safety clear
|
||||||
|
ps->>ctl: dispatch(mode, ctx, controlState)
|
||||||
|
ctl-->>ps: percControl updated
|
||||||
|
end
|
||||||
|
ps->>ps: notifyOutputChanged()
|
||||||
|
ps->>out: msg{topic, payload (delta-compressed)}
|
||||||
|
```
|
||||||
|
|
||||||
|
For control-strategy details see [`wiki/modes/`](modes/README).
|
||||||
|
|
||||||
|
## 8. Data model — `getOutput()`
|
||||||
|
|
||||||
|
What lands on Port 0. Built in `getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
|
||||||
|
The `<nodeId>` segment of the MeasurementContainer key is the Node-RED node id assigned at deploy time; auto-gen substitutes a placeholder stub.
|
||||||
|
|
||||||
|
## 9. Configuration — editor form ↔ config keys
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph editor["Node-RED editor form"]
|
||||||
|
f1[Basin: volume / height]
|
||||||
|
f2[Levels: inflow / outflow / overflow]
|
||||||
|
f3[Control mode]
|
||||||
|
f4[Level-based setpoints: startLevel / stopLevel / minLevel / maxLevel]
|
||||||
|
f5[Safety: dry-run % / high-volume %]
|
||||||
|
end
|
||||||
|
subgraph config["Domain config slice"]
|
||||||
|
c1[basin.volume<br/>basin.height]
|
||||||
|
c2[basin.inflowLevel<br/>basin.outflowLevel<br/>basin.overflowLevel]
|
||||||
|
c3[control.mode]
|
||||||
|
c4[control.levelbased.startLevel<br/>control.levelbased.stopLevel<br/>control.levelbased.minLevel<br/>control.levelbased.maxLevel]
|
||||||
|
c5[safety.dryRunThresholdPercent<br/>safety.highVolumeSafetyThresholdPercent]
|
||||||
|
end
|
||||||
|
f1 --> c1
|
||||||
|
f2 --> c2
|
||||||
|
f3 --> c3
|
||||||
|
f4 --> c4
|
||||||
|
f5 --> c5
|
||||||
|
```
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Range | Where used |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `basinVolume` | `basin.volume` | `1` | > 0 (m³) | `BasinGeometry` |
|
||||||
|
| `basinHeight` | `basin.height` | `1` | > 0 (m) | `BasinGeometry` |
|
||||||
|
| `inflowLevel` | `basin.inflowLevel` | `0.8` | ≥ 0 (m) | threshold validator, control ramp foot |
|
||||||
|
| `outflowLevel` | `basin.outflowLevel` | `0.2` | ≥ 0 (m) | dead-volume floor |
|
||||||
|
| `overflowLevel` | `basin.overflowLevel` | `0.9` | > 0 (m) | overfill safety ceiling |
|
||||||
|
| `controlMode` | `control.mode` | `levelbased` | enum | `control/dispatch` |
|
||||||
|
| `levelCurveType` | `control.levelbased.curveType` | `linear` | `linear` \| `log` | `levelBased.run` |
|
||||||
|
| `logCurveFactor` | `control.levelbased.logCurveFactor` | `9` | > 0 | log-curve steepness |
|
||||||
|
| `enableShiftedRamp` | `control.levelbased.enableShiftedRamp` | `false` | bool | hysteresis ramp |
|
||||||
|
| `startLevel` | `control.levelbased.startLevel` | `null` | ≥ 0 (m) | ramp zero-point |
|
||||||
|
| `stopLevel` | `control.levelbased.stopLevel` | `null` | ≥ 0 (m) | Schmitt-trigger off threshold |
|
||||||
|
| `minLevel` | `control.levelbased.minLevel` | `null` | ≥ 0 (m) | `levelBased.run` |
|
||||||
|
| `maxLevel` | `control.levelbased.maxLevel` | `null` | ≤ overflowLevel (m) | ramp 100 % point |
|
||||||
|
| `flowSetpoint` | `control.flowbased.setpoint` | `null` | ≥ 0 (m³/h) | flow-PID target |
|
||||||
|
| `enableDryRunProtection` | `safety.enableDryRunProtection` | `true` | bool | `SafetyController._dryRunRule` |
|
||||||
|
| `dryRunThresholdPercent` | `safety.dryRunThresholdPercent` | `2` | 0–100 % | dry-run trip volume |
|
||||||
|
| `enableHighVolumeSafety` | `safety.enableHighVolumeSafety` | `true` | bool | `SafetyController._overfillRule` |
|
||||||
|
| `highVolumeSafetyThresholdPercent` | `safety.highVolumeSafetyThresholdPercent` | `98` | 0–100 % | overfill trip volume |
|
||||||
|
| `timeleftToFullOrEmptyThresholdSeconds` | `safety.timeleftToFullOrEmptyThresholdSeconds` | `0` | ≥ 0 (s) | ETA-based pre-trip guard |
|
||||||
|
|
||||||
|
> `enableOverfillProtection` and `overfillThresholdPercent` are **deprecated aliases** still accepted by `SafetyController` for back-compat. Use `enableHighVolumeSafety` and `highVolumeSafetyThresholdPercent` in new flows. See `OPEN_QUESTIONS.md` (B1.2 resolved).
|
||||||
|
|
||||||
|
## 10. State chart
|
||||||
|
|
||||||
|
pumpingStation has two orthogonal state vectors: **control mode** (operator-driven, persistent) and **safety state** (data-driven, evaluated every tick). The e-stop path is the no-volume-data panic that shuts all machines independently.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
state ControlMode {
|
||||||
|
[*] --> levelbased
|
||||||
|
levelbased --> flowbased : set.mode
|
||||||
|
flowbased --> manual : set.mode
|
||||||
|
manual --> levelbased : set.mode
|
||||||
|
manual --> none : set.mode
|
||||||
|
levelbased --> none : set.mode
|
||||||
|
none --> levelbased : set.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
state SafetyState {
|
||||||
|
[*] --> nominal
|
||||||
|
nominal --> dryRun : vol < minVol AND draining
|
||||||
|
nominal --> overfill : vol > highVolThreshold AND filling
|
||||||
|
nominal --> panic : no volume reading
|
||||||
|
dryRun --> nominal : vol ≥ minVol
|
||||||
|
overfill --> nominal : vol ≤ highVolThreshold
|
||||||
|
panic --> nominal : volume reading restored
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Safety state | `blocked` | Control dispatch | Side-effects |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nominal` | false | runs normally | — |
|
||||||
|
| `dryRun` | **true** | **skipped** | downstream machines + machineGroups shut down |
|
||||||
|
| `overfill` | false | runs (pumps must drain) | upstream machines + child stations shut down |
|
||||||
|
| `panic` | **true** | **skipped** | **ALL** machines shut down |
|
||||||
|
|
||||||
|
`dryRun` is triggered when `direction='draining'` AND vol < `minVol × (1 + dryRunThresholdPercent/100)`.
|
||||||
|
`overfill` is triggered when `direction='filling'` AND vol > `maxVolAtOverflow × (highVolumeSafetyThresholdPercent/100)`.
|
||||||
|
|
||||||
|
## 11. Examples
|
||||||
|
|
||||||
|
All three tiers are written and runnable. Import any file via the Node-RED editor or the Admin API.
|
||||||
|
|
||||||
|
| Tier | File | What it shows | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Basic | `examples/01-Basic.json` | Single pumpingStation driven by inject nodes — no parent, no dashboard. Try `set.inflow`, `set.mode`, `cmd.calibrate.volume`. | ✅ |
|
||||||
|
| Integration | `examples/02-Integration.json` | pumpingStation + `machineGroupControl` + 2 `rotatingMachine` pumps + level `measurement`. Demonstrates Phase-2 parent/child handshake and `levelbased` control driving real pumps. | ✅ |
|
||||||
|
| Dashboard | `examples/03-Dashboard.json` | Tier 2 plumbing + FlowFuse Dashboard 2.0 page — 3 charts (flow / level / volume %), mode dropdown, demand slider. | ✅ |
|
||||||
|
| Headless | `examples/standalone-demo.js` | Node.js-only simulator, no Node-RED required. | ✅ |
|
||||||
|
|
||||||
|
See `examples/README.md` for layout conventions (link channels, lane positions, group boxes).
|
||||||
|
|
||||||
|
## 12. Debug recipes
|
||||||
|
|
||||||
|
| Symptom | First thing to check | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| Status badge stuck on `❔ 0.0%` | No volume/level measurement registered yet. Watch Port 2. | Editor debug tap on Port 2 + `_subscribeMeasurement` log line. |
|
||||||
|
| `direction` always `steady` | Net flow inside `general.flowThreshold` dead-band (default 0.0001 m³/s ≈ 0.36 m³/h). | `flowAggregator.deriveDirection`. |
|
||||||
|
| `set.demand` ignored | Mode isn't `manual`. Confirm with `set.mode=manual` first. | `handlers.setDemand` debug log. |
|
||||||
|
| Predicted volume drifts off measured | Integrator needs a calibration anchor. Fire `cmd.calibrate.volume` with a known basin volume. | `measurement/calibration.js`. |
|
||||||
|
| Pumps don't stop on dry-run | `safety.enableDryRunProtection` must be `true` AND `direction` must be `'draining'`. | `SafetyController._dryRunRule`. |
|
||||||
|
| Threshold-ordering warnings on startup | `validateThresholdOrdering` detected violations (e.g. `inflowLevel > overflowLevel`). | `basin/thresholdValidator.js`. |
|
||||||
|
| All machines shut down immediately | No volume reading reached the node — panic path in SafetyController. Check child registration sequence. | `SafetyController.evaluate` line 59. |
|
||||||
|
|
||||||
|
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
|
||||||
|
|
||||||
|
## 13. When you would NOT use this node
|
||||||
|
|
||||||
|
- Use `rotatingMachine` directly for a single pump with no basin model. pumpingStation adds overhead that pays off only when you need predicted volume, time-to-full, or multi-pump orchestration.
|
||||||
|
- Don't use pumpingStation to schedule a fixed pump rota. Its control modes are reactive (level / flow / manual demand), not calendar-driven. Use an external scheduler and wire it in via `set.demand`.
|
||||||
|
- Skip pumpingStation if you only need flow or pressure measurements with no wet-well state. A bare `machineGroupControl` is lighter when the basin is modelled elsewhere or not at all.
|
||||||
|
|
||||||
|
## 14. Known limitations / current issues
|
||||||
|
|
||||||
|
| # | Issue | Tracked in |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Cascaded `pumpingstation` children accepted but semantics of nested stations are not test-covered in production scenarios. | TBD — exercise in Docker E2E before promoting. |
|
||||||
|
| 2 | `pressureBased`, `percentageBased`, `powerBased`, and `hybrid` are listed in the config enum but not dispatched — only `levelbased`, `flowbased`, `manual` are implemented. | `control/index.js` |
|
||||||
|
| 3 | Predicted-volume integrator drifts over long horizons without a measured-level calibration source. `cmd.calibrate.volume` is operator-triggered, not automatic. | Operator procedure; auto-calibration from level sensor is future work. |
|
||||||
|
| 4 | `enableOverfillProtection` / `overfillThresholdPercent` deprecated aliases still accepted by `SafetyController` (back-compat). Remove after one release cycle. | B1.2 resolved in `OPEN_QUESTIONS.md`. |
|
||||||
Reference in New Issue
Block a user