Compare commits
51 Commits
4e098eefaa
...
basin-docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ebb31816 | ||
|
|
6ab585bcc2 | ||
|
|
d8490aa949 | ||
|
|
6b46a8a8f0 | ||
|
|
62bc73f2f9 | ||
|
|
de9a79b888 | ||
|
|
8a6ca1baeb | ||
|
|
da50403c76 | ||
|
|
ab0d4ed285 | ||
|
|
2dd419dbf4 | ||
|
|
785d036dc6 | ||
|
|
65fe68b87f | ||
|
|
d641d2248d | ||
|
|
12904b4902 | ||
|
|
1ebbcb62cc | ||
|
|
3e13512a83 | ||
|
|
66fd3feff8 | ||
|
|
016433abe6 | ||
|
|
a2189457f6 | ||
|
|
4637448c49 | ||
|
|
61e0688f73 | ||
|
|
0ff55f5e9c | ||
|
|
5e2ebe4d96 | ||
|
|
e8dd657b4f | ||
|
|
c62d8bc275 | ||
|
|
f869296832 | ||
|
|
9f430cebb5 | ||
|
|
7d05d37678 | ||
|
|
762770a063 | ||
|
|
3ff76228eb | ||
|
|
f01b0bcb19 | ||
|
|
7efd3b0a07 | ||
|
|
c81ee1b470 | ||
|
|
955c17a466 | ||
|
|
052ded7b6e | ||
|
|
321ea33bf7 | ||
|
|
288bd244dd | ||
|
|
d91609b3a4 | ||
|
|
5a575a29fe | ||
|
|
0a6c7ee2e1 | ||
|
|
4cc529b1c2 | ||
|
|
fbfcec4b47 | ||
|
|
43eb97407f | ||
|
|
9e4b149b64 | ||
|
|
1848486f1c | ||
|
|
d44cbc978b | ||
|
|
f243761f00 | ||
|
|
2a31c7ec69 | ||
|
|
69f68adffe | ||
|
|
5a1eff37d7 | ||
|
|
e8f9207a92 |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# pumpingStation — Claude Code context
|
||||
|
||||
Wet-well basin model and pump orchestration.
|
||||
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||
|
||||
## S88 classification
|
||||
|
||||
| Level | Colour | Placement lane |
|
||||
|---|---|---|
|
||||
| **Process Cell** | `#0c99d9` | L5 |
|
||||
|
||||
## Flow layout rules
|
||||
|
||||
When wiring this node into a multi-node demo or production flow, follow the
|
||||
placement rule set in the **EVOLV superproject**:
|
||||
|
||||
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||
|
||||
Key points for this node:
|
||||
- Place on lane **L5** (x-position per the lane table in the rule).
|
||||
- Stack same-level siblings vertically.
|
||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||
- Wrap in a Node-RED group box coloured `#0c99d9` (Process Cell).
|
||||
11
README.md
11
README.md
@@ -1 +1,10 @@
|
||||
# rotating machine
|
||||
# pumpingStation
|
||||
|
||||
Wet-well basin model and pump orchestration node for EVOLV.
|
||||
|
||||
The detailed documentation lives in [`wiki/`](wiki/):
|
||||
|
||||
- [`wiki/functional-description.md`](wiki/functional-description.md) defines the shared basin model, pipe reference semantics, safety points, net-flow selection, and child registration behaviour.
|
||||
- [`wiki/modes/`](wiki/modes/) documents control-mode-specific behaviour. For v1.0 the editor exposes `levelbased` and `manual`; levelbased supports linear and log curves with separate rising/falling ramp semantics.
|
||||
- [`wiki/diagrams/basin-model.drawio.svg`](wiki/diagrams/basin-model.drawio.svg) is the current source of truth for the generic basin model.
|
||||
- [`examples/basic-dashboard.flow.json`](examples/basic-dashboard.flow.json) provides a simple Node-RED Dashboard 2 flow with level, volume, demand, net-flow, and safety-state trends.
|
||||
|
||||
589
examples/basic-dashboard.flow.json
Normal file
589
examples/basic-dashboard.flow.json
Normal file
@@ -0,0 +1,589 @@
|
||||
[
|
||||
{
|
||||
"id": "ps_tab_basic_dashboard",
|
||||
"type": "tab",
|
||||
"label": "PumpingStation Dashboard",
|
||||
"disabled": false,
|
||||
"info": "Basic level-based pumpingStation dashboard with basin trends and safety state."
|
||||
},
|
||||
{
|
||||
"id": "ui_base_ps_basic",
|
||||
"type": "ui-base",
|
||||
"name": "EVOLV Demo",
|
||||
"path": "/dashboard",
|
||||
"appIcon": "",
|
||||
"includeClientData": true,
|
||||
"acceptsClientConfig": [
|
||||
"ui-notification",
|
||||
"ui-control"
|
||||
],
|
||||
"showPathInSidebar": false,
|
||||
"headerContent": "page",
|
||||
"navigationStyle": "default",
|
||||
"titleBarStyle": "default"
|
||||
},
|
||||
{
|
||||
"id": "ui_theme_ps_basic",
|
||||
"type": "ui-theme",
|
||||
"name": "EVOLV Pumping Theme",
|
||||
"colors": {
|
||||
"surface": "#ffffff",
|
||||
"primary": "#0c99d9",
|
||||
"bgPage": "#f1f3f5",
|
||||
"groupBg": "#ffffff",
|
||||
"groupOutline": "#cfd7de"
|
||||
},
|
||||
"sizes": {
|
||||
"density": "default",
|
||||
"pagePadding": "14px",
|
||||
"groupGap": "14px",
|
||||
"groupBorderRadius": "6px",
|
||||
"widgetGap": "12px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ui_page_ps_basic",
|
||||
"type": "ui-page",
|
||||
"name": "PumpingStation",
|
||||
"ui": "ui_base_ps_basic",
|
||||
"path": "/pumping-station",
|
||||
"icon": "water_drop",
|
||||
"layout": "grid",
|
||||
"theme": "ui_theme_ps_basic",
|
||||
"breakpoints": [
|
||||
{
|
||||
"name": "Default",
|
||||
"px": "0",
|
||||
"cols": "12"
|
||||
}
|
||||
],
|
||||
"order": 1,
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"id": "ui_group_ps_inputs",
|
||||
"type": "ui-group",
|
||||
"name": "Simulation Inputs",
|
||||
"page": "ui_page_ps_basic",
|
||||
"width": "4",
|
||||
"height": "1",
|
||||
"order": 1,
|
||||
"showTitle": true,
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"id": "ui_group_ps_trends",
|
||||
"type": "ui-group",
|
||||
"name": "Basin Trends",
|
||||
"page": "ui_page_ps_basic",
|
||||
"width": "8",
|
||||
"height": "1",
|
||||
"order": 2,
|
||||
"showTitle": true,
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"id": "ui_group_ps_state",
|
||||
"type": "ui-group",
|
||||
"name": "State",
|
||||
"page": "ui_page_ps_basic",
|
||||
"width": "12",
|
||||
"height": "1",
|
||||
"order": 3,
|
||||
"showTitle": true,
|
||||
"className": ""
|
||||
},
|
||||
{
|
||||
"id": "ps_node_basic",
|
||||
"type": "pumpingStation",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "PS Dashboard Demo",
|
||||
"basinVolume": 50,
|
||||
"basinHeight": 5,
|
||||
"inflowLevel": 3,
|
||||
"outflowLevel": 0.2,
|
||||
"overflowLevel": 4.5,
|
||||
"defaultFluid": "wastewater",
|
||||
"inletPipeDiameter": 0.4,
|
||||
"outletPipeDiameter": 0.3,
|
||||
"pipelineLength": 80,
|
||||
"maxDischargeHead": 24,
|
||||
"staticHead": 12,
|
||||
"maxInflowRate": 200,
|
||||
"temperatureReferenceDegC": 15,
|
||||
"timeleftToFullOrEmptyThresholdSeconds": 0,
|
||||
"enableDryRunProtection": true,
|
||||
"enableHighVolumeSafety": true,
|
||||
"enableOverfillProtection": true,
|
||||
"dryRunThresholdPercent": 2,
|
||||
"highVolumeSafetyThresholdPercent": 98,
|
||||
"overfillThresholdPercent": 98,
|
||||
"minHeightBasedOn": "outlet",
|
||||
"processOutputFormat": "process",
|
||||
"dbaseOutputFormat": "influxdb",
|
||||
"refHeight": "NAP",
|
||||
"basinBottomRef": 0,
|
||||
"unit": "m3/h",
|
||||
"enableLog": false,
|
||||
"logLevel": "error",
|
||||
"positionVsParent": "atEquipment",
|
||||
"positionIcon": "",
|
||||
"hasDistance": false,
|
||||
"distance": 0,
|
||||
"distanceUnit": "m",
|
||||
"distanceDescription": "",
|
||||
"controlMode": "levelbased",
|
||||
"levelCurveType": "linear",
|
||||
"logCurveFactor": 9,
|
||||
"minLevel": 1,
|
||||
"startLevel": 2,
|
||||
"maxLevel": 4,
|
||||
"x": 720,
|
||||
"y": 260,
|
||||
"wires": [
|
||||
[
|
||||
"ps_parse_output"
|
||||
],
|
||||
[
|
||||
"ps_debug_influx"
|
||||
],
|
||||
[
|
||||
"ps_debug_parent"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_calibrate_initial",
|
||||
"type": "inject",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Set start level 2 m",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": true,
|
||||
"onceDelay": "0.5",
|
||||
"topic": "calibratePredictedLevel",
|
||||
"payload": "2",
|
||||
"payloadType": "num",
|
||||
"x": 180,
|
||||
"y": 180,
|
||||
"wires": [
|
||||
[
|
||||
"ps_node_basic"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_auto_inflow",
|
||||
"type": "inject",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Auto inflow 0.008 m3/s",
|
||||
"props": [
|
||||
{
|
||||
"p": "payload"
|
||||
}
|
||||
],
|
||||
"repeat": "1",
|
||||
"crontab": "",
|
||||
"once": true,
|
||||
"onceDelay": "1",
|
||||
"topic": "",
|
||||
"payload": "0.008",
|
||||
"payloadType": "num",
|
||||
"x": 180,
|
||||
"y": 240,
|
||||
"wires": [
|
||||
[
|
||||
"ps_build_qin"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_inflow_input",
|
||||
"type": "ui-number-input",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_inputs",
|
||||
"name": "Inflow",
|
||||
"label": "Inflow (m3/s)",
|
||||
"order": 1,
|
||||
"width": "4",
|
||||
"height": "1",
|
||||
"passthru": true,
|
||||
"topic": "",
|
||||
"min": 0,
|
||||
"max": 0.05,
|
||||
"step": 0.001,
|
||||
"x": 190,
|
||||
"y": 300,
|
||||
"wires": [
|
||||
[
|
||||
"ps_build_qin"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_build_qin",
|
||||
"type": "function",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Build q_in",
|
||||
"func": "msg.topic = 'q_in';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 440,
|
||||
"y": 260,
|
||||
"wires": [
|
||||
[
|
||||
"ps_node_basic"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_outflow_input",
|
||||
"type": "ui-number-input",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_inputs",
|
||||
"name": "Outflow",
|
||||
"label": "Outflow (m3/s)",
|
||||
"order": 2,
|
||||
"width": "4",
|
||||
"height": "1",
|
||||
"passthru": true,
|
||||
"topic": "",
|
||||
"min": 0,
|
||||
"max": 0.05,
|
||||
"step": 0.001,
|
||||
"x": 190,
|
||||
"y": 360,
|
||||
"wires": [
|
||||
[
|
||||
"ps_build_qout"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_build_qout",
|
||||
"type": "function",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Build q_out",
|
||||
"func": "msg.topic = 'q_out';\nmsg.unit = 'm3/s';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 440,
|
||||
"y": 360,
|
||||
"wires": [
|
||||
[
|
||||
"ps_node_basic"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_parse_output",
|
||||
"type": "function",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Parse PS output",
|
||||
"func": "// MeasurementContainer flat keys are `${type}.${variant}.${position}.${childId}`.\n// When PS writes without an explicit .child(), the childId is the literal\n// string 'default' — DON'T strip it. See generalFunctions/src/measurements/\n// MeasurementContainer.js getFlattenedOutput for details.\nconst fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment.default', 'level.measured.atequipment.default');\nconst volume = firstFinite('volume.predicted.atequipment.default', 'volume.measured.atequipment.default');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment.default', 'netFlowRate.measured.atequipment.default');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
|
||||
"outputs": 6,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 980,
|
||||
"y": 220,
|
||||
"wires": [
|
||||
[
|
||||
"ps_chart_level"
|
||||
],
|
||||
[
|
||||
"ps_chart_volume"
|
||||
],
|
||||
[
|
||||
"ps_chart_demand"
|
||||
],
|
||||
[
|
||||
"ps_chart_netflow"
|
||||
],
|
||||
[
|
||||
"ps_text_safety"
|
||||
],
|
||||
[
|
||||
"ps_text_snapshot"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ps_chart_level",
|
||||
"type": "ui-chart",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_trends",
|
||||
"name": "Level",
|
||||
"label": "Level (m)",
|
||||
"order": 1,
|
||||
"width": 4,
|
||||
"height": 4,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"xAxisType": "time",
|
||||
"yAxisLabel": "m",
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"x": 1230,
|
||||
"y": 140,
|
||||
"wires": [],
|
||||
"showLegend": false,
|
||||
"categoryType": "msg",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"ymin": "0",
|
||||
"ymax": "5",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"interpolation": "linear",
|
||||
"className": "",
|
||||
"colors": [
|
||||
"#0c99d9"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true
|
||||
},
|
||||
{
|
||||
"id": "ps_chart_volume",
|
||||
"type": "ui-chart",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_trends",
|
||||
"name": "Volume",
|
||||
"label": "Volume (m3)",
|
||||
"order": 2,
|
||||
"width": 4,
|
||||
"height": 4,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"xAxisType": "time",
|
||||
"yAxisLabel": "m3",
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"x": 1230,
|
||||
"y": 200,
|
||||
"wires": [],
|
||||
"showLegend": false,
|
||||
"categoryType": "msg",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"ymin": "0",
|
||||
"ymax": "50",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"interpolation": "linear",
|
||||
"className": "",
|
||||
"colors": [
|
||||
"#2ca02c"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true
|
||||
},
|
||||
{
|
||||
"id": "ps_chart_demand",
|
||||
"type": "ui-chart",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_trends",
|
||||
"name": "Demand",
|
||||
"label": "Demand (%)",
|
||||
"order": 3,
|
||||
"width": 4,
|
||||
"height": 4,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"xAxisType": "time",
|
||||
"yAxisLabel": "%",
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"x": 1230,
|
||||
"y": 260,
|
||||
"wires": [],
|
||||
"showLegend": false,
|
||||
"categoryType": "msg",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"ymin": "0",
|
||||
"ymax": "120",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"interpolation": "linear",
|
||||
"className": "",
|
||||
"colors": [
|
||||
"#d68910"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true
|
||||
},
|
||||
{
|
||||
"id": "ps_chart_netflow",
|
||||
"type": "ui-chart",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_trends",
|
||||
"name": "Net Flow",
|
||||
"label": "Net flow (m3/s)",
|
||||
"order": 4,
|
||||
"width": 4,
|
||||
"height": 4,
|
||||
"chartType": "line",
|
||||
"category": "topic",
|
||||
"xAxisType": "time",
|
||||
"yAxisLabel": "m3/s",
|
||||
"removeOlder": "15",
|
||||
"removeOlderUnit": "60",
|
||||
"x": 1240,
|
||||
"y": 320,
|
||||
"wires": [],
|
||||
"showLegend": false,
|
||||
"categoryType": "msg",
|
||||
"xAxisProperty": "",
|
||||
"xAxisPropertyType": "timestamp",
|
||||
"xAxisFormat": "",
|
||||
"xAxisFormatType": "auto",
|
||||
"yAxisProperty": "payload",
|
||||
"yAxisPropertyType": "msg",
|
||||
"xmin": "",
|
||||
"xmax": "",
|
||||
"ymin": "",
|
||||
"ymax": "",
|
||||
"bins": 10,
|
||||
"action": "append",
|
||||
"stackSeries": false,
|
||||
"pointShape": "circle",
|
||||
"pointRadius": 4,
|
||||
"interpolation": "linear",
|
||||
"className": "",
|
||||
"colors": [
|
||||
"#9467bd"
|
||||
],
|
||||
"textColor": [
|
||||
"#666666"
|
||||
],
|
||||
"textColorDefault": true,
|
||||
"gridColor": [
|
||||
"#e5e5e5"
|
||||
],
|
||||
"gridColorDefault": true
|
||||
},
|
||||
{
|
||||
"id": "ps_text_safety",
|
||||
"type": "ui-text",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_state",
|
||||
"name": "Safety",
|
||||
"label": "Safety",
|
||||
"order": 1,
|
||||
"width": 4,
|
||||
"height": 1,
|
||||
"format": "{{msg.payload}}",
|
||||
"layout": "row-spread",
|
||||
"x": 1230,
|
||||
"y": 380,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "ps_text_snapshot",
|
||||
"type": "ui-text",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"group": "ui_group_ps_state",
|
||||
"name": "Snapshot",
|
||||
"label": "Snapshot",
|
||||
"order": 2,
|
||||
"width": 8,
|
||||
"height": 1,
|
||||
"format": "{{msg.payload}}",
|
||||
"layout": "row-spread",
|
||||
"x": 1240,
|
||||
"y": 440,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "ps_debug_influx",
|
||||
"type": "debug",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Influx output",
|
||||
"active": false,
|
||||
"tosidebar": true,
|
||||
"console": false,
|
||||
"tostatus": false,
|
||||
"complete": "true",
|
||||
"targetType": "full",
|
||||
"x": 980,
|
||||
"y": 320,
|
||||
"wires": []
|
||||
},
|
||||
{
|
||||
"id": "ps_debug_parent",
|
||||
"type": "debug",
|
||||
"z": "ps_tab_basic_dashboard",
|
||||
"name": "Parent output",
|
||||
"active": false,
|
||||
"tosidebar": true,
|
||||
"console": false,
|
||||
"tostatus": false,
|
||||
"complete": "true",
|
||||
"targetType": "full",
|
||||
"x": 980,
|
||||
"y": 380,
|
||||
"wires": []
|
||||
}
|
||||
]
|
||||
@@ -8,22 +8,51 @@
|
||||
| **Control Module** | `#a9daee` | zwart |
|
||||
|
||||
-->
|
||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||
|
||||
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
|
||||
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
|
||||
<script src="/pumpingStation/editor/index.js"></script>
|
||||
<script src="/pumpingStation/editor/bounds.js"></script>
|
||||
<script src="/pumpingStation/editor/basin-diagram.js"></script>
|
||||
<script src="/pumpingStation/editor/mode-preview.js"></script>
|
||||
<script src="/pumpingStation/editor/hover-couple.js"></script>
|
||||
<script src="/pumpingStation/editor/oneditprepare.js"></script>
|
||||
<script src="/pumpingStation/editor/oneditsave.js"></script>
|
||||
|
||||
<script>//test
|
||||
RED.nodes.registerType("pumpingStation", {
|
||||
category: "EVOLV",
|
||||
color: "#0c99d9", // color for the node based on the S88 schema
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
|
||||
// Define station-specific properties
|
||||
simulator: { value: false },
|
||||
basinVolume: { value: 1 }, // m³, total empty basin
|
||||
basinHeight: { value: 1 }, // m, floor to top
|
||||
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
heightOverflow: { value: 0.9 }, // m, overflow elevation
|
||||
inflowLevel: { value: 0.8 }, // m, bottom/invert of inlet pipe above floor
|
||||
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
|
||||
overflowLevel: { value: 0.9 }, // m, overflow elevation
|
||||
defaultFluid: { value: "wastewater" },
|
||||
inletPipeDiameter: { value: 0.3 }, // m
|
||||
outletPipeDiameter: { value: 0.3 }, // m
|
||||
pipelineLength: { value: 80 }, // m
|
||||
maxDischargeHead: { value: 24 }, // m
|
||||
staticHead: { value: 12 }, // m
|
||||
maxInflowRate: { value: 200 }, // m³/h
|
||||
temperatureReferenceDegC: { value: 15 },
|
||||
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
|
||||
enableDryRunProtection: { value: true },
|
||||
enableHighVolumeSafety: { value: true },
|
||||
enableOverfillProtection: { value: true }, // deprecated alias
|
||||
dryRunThresholdPercent: { value: 2 },
|
||||
highVolumeSafetyThresholdPercent: { value: 98 },
|
||||
overfillThresholdPercent: { value: 98 }, // deprecated alias
|
||||
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
// Advanced reference information
|
||||
refHeight: { value: "NAP" }, // reference height
|
||||
@@ -47,7 +76,21 @@
|
||||
hasDistance: { value: false },
|
||||
distance: { value: 0 },
|
||||
distanceUnit: { value: "m" },
|
||||
distanceDescription: { value: "" }
|
||||
distanceDescription: { value: "" },
|
||||
|
||||
// control strategy
|
||||
controlMode: { value: "levelbased" },
|
||||
levelCurveType: { value: "linear" },
|
||||
logCurveFactor: { value: 9 },
|
||||
enableShiftedRamp: { value: false },
|
||||
shiftLevel: { value: 0 },
|
||||
shiftArmPercent: { value: 95 },
|
||||
startLevel: { value: null },
|
||||
stopLevel: { value: null },
|
||||
minLevel: { value: null },
|
||||
maxLevel: { value: null },
|
||||
flowSetpoint: { value: null },
|
||||
flowDeadband: { value: null }
|
||||
|
||||
},
|
||||
|
||||
@@ -61,51 +104,11 @@
|
||||
return this.positionIcon + " PumpingStation";
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||
window.EVOLV.nodes.pumpingStation.initEditor(this);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
// Wait for the menu data to be ready before initializing the editor
|
||||
waitForMenuData();
|
||||
|
||||
// NODE SPECIFIC
|
||||
document.getElementById("node-input-basinVolume");
|
||||
document.getElementById("node-input-basinHeight");
|
||||
document.getElementById("node-input-heightInlet");
|
||||
document.getElementById("node-input-heightOutlet");
|
||||
document.getElementById("node-input-heightOverflow");
|
||||
document.getElementById("node-input-refHeight");
|
||||
document.getElementById("node-input-basinBottomRef");
|
||||
|
||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
||||
if (refHeightEl) {
|
||||
refHeightEl.value = this.refHeight || "NAP";
|
||||
}
|
||||
|
||||
|
||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
||||
oneditprepare: function () {
|
||||
window.PSEditor.oneditprepare.call(this);
|
||||
},
|
||||
oneditsave: function () {
|
||||
const node = this;
|
||||
|
||||
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
//node specific
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
|
||||
node.simulator = document.getElementById("node-input-simulator").checked;
|
||||
|
||||
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef"]
|
||||
.forEach(field => {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
});
|
||||
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
||||
window.PSEditor.oneditsave.call(this);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -115,7 +118,7 @@
|
||||
|
||||
<script type="text/html" data-template-name="pumpingStation">
|
||||
|
||||
<!-- Simulator toggle -->
|
||||
<h4>Simulation</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label>
|
||||
<input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" />
|
||||
@@ -123,34 +126,394 @@
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Basin parameters</h4>
|
||||
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Each input on the left controls a line in the diagram on the right — hover an input to highlight its line.</p>
|
||||
<div id="ps-basin-validation" style="display:none;color:#C0392B;font-size:11px;margin:0 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
|
||||
|
||||
<!-- Basin geometry -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
|
||||
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
|
||||
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
<style>
|
||||
/* Two-column layout: stacked colour-coded inputs on the left,
|
||||
SVG on the right. Hover an input row → its paired SVG line
|
||||
(referenced by data-couples-line) gets a thicker stroke. */
|
||||
.ps-diag { display:flex; gap:28px; align-items:flex-start; margin:0 0 14px 0; }
|
||||
.ps-diag-side { width: 220px; flex: 0 0 220px; display:flex; flex-direction:column; gap:6px; }
|
||||
.ps-diag-side .ps-row {
|
||||
display:grid; grid-template-columns: minmax(0,1fr) 70px 16px; align-items:center;
|
||||
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
|
||||
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer;
|
||||
min-width:0;
|
||||
}
|
||||
.ps-diag-side .ps-row:hover { background:#f0f0f0; }
|
||||
.ps-diag-side .ps-row.ps-readonly { background:#fff; cursor:default; opacity:0.85; }
|
||||
.ps-diag-side .ps-row label { font-weight:600; margin:0; line-height:1.2; }
|
||||
.ps-diag-side .ps-row .ps-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
|
||||
.ps-diag-side .ps-row input[type=number] {
|
||||
width:100%; height:22px; box-sizing:border-box; font-size:11px;
|
||||
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px;
|
||||
background:#fff;
|
||||
}
|
||||
.ps-diag-side .ps-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
|
||||
.ps-diag-side .ps-row .ps-readonly-val {
|
||||
font-family:monospace; font-size:11px; color:#666; text-align:right;
|
||||
padding-right:4px;
|
||||
}
|
||||
.ps-diag-side .ps-row .ps-unit { color:#888; font-size:10px; }
|
||||
.ps-diag-svg { flex:1; min-width:0; }
|
||||
/* Border colours matched to each SVG line stroke. */
|
||||
.ps-row[data-stroke="#333"] { border-left-color:#333; }
|
||||
.ps-row[data-stroke="#C0392B"] { border-left-color:#C0392B; }
|
||||
.ps-row[data-stroke="#1E8449"] { border-left-color:#1E8449; }
|
||||
.ps-row[data-stroke="#1F4E79"] { border-left-color:#1F4E79; }
|
||||
.ps-row[data-stroke="#D68910"] { border-left-color:#D68910; }
|
||||
.ps-row[data-stroke="#888"] { border-left-color:#888; }
|
||||
.ps-row[data-stroke="#333"] label { color:#333; }
|
||||
.ps-row[data-stroke="#C0392B"] label { color:#C0392B; }
|
||||
.ps-row[data-stroke="#1E8449"] label { color:#1E8449; }
|
||||
.ps-row[data-stroke="#1F4E79"] label { color:#1F4E79; }
|
||||
.ps-row[data-stroke="#D68910"] label { color:#D68910; }
|
||||
.ps-row[data-stroke="#888"] label { color:#888; }
|
||||
/* Highlight class applied to the SVG line during input row hover. */
|
||||
.ps-line-highlight { stroke-width:3.5 !important; opacity:1 !important; }
|
||||
</style>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
BASIN DIAGRAM (ps-basin-diagram)
|
||||
============================================================
|
||||
Coordinate system: SVG viewBox is 520 (wide) × 430 (tall).
|
||||
Origin (0,0) is top-left. +x goes right. +y goes DOWN.
|
||||
Bigger y = lower on screen.
|
||||
|
||||
X-LANES (all viewBox units, edit any of these to shift a column):
|
||||
x ≈ 5..75 left input column (inlet number input)
|
||||
x = 80 inlet unit "m"
|
||||
x = 135 inlet text labels (right-aligned, anchor at x)
|
||||
x = 140..200 inlet arrow (line + arrow head into tank)
|
||||
x = 200..320 tank body (rect.x=200 width=120) — interior 201..319
|
||||
x = 195/325 threshold tick lines (extend 5 px outside tank)
|
||||
x = 260 mid-tank zone labels (centered)
|
||||
x = 320..360 outlet arrow
|
||||
x = 330 right-side label column ("overflowLevel", "Outlet", …)
|
||||
x = 365 outlet sub-text column
|
||||
x = 425..495 right input column (foreignObject inputs, width=70)
|
||||
x = 500 right unit column ("m", "m³")
|
||||
|
||||
Y-COORDINATES:
|
||||
y = 40 tank rim (basinHeight line)
|
||||
y = 380 tank floor / datum
|
||||
y = 410 ordering warning ribbon
|
||||
y = 19,44 "basin volume" / "basinHeight" labels (static)
|
||||
Threshold rows (overflowLevel, highVolumeSafetyLevel, inflowLevelGuide,
|
||||
dryRunLevel, outflowLevel, basinHeight tick) get y assigned
|
||||
DYNAMICALLY by the redraw() function around line 250-340 below.
|
||||
Their input row may be NUDGED off ideal-y to avoid overlap; a leader
|
||||
line (ps-leader-*) is then drawn between threshold y and input y.
|
||||
Zone-label rows (ps-zone-*) get y assigned dynamically to the midpoint
|
||||
between adjacent thresholds; they hide if the gap is too small.
|
||||
|
||||
HOW TO NUDGE OVERLAPPING LABELS:
|
||||
- For STATIC y values (hardcoded below): edit the inline y attribute.
|
||||
- For DYNAMIC y values: search redraw() for the element id and adjust
|
||||
the layout math (e.g. NUDGE_PX or the threshold-stack ordering).
|
||||
- For x: every label column above can be shifted by editing the inline
|
||||
x attribute on the relevant <text>/<line>/<foreignObject>.
|
||||
|
||||
Note: dynamic line/label positioning lives in oneditprepare → redraw()
|
||||
further up in this file. Changing only the inline y here will be
|
||||
overridden on first redraw for any element whose id appears in redraw().
|
||||
============================================================
|
||||
-->
|
||||
<div class="ps-diag" id="ps-basin-wrap">
|
||||
<!-- LEFT: stacked colour-coded inputs. Hover a row → its paired SVG
|
||||
line (data-couples-line) is highlighted in the diagram. -->
|
||||
<div class="ps-diag-side">
|
||||
<div class="ps-row" data-stroke="#333" style="cursor:default;">
|
||||
<div><label>basinVolume</label><div class="ps-sub">total empty volume (no marker)</div></div>
|
||||
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||
<span class="ps-unit">m³</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#333" data-couples-line="ps-line-basinHeight">
|
||||
<div><label>basinHeight</label><div class="ps-sub">floor → rim</div></div>
|
||||
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#C0392B" data-couples-line="ps-line-overflowLevel">
|
||||
<div><label>overflowLevel</label><div class="ps-sub">spill height</div></div>
|
||||
<input type="number" id="node-input-overflowLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row ps-readonly" data-stroke="#D68910" data-couples-line="ps-line-highVolumeSafetyLevel">
|
||||
<div><label>highVolumeSafety</label><div class="ps-sub">derived (overflow × %)</div></div>
|
||||
<span id="derived-highVolumeSafetyLevel" class="ps-readonly-val">— m</span>
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#1F4E79" data-couples-line="ps-line-inflowLevel">
|
||||
<div><label>inflowLevel</label><div class="ps-sub">bottom of inlet pipe</div></div>
|
||||
<input type="number" id="node-input-inflowLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-line-dryRunLevel">
|
||||
<div><label>dryRunLevel</label><div class="ps-sub">derived (outflow × dry%)</div></div>
|
||||
<span id="derived-dryRunLevel" class="ps-readonly-val">— m</span>
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#1F4E79" data-couples-line="ps-line-outflowLevel">
|
||||
<div><label>outflowLevel</label><div class="ps-sub">top of outlet pipe</div></div>
|
||||
<input type="number" id="node-input-outflowLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row ps-readonly" data-stroke="#888" style="cursor:default;">
|
||||
<div><label>basinBottomRef</label><div class="ps-sub">floor above NAP (no marker)</div></div>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RIGHT: SVG. The viewBox is now narrower (320 wide) since the right
|
||||
input column is gone — labels render inside the tank's right margin. -->
|
||||
<svg id="ps-basin-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 430"
|
||||
style="display:block;width:100%;max-width:360px;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
|
||||
font-family="Arial,sans-serif" font-size="11">
|
||||
<defs>
|
||||
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
|
||||
</marker>
|
||||
</defs>
|
||||
<!-- Tank body — shifted right (x=145, width=110) to give the inlet
|
||||
sub-label "bottom of pipe" room on the left without clipping.
|
||||
Threshold tick lines extend 5 px outside the tank walls. -->
|
||||
<rect x="145" y="40" width="110" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
|
||||
<rect id="ps-deadvol" x="146" width="108" fill="#AACCE0" />
|
||||
|
||||
<!-- Mid-tank zone labels — centred at x=200 (tank centre). -->
|
||||
<text id="ps-zone-spare" x="200" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare</text>
|
||||
<text id="ps-zone-sewage" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + buffer</text>
|
||||
<text id="ps-zone-buffer1" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Buffer</text>
|
||||
<text id="ps-zone-buffer2" x="200" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Buffer</text>
|
||||
<text id="ps-zone-dead" x="200" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead vol</text>
|
||||
|
||||
<!-- basinHeight tick at tank rim (y=40, static). -->
|
||||
<line id="ps-line-basinHeight" x1="140" y1="40" x2="260" y2="40" stroke="#333" stroke-width="1.5" />
|
||||
<text id="ps-label-basinHeight" x="265" y="44" fill="#333">basinHeight</text>
|
||||
|
||||
<line id="ps-line-overflowLevel" x1="140" x2="260" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-overflowLevel" x="265" fill="#C0392B">overflowLevel</text>
|
||||
|
||||
<line id="ps-line-highVolumeSafetyLevel" x1="140" x2="260" stroke="#D68910" stroke-dasharray="1 2" stroke-width="1" opacity="0.7" />
|
||||
<text id="ps-label-highVolumeSafetyLevel" x="265" fill="#D68910" font-size="10" font-style="italic">highVolSafety</text>
|
||||
|
||||
<line id="ps-line-inflowLevelGuide" x1="145" x2="255" stroke="#1F4E79" stroke-dasharray="2 3" stroke-width="1" opacity="0.55" />
|
||||
<text id="ps-label-inflowLevelGuide" x="265" fill="#1F4E79" font-size="10" font-style="italic">inlet invert</text>
|
||||
|
||||
<line id="ps-line-inflowLevel" x1="85" x2="145" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||
<text id="ps-label-inflowLevel" x="80" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
|
||||
<text id="ps-sub-inflowLevel" x="80" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
|
||||
|
||||
<line id="ps-line-dryRunLevel" x1="140" x2="260" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
|
||||
<text id="ps-label-dryRunLevel" x="265" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel</text>
|
||||
|
||||
<line id="ps-line-outflowLevel" x1="255" x2="295" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||
<text id="ps-label-outflowLevel" x="300" fill="#1F4E79" font-weight="bold">Outlet</text>
|
||||
<text id="ps-sub-outflowLevel" x="300" fill="#777" font-size="9">top of pipe</text>
|
||||
|
||||
<!-- Floor / datum — datum label sits BELOW the tank (y=395) so it
|
||||
never collides with the Outlet / top-of-pipe sub-label when
|
||||
outflowLevel is near the floor. -->
|
||||
<line x1="140" y1="380" x2="260" y2="380" stroke="#000" stroke-width="2" />
|
||||
<text x="200" y="395" text-anchor="middle" fill="#000" font-size="10">0 m (datum)</text>
|
||||
|
||||
<!-- Ordering-warning ribbon -->
|
||||
<text id="ps-warning" x="200" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Inlet/Outlet elevations -->
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Control Strategy</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
|
||||
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
|
||||
<select id="node-input-controlMode">
|
||||
<option value="levelbased">Level-based</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
|
||||
|
||||
<div id="ps-mode-levelbased" class="ps-mode-section">
|
||||
<div class="form-row">
|
||||
<label for="node-input-levelCurveType">Curve</label>
|
||||
<select id="node-input-levelCurveType" style="width:60%;">
|
||||
<option value="linear">Linear</option>
|
||||
<option value="log">Log - fast early response</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="ps-log-factor-row" style="display:none;">
|
||||
<label for="node-input-logCurveFactor">Log shape factor</label>
|
||||
<input type="number" id="node-input-logCurveFactor" min="0.001" step="0.1" style="width:100px;" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableShiftedRamp" style="width:auto;">
|
||||
<input type="checkbox" id="node-input-enableShiftedRamp" style="width:auto;vertical-align:middle;margin-right:6px;" />
|
||||
Enable shifted ramp (hysteresis)
|
||||
</label>
|
||||
</div>
|
||||
<div id="ps-mode-validation" style="display:none;color:#C0392B;font-size:11px;margin:4px 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
|
||||
<!--
|
||||
============================================================
|
||||
LEVEL-BASED MODE PREVIEW (ps-levelbased-mode-diagram)
|
||||
============================================================
|
||||
Coordinate system: SVG viewBox is 430 (wide) × 185 (tall).
|
||||
Origin (0,0) top-left. +x right. +y DOWN (so y=24 is HIGH on screen,
|
||||
y=158 is at the baseline).
|
||||
|
||||
X-AXIS (level, in viewBox px) — controlled by redrawModeDiagram() in
|
||||
the oneditprepare script above. The function maps the user's
|
||||
startLevel/inflowLevel/maxLevel/shiftLevel onto the px window
|
||||
x0=52 (left axis) → x1=390 (right end of plot).
|
||||
DO NOT hardcode x for ps-mode-line-* / ps-mode-label-*; they're
|
||||
rewritten on every input change.
|
||||
|
||||
Y-AXIS (process demand %):
|
||||
y=24 100% (top of plot)
|
||||
y=140 0% (baseline / x-axis)
|
||||
y=160 OFF baseline (pink dashed)
|
||||
y=180 axis labels under the plot ("dry run","start","inlet","max","overflow","shift")
|
||||
y=205 legend captions (one row, BELOW axis labels — moved here to stop
|
||||
colliding with the title row at y=14)
|
||||
y=14 curve-type title only ("linear curve" / "log curve"), centered.
|
||||
|
||||
WHAT IS STATIC vs DYNAMIC:
|
||||
STATIC (edit inline below): viewBox bounds, axis lines, "0%"/"100%"
|
||||
tick labels, in-plot caption x/y, axis-label y=176.
|
||||
DYNAMIC (edit in JS): every ps-mode-line-*, ps-mode-label-* x;
|
||||
ps-mode-curve-up/down points; visibility of shift elements.
|
||||
|
||||
HOW TO NUDGE OVERLAPPING TEXT:
|
||||
- Move the curve-type caption: edit the x="220" y="18" on
|
||||
#ps-mode-curve-label.
|
||||
- Move axis labels (start/inlet/max/shift) UP or DOWN: edit y="176".
|
||||
(To move them left/right relative to the line, edit redrawModeDiagram
|
||||
in the script — the x is set there.)
|
||||
- Move the legend captions: edit x="280" y="54" / y="72" on
|
||||
#ps-mode-curve-up-label / #ps-mode-curve-down-label.
|
||||
- To resize the plot box, change viewBox + the x0/x1/y0/y1 constants
|
||||
in redrawModeDiagram() to match.
|
||||
============================================================
|
||||
-->
|
||||
<div class="ps-diag" id="ps-mode-wrap">
|
||||
<!-- LEFT side-panel: only the level-based mode's editable inputs +
|
||||
read-only displays for derived/related levels (so user has all
|
||||
level context in one column). Hover-coupled to the SVG markers. -->
|
||||
<div class="ps-diag-side">
|
||||
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-dryRunLevel">
|
||||
<div><label>dryRunLevel</label><div class="ps-sub">derived</div></div>
|
||||
<span id="ps-mode-readout-dryRun" class="ps-readonly-val">— m</span>
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#1E8449" data-couples-line="ps-mode-line-startLevel">
|
||||
<div><label>startLevel</label><div class="ps-sub">pump-on threshold</div></div>
|
||||
<input type="number" id="node-input-startLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#7D3C98" data-couples-line="ps-mode-line-stopLevel">
|
||||
<div><label>stopLevel</label><div class="ps-sub">pump-off threshold (optional, ≤ startLevel)</div></div>
|
||||
<input type="number" id="node-input-stopLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
|
||||
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||
<span id="ps-mode-readout-inflow" class="ps-readonly-val">— m</span>
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" data-stroke="#D68910" data-couples-line="ps-mode-line-maxLevel">
|
||||
<div><label>maxLevel</label><div class="ps-sub">100% saturation</div></div>
|
||||
<input type="number" id="node-input-maxLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" id="ps-shiftLevel-row" data-stroke="#D68910" data-couples-line="ps-mode-line-shiftLevel" style="display:none;">
|
||||
<div><label>shiftLevel</label><div class="ps-sub">held output drops here</div></div>
|
||||
<input type="number" id="node-input-shiftLevel" min="0" step="0.01" />
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
<div class="ps-row" id="ps-shiftArmPercent-row" data-stroke="#D68910" data-couples-line="ps-mode-line-armPercent" style="display:none;">
|
||||
<div><label>shiftArmPercent</label><div class="ps-sub">arms when output % crosses this</div></div>
|
||||
<input type="number" id="node-input-shiftArmPercent" min="0" max="100" step="1" />
|
||||
<span class="ps-unit">%</span>
|
||||
</div>
|
||||
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-overflowLevel">
|
||||
<div><label>overflowLevel</label><div class="ps-sub">from basin above</div></div>
|
||||
<span id="ps-mode-readout-overflow" class="ps-readonly-val">— m</span>
|
||||
<span class="ps-unit">m</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="ps-levelbased-mode-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 215"
|
||||
style="display:block;width:100%;max-width:540px;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
|
||||
font-family="Arial,sans-serif" font-size="11">
|
||||
<!-- ZONE BANDS — drawn FIRST so they sit behind axes and curves.
|
||||
x is set DYNAMICALLY by redrawModeDiagram(); y/height span the full plot (24..160).
|
||||
Order from leftmost to rightmost: dryRun (red) | safetyLow (orange) | safe (green) |
|
||||
safetyHigh (orange) | overflow (red).
|
||||
-->
|
||||
<rect id="ps-zone-dryRun" y="24" height="136" fill="#fdecea" />
|
||||
<rect id="ps-zone-safetyLow" y="24" height="136" fill="#fef5e7" />
|
||||
<rect id="ps-zone-safe" y="24" height="136" fill="#eafaf1" />
|
||||
<rect id="ps-zone-safetyHigh" y="24" height="136" fill="#fef5e7" />
|
||||
<rect id="ps-zone-overflow" y="24" height="136" fill="#fdecea" />
|
||||
<!-- X-axis (0% baseline) at y=140; y axis at x=52 (top y=24). Plot range: y=24..140. -->
|
||||
<line x1="52" y1="140" x2="402" y2="140" stroke="#333" />
|
||||
<line x1="52" y1="140" x2="52" y2="24" stroke="#333" />
|
||||
<!-- OFF tier baseline at y=160 (20px below 0% baseline). pink line drawn dynamically by curve. -->
|
||||
<line x1="52" y1="160" x2="402" y2="160" stroke="#E08080" stroke-dasharray="2 3" />
|
||||
<!-- Y-axis tick labels (x=4, right-aligned via text-anchor="end" at x=50 for tighter alignment). -->
|
||||
<text x="50" y="27" text-anchor="end" fill="#333">100%</text>
|
||||
<text x="50" y="143" text-anchor="end" fill="#333">0%</text>
|
||||
<text x="50" y="163" text-anchor="end" fill="#E08080">OFF</text>
|
||||
<!-- Plot title above 100% line. -->
|
||||
<text id="ps-mode-curve-label" x="220" y="14" text-anchor="middle" fill="#555">linear curve</text>
|
||||
<!-- Curves drawn dynamically. Up curve foot=inlet→top=max. Down curve foot=start→top=shiftLevel (visible when shift enabled). -->
|
||||
<polyline id="ps-mode-curve-up" fill="none" stroke="#1E8449" stroke-width="2.5" points="" />
|
||||
<polyline id="ps-mode-curve-down" fill="none" stroke="#D68910" stroke-width="2" stroke-dasharray="5 3" points="" style="display:none;" />
|
||||
<!-- Vertical level-marker lines — span y=24..140 (top to baseline only, NOT into OFF tier). x set dynamically. -->
|
||||
<line id="ps-mode-line-dryRunLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||
<line id="ps-mode-line-startLevel" y1="24" y2="140" stroke="#1E8449" stroke-dasharray="2 2" />
|
||||
<line id="ps-mode-line-stopLevel" y1="24" y2="140" stroke="#7D3C98" stroke-dasharray="2 2" />
|
||||
<line id="ps-mode-line-inflowLevel" y1="24" y2="140" stroke="#1F4E79" stroke-dasharray="2 2" />
|
||||
<line id="ps-mode-line-maxLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" />
|
||||
<line id="ps-mode-line-overflowLevel" y1="24" y2="140" stroke="#C0392B" stroke-dasharray="2 2" />
|
||||
<line id="ps-mode-line-shiftLevel" y1="24" y2="140" stroke="#D68910" stroke-dasharray="2 2" style="display:none;" />
|
||||
<!-- Horizontal arming-% line — y is set DYNAMICALLY by the JS to the
|
||||
shiftArmPercent value (in plot-y space). Spans full plot width. -->
|
||||
<line id="ps-mode-line-armPercent" x1="52" x2="392" stroke="#D68910" stroke-dasharray="4 3" stroke-width="1" opacity="0.7" style="display:none;" />
|
||||
<text id="ps-mode-label-armPercent" x="394" text-anchor="start" fill="#D68910" font-size="9" style="display:none;">arm%</text>
|
||||
<!-- Axis labels under the plot were removed — they crowded each other
|
||||
when levels were close. Identification comes from the line colour
|
||||
(matched to the side-panel input row) and hover-coupling. -->
|
||||
<!-- Empty <text> stubs kept for the redraw loop's getElementById calls
|
||||
(cheaper than guarding each one). They're hidden via display:none. -->
|
||||
<text id="ps-mode-label-dryRunLevel" style="display:none;"></text>
|
||||
<text id="ps-mode-label-startLevel" style="display:none;"></text>
|
||||
<text id="ps-mode-label-stopLevel" style="display:none;"></text>
|
||||
<text id="ps-mode-label-inflowLevel" style="display:none;"></text>
|
||||
<text id="ps-mode-label-maxLevel" style="display:none;"></text>
|
||||
<text id="ps-mode-label-overflowLevel" style="display:none;"></text>
|
||||
<text id="ps-mode-label-shiftLevel" style="display:none;"></text>
|
||||
<!-- Legend captions — placed BELOW the axis labels (y=200) on their own row,
|
||||
so they never collide with the title (y=14). Up-caption left-aligned at
|
||||
x=60; down-caption to its right at x=210. Both font-size 10. -->
|
||||
<text id="ps-mode-curve-up-label" x="60" y="205" fill="#1E8449" font-size="10">— ramp inlet→max</text>
|
||||
<text id="ps-mode-curve-down-label" x="210" y="205" fill="#D68910" font-size="10" style="display:none;">— shifted (held @100% then ramp shift→start)</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOverflow"><i class="fa fa-tint"></i> Overflow Level (m)</label>
|
||||
<input type="number" id="node-input-heightOverflow" min="0" step="0.01" />
|
||||
|
||||
<div id="ps-mode-manual" class="ps-mode-section" style="display:none">
|
||||
<p style="font-size:12px;color:#777;margin:0;">Manual mode accepts external <code>Qd</code> demand commands and does not compute demand from basin level.</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Reference data -->
|
||||
<h4>Reference</h4>
|
||||
|
||||
<!-- Reference data — basinBottomRef moved into basin side-panel above. -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
||||
<select id="node-input-refHeight" style="width:60%;">
|
||||
@@ -158,9 +521,54 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Safety</h4>
|
||||
|
||||
<!-- Safety settings -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
<label for="node-input-enableDryRunProtection">
|
||||
<i class="fa fa-shield"></i> Dry-run Protection
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableDryRunProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Prevent pumps from running on low volume</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dryRunThresholdPercent" style="padding-left:20px;">Low Volume Threshold (%)</label>
|
||||
<input type="number" id="node-input-dryRunThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-dryRunLevel" style="margin-left:8px;color:#777;font-size:12px;">→ dryRunLevel ≈ — m</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableHighVolumeSafety">
|
||||
<i class="fa fa-exclamation-triangle"></i> High-volume Safety
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableHighVolumeSafety" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Act before physical overflow</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-highVolumeSafetyThresholdPercent" style="padding-left:20px;">High-volume Safety (%)</label>
|
||||
<input type="number" id="node-input-highVolumeSafetyThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-highVolumeSafetyLevel" style="margin-left:8px;color:#777;font-size:12px;">→ highVolumeSafetyLevel ≈ — m</span>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||
<option value="process">process</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Shared asset/logger/position menus -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const nameOfNode = 'pumpingStation'; // this is the name of the node, it should match the file name and the node type in Node-RED
|
||||
const path = require('path');
|
||||
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
|
||||
const { MenuManager, configManager } = require('generalFunctions');
|
||||
|
||||
@@ -37,4 +38,16 @@ module.exports = function(RED) {
|
||||
}
|
||||
});
|
||||
|
||||
// Editor JS modules — loaded by pumpingStation.html via <script src=...> tags.
|
||||
// Files live in src/editor/. Filename is restricted to a safe charset to
|
||||
// prevent path-traversal.
|
||||
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
|
||||
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
|
||||
res.type('application/javascript');
|
||||
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
|
||||
if (err && !res.headersSent) res.status(404).send('// editor module not found');
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
124
simulations/README.md
Normal file
124
simulations/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Evaluation harness
|
||||
|
||||
Scenario-based evaluation for pumpingStation. Each scenario scripts a stream of inputs against a configured station, ticks the simulator at 1 s resolution, records every state, and prints a summary + event log + expectation check. Separate from unit tests (`test/`) — those verify individual pieces of logic in isolation; scenarios check end-to-end behaviour over time with realistic input trajectories.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# One scenario
|
||||
node simulations/run.js levelbased-steady
|
||||
|
||||
# All scenarios at once
|
||||
node simulations/run.js --all
|
||||
```
|
||||
|
||||
Per-tick records are written to `simulations/logs/<scenario>.jsonl` for post-hoc analysis (e.g. streaming into InfluxDB for Grafana, or pandas / jq for one-off exploration).
|
||||
|
||||
## Scenario file shape
|
||||
|
||||
```js
|
||||
// simulations/scenarios/<name>.js
|
||||
module.exports = {
|
||||
name: 'scenario-identifier',
|
||||
description: 'one sentence — what the scenario is testing',
|
||||
durationSec: 1200,
|
||||
|
||||
config: { /* PumpingStation config, same shape as nodeClass builds */ },
|
||||
|
||||
setup: async (ps) => {
|
||||
// Optional. Wire fake MGCs, calibrate initial level, etc.
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
// Called every tick (t in seconds). Drive inflow, mode changes,
|
||||
// operator actions, etc.
|
||||
ps.setManualInflow(0.005, Date.now(), 'm3/s');
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Supported expectation types
|
||||
|
||||
| Type | Semantics |
|
||||
|---|---|
|
||||
| `max_level_bounded` | max level across the run must be `≤ value` |
|
||||
| `min_level_bounded` | min level across the run must be `≥ value` |
|
||||
| `max_demand_bounded` | max percControl must be `≤ value` |
|
||||
| `max_demand_gt` | max percControl must be `> value` |
|
||||
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
|
||||
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
|
||||
| `end_state_eq` | final record's `field` must equal `value` |
|
||||
| `threshold_issues_eq` | startup guardrail issue count must equal `value` |
|
||||
|
||||
Add new expectation types in `run.js` (`evalExpectation`).
|
||||
|
||||
## Output
|
||||
|
||||
Example run:
|
||||
|
||||
```
|
||||
═══ Scenario: levelbased-steady ═══
|
||||
Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.
|
||||
Duration: 1200s, 1s ticks
|
||||
|
||||
─── Samples (every 10%) ───
|
||||
t(s) level(m) vol(m3) dir netFlow(m3/s) src demand safe
|
||||
────────────────────────────────────────────────────────────────────────────────────────
|
||||
0 2.00 20.00 steady 0 — 0% ·
|
||||
120 2.64 26.40 draining -0.0026 predicted 62% ·
|
||||
240 2.30 23.00 draining -0.0004 predicted 68% ·
|
||||
...
|
||||
|
||||
─── Events (3) ───
|
||||
t= 15s direction steady → filling
|
||||
t= 134s direction filling → draining
|
||||
|
||||
─── Metrics ───
|
||||
level min=2.00 max=2.73 end=2.33 m
|
||||
percControl min=0% max=73% end=66%
|
||||
safety trips=0 ticks
|
||||
threshold issues=0 at startup
|
||||
|
||||
─── Expectations ───
|
||||
✓ no safety trips: 0 ticks with safetyActive (expected 0)
|
||||
✓ level stays below overflow: max level = 2.73 m (bound: ≤ 4.5)
|
||||
✓ level stays above outflow: min level = 2.00 m (bound: ≥ 0.2)
|
||||
✓ no threshold issues on init: 0 threshold issues at startup (expected 0)
|
||||
|
||||
Log: simulations/logs/levelbased-steady.jsonl (1200 records)
|
||||
✅ PASS
|
||||
```
|
||||
|
||||
## Why separate from `test/`?
|
||||
|
||||
| | `test/` | `simulations/` |
|
||||
|---|---|---|
|
||||
| runner | `node --test` | `node simulations/run.js` |
|
||||
| scope | one function / small behaviour | end-to-end scenario over time |
|
||||
| duration | milliseconds | seconds to minutes (simulated) |
|
||||
| assertion style | tight, exact (`assert.equal`) | tolerance / bounds / event counts |
|
||||
| output | TAP | summary table + JSONL for analysis |
|
||||
| purpose | catch regressions | analyse how the system responds to input |
|
||||
|
||||
Unit tests live under `test/basic/`, `test/integration/`, `test/edge/`. Scenarios live here under `simulations/scenarios/`.
|
||||
|
||||
## Sending logs to Grafana (optional)
|
||||
|
||||
The JSONL output has one record per tick. To stream into InfluxDB for Grafana viewing, adapt a small consumer:
|
||||
|
||||
```bash
|
||||
jq -c '{
|
||||
measurement: "pumping_station_eval",
|
||||
tags: { scenario: "'$SCENARIO'" },
|
||||
fields: { level: .level, volume: .volume, demand: .percControl, safety: (.safetyActive|if . then 1 else 0 end) },
|
||||
timestamp: (.t | tonumber | . * 1000000000)
|
||||
}' simulations/logs/$SCENARIO.jsonl \
|
||||
| influx write --bucket=telemetry ...
|
||||
```
|
||||
|
||||
The `t` field is seconds from the scenario start (not wall-clock), so point the Grafana time range at `now() - $duration` after running.
|
||||
40
simulations/formatters/table.js
Normal file
40
simulations/formatters/table.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// ASCII table summary of scenario samples.
|
||||
// Used by simulations/run.js.
|
||||
|
||||
function pad(s, n, left = false) {
|
||||
s = String(s ?? '');
|
||||
if (s.length >= n) return s.slice(0, n);
|
||||
return left ? s.padStart(n) : s.padEnd(n);
|
||||
}
|
||||
|
||||
function num(x, digits = 2) {
|
||||
return Number.isFinite(x) ? x.toFixed(digits) : '—';
|
||||
}
|
||||
|
||||
function formatTable(records, sampleEvery = 1) {
|
||||
if (!records.length) return ' (no records)';
|
||||
const header = ['t(s)', 'level(m)', 'vol(m3)', 'dir', 'netFlow(m3/s)', 'src', 'demand', 'safe'];
|
||||
const rows = [];
|
||||
for (let i = 0; i < records.length; i += sampleEvery) rows.push(records[i]);
|
||||
if (rows[rows.length - 1] !== records[records.length - 1]) rows.push(records[records.length - 1]);
|
||||
|
||||
const widths = [6, 9, 9, 10, 14, 14, 8, 5];
|
||||
const lines = [];
|
||||
lines.push(header.map((h, i) => pad(h, widths[i], true)).join(' '));
|
||||
lines.push(widths.map((w) => '─'.repeat(w)).join(' '));
|
||||
for (const r of rows) {
|
||||
lines.push([
|
||||
pad(r.t, widths[0], true),
|
||||
pad(num(r.level, 2), widths[1], true),
|
||||
pad(num(r.volume, 2), widths[2], true),
|
||||
pad(r.direction ?? '—', widths[3], true),
|
||||
pad(num(r.netFlow, 5), widths[4], true),
|
||||
pad(r.flowSource ?? '—', widths[5], true),
|
||||
pad(num(r.percControl, 0) + '%', widths[6], true),
|
||||
pad(r.safetyActive ? '⚠' : '·', widths[7], true),
|
||||
].join(' '));
|
||||
}
|
||||
return lines.map((l) => ' ' + l).join('\n');
|
||||
}
|
||||
|
||||
module.exports = { formatTable };
|
||||
2
simulations/logs/.gitignore
vendored
Normal file
2
simulations/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.jsonl
|
||||
!.gitignore
|
||||
201
simulations/run.js
Normal file
201
simulations/run.js
Normal file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
// Scenario runner for pumpingStation. Usage:
|
||||
//
|
||||
// node simulations/run.js <scenario> # run one
|
||||
// node simulations/run.js --all # run all scenarios
|
||||
//
|
||||
// Each scenario lives in simulations/scenarios/<name>.js and exports:
|
||||
// { name, description, durationSec, config, setup?, inputs, expectations? }
|
||||
//
|
||||
// The runner ticks the station once per simulated second, records every
|
||||
// state into simulations/logs/<name>.jsonl, prints a summary table + event log,
|
||||
// and checks expectations.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const PumpingStation = require('../src/specificClass');
|
||||
const { formatTable } = require('./formatters/table');
|
||||
|
||||
function loadScenario(name) {
|
||||
return require(path.join(__dirname, 'scenarios', name));
|
||||
}
|
||||
|
||||
function snapshot(t, ps) {
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
return {
|
||||
t,
|
||||
level: lvl,
|
||||
volume: vol,
|
||||
direction: ps.state?.direction ?? null,
|
||||
netFlow: ps.state?.netFlow ?? null,
|
||||
flowSource: ps.state?.flowSource ?? null,
|
||||
timeleft: ps.state?.seconds ?? null,
|
||||
percControl: ps.percControl,
|
||||
mode: ps.mode,
|
||||
safetyActive: !!ps.safetyControllerActive,
|
||||
};
|
||||
}
|
||||
|
||||
function evalExpectation(ex, records) {
|
||||
const levels = records.map((r) => r.level).filter(Number.isFinite);
|
||||
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
|
||||
const last = records[records.length - 1] || {};
|
||||
switch (ex.type) {
|
||||
case 'max_level_bounded': {
|
||||
const v = Math.max(...levels);
|
||||
return { ok: v <= ex.value, msg: `max level = ${v.toFixed(2)} m (bound: ≤ ${ex.value})` };
|
||||
}
|
||||
case 'min_level_bounded': {
|
||||
const v = Math.min(...levels);
|
||||
return { ok: v >= ex.value, msg: `min level = ${v.toFixed(2)} m (bound: ≥ ${ex.value})` };
|
||||
}
|
||||
case 'max_demand_bounded': {
|
||||
const v = Math.max(...demands);
|
||||
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
|
||||
}
|
||||
case 'max_demand_gt': {
|
||||
const v = Math.max(...demands);
|
||||
return { ok: v > ex.value, msg: `max demand = ${v.toFixed(0)} % (expected > ${ex.value})` };
|
||||
}
|
||||
case 'safety_trips_eq': {
|
||||
const n = records.filter((r) => r.safetyActive).length;
|
||||
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
|
||||
}
|
||||
case 'safety_trips_gt': {
|
||||
const n = records.filter((r) => r.safetyActive).length;
|
||||
return { ok: n > ex.value, msg: `${n} ticks with safetyActive (expected > ${ex.value})` };
|
||||
}
|
||||
case 'end_state_eq': {
|
||||
return { ok: last[ex.field] === ex.value, msg: `end ${ex.field} = ${last[ex.field]} (expected ${ex.value})` };
|
||||
}
|
||||
case 'threshold_issues_eq': {
|
||||
const n = (records[0] && records[0].thresholdIssues) || 0;
|
||||
return { ok: n === ex.value, msg: `${n} threshold issues at startup (expected ${ex.value})` };
|
||||
}
|
||||
default:
|
||||
return { ok: false, msg: `unknown expectation type: ${ex.type}` };
|
||||
}
|
||||
}
|
||||
|
||||
function events(records) {
|
||||
const out = [];
|
||||
let prev = null;
|
||||
for (const r of records) {
|
||||
if (!prev) { prev = r; continue; }
|
||||
if (r.direction !== prev.direction) out.push({ t: r.t, kind: 'direction', from: prev.direction, to: r.direction });
|
||||
if (r.safetyActive !== prev.safetyActive) out.push({ t: r.t, kind: 'safety', active: r.safetyActive });
|
||||
if (r.mode !== prev.mode) out.push({ t: r.t, kind: 'mode', from: prev.mode, to: r.mode });
|
||||
prev = r;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runScenario(name) {
|
||||
const scenario = loadScenario(name);
|
||||
|
||||
// Use simulated time so the volume integrator sees 1 s per tick.
|
||||
// The class reads Date.now() internally; monkey-patching lets it
|
||||
// advance at scenario pace rather than wall-clock.
|
||||
const realNow = Date.now;
|
||||
let simTime = realNow();
|
||||
Date.now = () => simTime;
|
||||
|
||||
try {
|
||||
const ps = new PumpingStation(scenario.config);
|
||||
if (scenario.setup) await scenario.setup(ps);
|
||||
|
||||
const duration = scenario.durationSec ?? 600;
|
||||
const logDir = path.join(__dirname, 'logs');
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, `${scenario.name}.jsonl`);
|
||||
const log = fs.createWriteStream(logPath);
|
||||
|
||||
const records = [];
|
||||
for (let t = 0; t < duration; t += 1) {
|
||||
simTime += 1000; // advance 1 simulated second
|
||||
if (scenario.inputs) scenario.inputs(t, ps);
|
||||
ps.tick();
|
||||
const snap = snapshot(t, ps);
|
||||
snap.thresholdIssues = ps.thresholdIssues?.length ?? 0;
|
||||
records.push(snap);
|
||||
log.write(JSON.stringify(snap) + '\n');
|
||||
}
|
||||
// Drain so the file is fully written before we return.
|
||||
await new Promise((resolve, reject) => { log.end(); log.on('finish', resolve); log.on('error', reject); });
|
||||
|
||||
return { ps, records, scenario, duration, logPath };
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAndReport(name) {
|
||||
const { ps, records, scenario, duration, logPath } = await runScenario(name);
|
||||
|
||||
// Output
|
||||
console.log(`\n═══ Scenario: ${scenario.name} ═══`);
|
||||
console.log(scenario.description);
|
||||
console.log(`Duration: ${duration}s, 1s ticks`);
|
||||
|
||||
console.log('\n─── Samples (every 10%) ───');
|
||||
console.log(formatTable(records, Math.max(1, Math.floor(duration / 10))));
|
||||
|
||||
const evts = events(records);
|
||||
console.log(`\n─── Events (${evts.length}) ───`);
|
||||
if (!evts.length) console.log(' (none)');
|
||||
for (const e of evts) {
|
||||
if (e.kind === 'direction') console.log(` t=${String(e.t).padStart(4)}s direction ${e.from} → ${e.to}`);
|
||||
else if (e.kind === 'safety') console.log(` t=${String(e.t).padStart(4)}s safety ${e.active ? 'ACTIVE ⚠' : 'cleared'}`);
|
||||
else if (e.kind === 'mode') console.log(` t=${String(e.t).padStart(4)}s mode ${e.from} → ${e.to}`);
|
||||
}
|
||||
|
||||
console.log('\n─── Metrics ───');
|
||||
const levels = records.map((r) => r.level).filter(Number.isFinite);
|
||||
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
|
||||
const trips = records.filter((r) => r.safetyActive).length;
|
||||
if (levels.length) {
|
||||
console.log(` level min=${Math.min(...levels).toFixed(2)} max=${Math.max(...levels).toFixed(2)} end=${levels[levels.length-1].toFixed(2)} m`);
|
||||
}
|
||||
if (demands.length) {
|
||||
console.log(` percControl min=${Math.min(...demands).toFixed(0)}% max=${Math.max(...demands).toFixed(0)}% end=${demands[demands.length-1].toFixed(0)}%`);
|
||||
}
|
||||
console.log(` safety trips=${trips} ticks`);
|
||||
console.log(` threshold issues=${ps.thresholdIssues?.length ?? 0} at startup`);
|
||||
|
||||
let allOk = true;
|
||||
if (scenario.expectations?.length) {
|
||||
console.log('\n─── Expectations ───');
|
||||
for (const ex of scenario.expectations) {
|
||||
const { ok, msg } = evalExpectation(ex, records);
|
||||
allOk = allOk && ok;
|
||||
console.log(` ${ok ? '✓' : '✗'} ${ex.name}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nLog: ${path.relative(process.cwd(), logPath)} (${records.length} records)`);
|
||||
console.log(allOk ? '✅ PASS' : '❌ FAIL');
|
||||
return allOk;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error('Usage: node simulations/run.js <scenario> | --all');
|
||||
console.error('Available:', fs.readdirSync(path.join(__dirname, 'scenarios')).map((f) => f.replace(/\.js$/, '')).join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
if (arg === '--all') {
|
||||
const names = fs.readdirSync(path.join(__dirname, 'scenarios')).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, ''));
|
||||
let allOk = true;
|
||||
for (const name of names) {
|
||||
try { allOk = (await runAndReport(name)) && allOk; }
|
||||
catch (err) { console.error(`ERROR in ${name}:`, err.message); allOk = false; }
|
||||
}
|
||||
process.exit(allOk ? 0 : 1);
|
||||
}
|
||||
try { process.exit((await runAndReport(arg)) ? 0 : 1); }
|
||||
catch (err) { console.error('ERROR:', err.message, '\n', err.stack); process.exit(1); }
|
||||
}
|
||||
|
||||
main();
|
||||
61
simulations/scenarios/levelbased-steady.js
Normal file
61
simulations/scenarios/levelbased-steady.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Steady sewer inflow, level-based control, pumps should settle.
|
||||
//
|
||||
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
|
||||
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
|
||||
// inflowLevel and maxLevel while filling) at roughly the point where demand matches
|
||||
// inflow. No safety trips should fire.
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased-steady',
|
||||
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
|
||||
durationSec: 3600,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup: async (ps) => {
|
||||
// Stub MGC: its pumps collectively deliver (demand/100) × MAX_OUTFLOW.
|
||||
const MAX_OUTFLOW = 0.012; // m³/s
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||
},
|
||||
handleInput: async (_source, demand) => {
|
||||
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||
const outflow = (d / 100) * MAX_OUTFLOW;
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.0); // start at the mode start level, below the rising ramp
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
ps.setManualInflow(0.008, Date.now(), 'm3/s'); // ≈ 29 m³/h
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
|
||||
{ name: 'rising ramp engages after inlet level', type: 'max_demand_gt', value: 0 },
|
||||
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
|
||||
],
|
||||
};
|
||||
60
simulations/scenarios/levelbased-storm.js
Normal file
60
simulations/scenarios/levelbased-storm.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Storm surge — inflow triples briefly, pumps should increase demand as
|
||||
// the level enters the rising ramp.
|
||||
//
|
||||
// Expectation: during the surge (t=300..600), demand rises but remains
|
||||
// bounded. High-volume safety should fire if the surge overwhelms pump
|
||||
// capacity; dry-run should not fire.
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased-storm',
|
||||
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. High-volume safety may engage.',
|
||||
durationSec: 1500,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 95,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup: async (ps) => {
|
||||
const MAX_OUTFLOW = 0.012; // m³/s pumps cannot keep up with 3× surge
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||
},
|
||||
handleInput: async (_src, demand) => {
|
||||
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||
const outflow = (d / 100) * MAX_OUTFLOW;
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
const surge = (t >= 300 && t < 600) ? 0.024 : 0.008;
|
||||
ps.setManualInflow(surge, Date.now(), 'm3/s');
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
|
||||
// Level may exceed maxLevel transiently but must stay under basinHeight
|
||||
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
|
||||
{ name: 'demand remains bounded during surge', type: 'max_demand_bounded', value: 100 },
|
||||
],
|
||||
};
|
||||
66
simulations/scenarios/safety-dry-run-trip.js
Normal file
66
simulations/scenarios/safety-dry-run-trip.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Dry-run safety trip — manual mode, fixed high demand, zero inflow.
|
||||
// Levelbased control would taper demand as the level drops (its ramp),
|
||||
// stalling drainage before safety fires. Manual mode holds demand
|
||||
// constant so the level actually reaches the dry-run threshold.
|
||||
|
||||
module.exports = {
|
||||
name: 'safety-dry-run-trip',
|
||||
description: 'Manual mode, constant 100 % demand, zero inflow; expect safety to force-stop downstream pumps when level reaches the dry-run threshold.',
|
||||
durationSec: 600,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'manual',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 50,
|
||||
enableHighVolumeSafety: false,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup: async (ps) => {
|
||||
const MAX_OUTFLOW = 0.04;
|
||||
let mgcRunning = true; // gets toggled by safety's shutdown call
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1', id: 'mgc1' }, functionality: { positionVsParent: 'downstream' } },
|
||||
turnOffAllMachines: () => {
|
||||
mgcRunning = false;
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
|
||||
},
|
||||
handleInput: async (_src, demand) => {
|
||||
if (!mgcRunning) return;
|
||||
const d = Math.max(0, Math.min(100, Number(demand) || 0));
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value((d / 100) * MAX_OUTFLOW, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
// Need a downstream machine for safety to shut down
|
||||
ps.machines['pump1'] = {
|
||||
config: { general: { name: 'pump1', id: 'pump1' }, functionality: { positionVsParent: 'downstream' } },
|
||||
_isOperationalState: () => mgcRunning,
|
||||
handleInput: async (_src, action) => {
|
||||
if (action === 'execSequence') mgcRunning = false;
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
ps.setManualInflow(0, Date.now(), 'm3/s');
|
||||
if (ps.mode === 'manual') ps.forwardDemandToChildren(100);
|
||||
},
|
||||
|
||||
expectations: [
|
||||
{ name: 'safety engages at some point', type: 'safety_trips_gt', value: 0 },
|
||||
{ name: 'level never goes below outflow pipe', type: 'min_level_bounded', value: 0.2 },
|
||||
],
|
||||
};
|
||||
191
src/editor/basin-diagram.js
Normal file
191
src/editor/basin-diagram.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// PumpingStation editor — interactive basin SVG (top of the editor).
|
||||
// Places threshold lines, derived safety levels, zone labels, dead-volume
|
||||
// band, and ordering warnings. Same formulas as
|
||||
// specificClass._validateThresholdOrdering.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
|
||||
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
|
||||
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
|
||||
const DIAG = { topY: 40, botY: 380 };
|
||||
|
||||
const yForLevel = (val, basinH) => {
|
||||
if (val == null || !basinH) return null;
|
||||
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
||||
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
||||
};
|
||||
|
||||
// Place a row — line, label, input, unit all share the same y.
|
||||
const placeItem = (id, y) => {
|
||||
const line = document.getElementById(`ps-line-${id}`);
|
||||
const label = document.getElementById(`ps-label-${id}`);
|
||||
const unit = document.getElementById(`ps-unit-${id}`);
|
||||
const fo = document.getElementById(`ps-fo-${id}`);
|
||||
const sub = document.getElementById(`ps-sub-${id}`);
|
||||
const lead = document.getElementById(`ps-leader-${id}`);
|
||||
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
||||
if (label) label.setAttribute('y', y + 4);
|
||||
if (unit) unit.setAttribute('y', y + 4);
|
||||
if (fo) fo.setAttribute('y', y - 11);
|
||||
if (sub) sub.setAttribute('y', y + 15);
|
||||
if (lead) lead.setAttribute('visibility', 'hidden');
|
||||
};
|
||||
|
||||
ns.basinDiagram = {
|
||||
redraw() {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
|
||||
const refLow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const highPct = fNum('highVolumeSafetyThresholdPercent');
|
||||
const ovf = fNum('overflowLevel');
|
||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
|
||||
|
||||
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
|
||||
// outflowLevel pinned at its proportional y. Two passes (top-down +
|
||||
// bottom-up) maintain a minimum vertical gap.
|
||||
const items = [
|
||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
|
||||
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), basinH) },
|
||||
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
||||
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
||||
].filter(it => it.yIdeal != null);
|
||||
|
||||
const GAP = 36;
|
||||
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
||||
for (const it of items) it.y = it.yIdeal;
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
||||
}
|
||||
for (let i = items.length - 2; i >= 0; i--) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
||||
}
|
||||
for (const it of items) placeItem(it.id, it.y);
|
||||
|
||||
// Zone labels show only when the gap between the bracketing
|
||||
// thresholds is at least MIN_ZONE_GAP px high — otherwise the label
|
||||
// collides with one of the threshold labels (which sit at threshold
|
||||
// y ±6 px text-height). 28 px keeps a 6 px clear gap above and
|
||||
// below the zone label.
|
||||
const MIN_ZONE_GAP = 28;
|
||||
const placeZone = (zoneId, topId, botId) => {
|
||||
const el = document.getElementById(`ps-zone-${zoneId}`);
|
||||
if (!el) return;
|
||||
const top = items.find(it => it.id === topId);
|
||||
const bot = items.find(it => it.id === botId);
|
||||
if (!top || !bot || (bot.y - top.y) < MIN_ZONE_GAP) {
|
||||
el.setAttribute('visibility', 'hidden'); return;
|
||||
}
|
||||
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||
el.setAttribute('visibility', 'visible');
|
||||
};
|
||||
placeZone('spare', 'overflowLevel', 'highVolumeSafetyLevel');
|
||||
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
|
||||
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
|
||||
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
|
||||
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
||||
const deadLbl = document.getElementById('ps-zone-dead');
|
||||
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
||||
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
||||
deadLbl.setAttribute('visibility', 'visible');
|
||||
} else if (deadLbl) {
|
||||
deadLbl.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
|
||||
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||
if (inflowY != null) {
|
||||
const line = document.getElementById('ps-line-inflowLevel');
|
||||
const lbl = document.getElementById('ps-label-inflowLevel');
|
||||
const sub = document.getElementById('ps-sub-inflowLevel');
|
||||
const fo = document.getElementById('ps-fo-inflowLevel');
|
||||
const unit = document.getElementById('ps-unit-inflowLevel');
|
||||
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
||||
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
||||
if (sub) sub.setAttribute('y', inflowY + 8);
|
||||
if (fo) fo.setAttribute('y', inflowY - 11);
|
||||
if (unit) unit.setAttribute('y', inflowY + 4);
|
||||
}
|
||||
|
||||
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
||||
const deadvol = document.getElementById('ps-deadvol');
|
||||
if (deadvol && outflowItem) {
|
||||
deadvol.setAttribute('y', outflowItem.y);
|
||||
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
||||
}
|
||||
|
||||
// SVG labels — keep them short, side panel shows the numeric value.
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
|
||||
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
|
||||
if (highLbl) highLbl.textContent = 'highVolumeSafety';
|
||||
|
||||
// Side-panel read-only displays — number only ("m" is shown in the unit span).
|
||||
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||
const d1 = document.getElementById('derived-dryRunLevel');
|
||||
if (d1) d1.textContent = fmt(dryLvl);
|
||||
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
|
||||
if (d2) d2.textContent = fmt(highLvl);
|
||||
|
||||
// Hierarchy validation. Soft '≤' relations follow the user's choice:
|
||||
// start ≤ inflow, max ≤ overflow, overflow ≤ basinHeight (equality OK).
|
||||
// dryRunLevel must be < startLevel strictly (otherwise the runtime
|
||||
// would trip dry-run before it could ramp).
|
||||
// Re-read the raw value (basinH falls back to 5 for diagram scaling;
|
||||
// here we want null when the user hasn't entered anything so the
|
||||
// ≤-checks below are skipped rather than false-flagged).
|
||||
const basinHraw = fNum('basinHeight');
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
const ovfl = fNum('overflowLevel');
|
||||
const issues = [];
|
||||
const ok = (a, b, op) => {
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
|
||||
return op === '<' ? a < b : a <= b;
|
||||
};
|
||||
if (Number.isFinite(refLow) && refLow <= 0)
|
||||
issues.push('outflowLevel must be > 0');
|
||||
if (!ok(dryLvl, start, '<'))
|
||||
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
||||
if (!ok(start, inlet, '<='))
|
||||
issues.push('startLevel must be ≤ inflowLevel');
|
||||
if (!ok(inlet, max, '<='))
|
||||
issues.push('inflowLevel must be ≤ maxLevel');
|
||||
if (!ok(max, ovfl, '<='))
|
||||
issues.push('maxLevel must be ≤ overflowLevel');
|
||||
if (!ok(ovfl, basinHraw, '<='))
|
||||
issues.push('overflowLevel must be ≤ basinHeight');
|
||||
|
||||
// Visible ribbon above the basin diagram.
|
||||
const warnDiv = document.getElementById('ps-basin-validation');
|
||||
if (warnDiv) {
|
||||
if (issues.length) {
|
||||
warnDiv.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||
warnDiv.style.display = '';
|
||||
} else {
|
||||
warnDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// Legacy in-SVG warning text — kept for the small reminder inside
|
||||
// the diagram. Only shows the count.
|
||||
const warn = document.getElementById('ps-warning');
|
||||
if (warn) {
|
||||
if (issues.length) {
|
||||
warn.setAttribute('visibility', 'visible');
|
||||
warn.textContent = `⚠ ${issues.length} ordering issue${issues.length > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
warn.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
}
|
||||
window._psBasinValidationIssues = issues;
|
||||
},
|
||||
};
|
||||
})();
|
||||
96
src/editor/bounds.js
Normal file
96
src/editor/bounds.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// PumpingStation editor — dynamic input bounds.
|
||||
// Sets HTML5 min/max attributes on every level and percent input based on
|
||||
// the current values of related inputs, so the up/down arrows stop at
|
||||
// values that respect the basin hierarchy:
|
||||
//
|
||||
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
|
||||
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
|
||||
//
|
||||
// The user can still type out-of-range values via the keyboard (HTML5
|
||||
// min/max only constrain the spinner). The validation ribbons in
|
||||
// basin-diagram.js and mode-preview.js catch typed violations and the
|
||||
// oneditsave handler blocks Deploy until they're resolved.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
const EPS = 0.001; // smallest meaningful step (mm-precision)
|
||||
|
||||
const setBounds = (id, min, max) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (!el) return;
|
||||
if (Number.isFinite(min)) el.setAttribute('min', String(min));
|
||||
else el.removeAttribute('min');
|
||||
if (Number.isFinite(max)) el.setAttribute('max', String(max));
|
||||
else el.removeAttribute('max');
|
||||
};
|
||||
|
||||
ns.bounds = {
|
||||
apply() {
|
||||
const basinHeight = fNum('basinHeight');
|
||||
const outflow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
const overflow = fNum('overflowLevel');
|
||||
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
|
||||
// Derived dryRunLevel (lower bound for startLevel).
|
||||
const dryRun = (Number.isFinite(outflow) && Number.isFinite(dryPct))
|
||||
? outflow * (1 + dryPct / 100) : null;
|
||||
|
||||
// Geometry — basin envelope.
|
||||
setBounds('basinHeight', EPS, undefined);
|
||||
setBounds('basinVolume', EPS, undefined);
|
||||
|
||||
// Levels (each capped by the next-higher level if defined).
|
||||
setBounds('outflowLevel', EPS,
|
||||
Number.isFinite(start) && Number.isFinite(dryPct)
|
||||
? start / (1 + dryPct / 100) - EPS // keep dryRun < start
|
||||
: (start ?? inlet ?? max ?? overflow ?? basinHeight));
|
||||
|
||||
setBounds('startLevel',
|
||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||
inlet ?? max ?? overflow ?? basinHeight);
|
||||
|
||||
setBounds('inflowLevel',
|
||||
start ?? EPS,
|
||||
max ?? overflow ?? basinHeight);
|
||||
|
||||
setBounds('maxLevel',
|
||||
inlet ?? start ?? EPS,
|
||||
overflow ?? basinHeight);
|
||||
|
||||
setBounds('overflowLevel',
|
||||
max ?? inlet ?? start ?? EPS,
|
||||
basinHeight);
|
||||
|
||||
// stopLevel — explicit pump-off threshold. Must sit between
|
||||
// dryRunLevel and startLevel (so it can be reached during draining
|
||||
// before pumps re-engage).
|
||||
setBounds('stopLevel',
|
||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
||||
|
||||
// Shift inputs (only relevant when shifted ramp enabled).
|
||||
if (shiftEnabled) {
|
||||
setBounds('shiftLevel',
|
||||
Number.isFinite(start) ? start : EPS,
|
||||
max ?? overflow ?? basinHeight);
|
||||
setBounds('shiftArmPercent', 1, 100);
|
||||
}
|
||||
|
||||
// Percentages.
|
||||
// dryRun% capped so dryRunLevel ≤ startLevel.
|
||||
let dryMax = 99;
|
||||
if (Number.isFinite(start) && Number.isFinite(outflow) && outflow > 0) {
|
||||
dryMax = Math.max(0, Math.min(99, ((start / outflow) - 1) * 100));
|
||||
}
|
||||
setBounds('dryRunThresholdPercent', 0, dryMax);
|
||||
|
||||
// highVol% bounded (1, 100). Equal to 100 means no margin to overflow.
|
||||
setBounds('highVolumeSafetyThresholdPercent', 1, 100);
|
||||
},
|
||||
};
|
||||
})();
|
||||
29
src/editor/hover-couple.js
Normal file
29
src/editor/hover-couple.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// PumpingStation editor — hover-coupling between side-panel input rows
|
||||
// and the SVG markers they control. Each .ps-row that carries
|
||||
// data-couples-line="<svg-element-id>" highlights that SVG line on
|
||||
// mouseenter and clears the highlight on mouseleave.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
ns.hoverCouple = {
|
||||
init() {
|
||||
document.querySelectorAll('.ps-diag-side .ps-row[data-couples-line]').forEach((row) => {
|
||||
const targetId = row.getAttribute('data-couples-line');
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
const enter = () => target.classList.add('ps-line-highlight');
|
||||
const leave = () => target.classList.remove('ps-line-highlight');
|
||||
row.addEventListener('mouseenter', enter);
|
||||
row.addEventListener('mouseleave', leave);
|
||||
// Also highlight while the input inside the row has focus, so
|
||||
// the user keeps the visual feedback while typing.
|
||||
const input = row.querySelector('input');
|
||||
if (input) {
|
||||
input.addEventListener('focus', enter);
|
||||
input.addEventListener('blur', leave);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
30
src/editor/index.js
Normal file
30
src/editor/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// PumpingStation editor — shared namespace + helpers.
|
||||
// Loaded first by pumpingStation.html via /pumpingStation/editor/index.js.
|
||||
// Each sibling module attaches additional members to window.PSEditor.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
// Read a numeric value from an input by node-input-<id>; null if blank/NaN.
|
||||
ns.fNum = (id) => {
|
||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
|
||||
// Set a numeric input's value, or blank if not finite.
|
||||
ns.setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
// Add input + change listeners to a list of node-input-* ids.
|
||||
ns.bindRedraw = (ids, handler) => {
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) {
|
||||
el.addEventListener('input', handler);
|
||||
el.addEventListener('change', handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
286
src/editor/mode-preview.js
Normal file
286
src/editor/mode-preview.js
Normal file
@@ -0,0 +1,286 @@
|
||||
// PumpingStation editor — level-based mode preview SVG.
|
||||
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
|
||||
// the optional shifted-down curve (startLevel→shiftLevel). Computes
|
||||
// validation issues and stashes them on window._psModeValidationIssues
|
||||
// for oneditsave to read.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
|
||||
// Derive dryRunLevel the same way the basin diagram does.
|
||||
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
|
||||
// Returns null if either input is missing.
|
||||
ns.deriveDryRunLevel = () => {
|
||||
const refLow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
if (refLow == null || dryPct == null) return null;
|
||||
return refLow * (1 + dryPct / 100);
|
||||
};
|
||||
|
||||
ns.modePreview = {
|
||||
redraw() {
|
||||
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||
if (!svg) return;
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
||||
// own marker line; does NOT shift the ramp foot. Must be < startLevel
|
||||
// for the marker to render.
|
||||
const stopRaw = fNum('stopLevel');
|
||||
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
|
||||
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
||||
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||
// we draw it as the leftmost vertical marker so the user sees
|
||||
// exactly where it lands.
|
||||
const dryRun = ns.deriveDryRunLevel();
|
||||
const overflow = fNum('overflowLevel');
|
||||
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftRaw = fNum('shiftLevel');
|
||||
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
|
||||
const armRaw = fNum('shiftArmPercent');
|
||||
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
|
||||
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
|
||||
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
|
||||
|
||||
// Plot window is FIXED relative to basin geometry so that moving any
|
||||
// single level slides only that line, not all the others. Lower bound
|
||||
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
|
||||
// if overflow isn't set) plus a small margin.
|
||||
const upperRefs = [max, overflow].filter(Number.isFinite);
|
||||
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
|
||||
const pad = Math.max(upperBase * 0.05, 0.1);
|
||||
const levelMin = 0;
|
||||
const levelMax = upperBase + pad;
|
||||
|
||||
// Plot rectangle (viewBox px).
|
||||
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
|
||||
const yOffPx = 160;
|
||||
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
|
||||
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
|
||||
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
|
||||
const scale = (x) => {
|
||||
const clamped = Math.max(0, Math.min(1, x));
|
||||
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
|
||||
return clamped;
|
||||
};
|
||||
|
||||
// Path with three flat regions and a ramp:
|
||||
// [levelMin..startX] OFF (pump off; below startLevel)
|
||||
// [startX..footX] 0 % (system armed but not yet ramping)
|
||||
// [footX..topX] ramp (linear or log scaled 0..100 %)
|
||||
// [topX..levelMax] 100 % (saturated)
|
||||
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
|
||||
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
|
||||
const buildPath = (startX, footX, topX) => {
|
||||
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
|
||||
const pts = [];
|
||||
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(startX)},${yForPct(0)}`);
|
||||
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
|
||||
for (let i = 0; i <= 24; i++) {
|
||||
const t = i / 24;
|
||||
const level = footX + t * (topX - footX);
|
||||
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
|
||||
}
|
||||
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||
return pts.join(' ');
|
||||
};
|
||||
|
||||
// Up curve. Foot is startLevel (the configured pump-on threshold and
|
||||
// ramp foot per the runtime in _controlLevelBased). The OFF baseline
|
||||
// is drawn for level < startLevel; at startLevel demand jumps from
|
||||
// OFF to 0 % and ramps up to 100 % at maxLevel.
|
||||
const up = document.getElementById('ps-mode-curve-up');
|
||||
const down = document.getElementById('ps-mode-curve-down');
|
||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||
if (up) up.setAttribute('points', buildPath(start, start, max));
|
||||
|
||||
// Shifted-DOWN curve (only when shift enabled): represents the
|
||||
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
|
||||
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
|
||||
// then linear/log ramp from (shiftLevel, 100 %) down to
|
||||
// (startLevel, 0 %), then OFF below startLevel.
|
||||
// Real runtime hold value depends on where direction flips, so the
|
||||
// preview shows the maximum extent.
|
||||
const buildShiftedDown = () => {
|
||||
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
|
||||
const pts = [];
|
||||
// OFF baseline far-left to startLevel
|
||||
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
|
||||
// Jump 0 % at startLevel
|
||||
pts.push(`${xFor(start)},${yForPct(0)}`);
|
||||
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
|
||||
for (let i = 0; i <= 24; i++) {
|
||||
const t = i / 24;
|
||||
const lvl = start + t * (shift - start);
|
||||
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
|
||||
}
|
||||
// Held at 100 % from shift → far-right
|
||||
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||
return pts.join(' ');
|
||||
};
|
||||
if (down) {
|
||||
if (shiftEnabled) {
|
||||
down.setAttribute('points', buildShiftedDown());
|
||||
down.style.display = '';
|
||||
if (downLabel) downLabel.style.display = '';
|
||||
} else {
|
||||
down.setAttribute('points', '');
|
||||
down.style.display = 'none';
|
||||
if (downLabel) downLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal arming-% line — only meaningful when shift enabled.
|
||||
const armLine = document.getElementById('ps-mode-line-armPercent');
|
||||
const armLabel = document.getElementById('ps-mode-label-armPercent');
|
||||
if (armLine && armLabel) {
|
||||
if (shiftEnabled) {
|
||||
const yArm = yForPct(armPct);
|
||||
armLine.setAttribute('y1', yArm);
|
||||
armLine.setAttribute('y2', yArm);
|
||||
armLabel.setAttribute('y', yArm - 2);
|
||||
armLabel.textContent = `arm ${Math.round(armPct)}%`;
|
||||
armLine.style.display = '';
|
||||
armLabel.style.display = '';
|
||||
} else {
|
||||
armLine.style.display = 'none';
|
||||
armLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical level markers — line only. Axis labels were removed;
|
||||
// identification comes from line colour + side-panel labels +
|
||||
// hover coupling.
|
||||
[
|
||||
['dryRunLevel', dryRun],
|
||||
['startLevel', start],
|
||||
['stopLevel', stop],
|
||||
['inflowLevel', inlet],
|
||||
['maxLevel', max],
|
||||
['overflowLevel', overflow],
|
||||
].forEach(([id, level]) => {
|
||||
const line = document.getElementById(`ps-mode-line-${id}`);
|
||||
if (!line) return;
|
||||
if (!Number.isFinite(level)) {
|
||||
line.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const x = xFor(level);
|
||||
line.style.display = '';
|
||||
line.setAttribute('x1', x); line.setAttribute('x2', x);
|
||||
});
|
||||
|
||||
// Background zone bands.
|
||||
const plotL = xFor(levelMin);
|
||||
const plotR = xFor(levelMax);
|
||||
const setBand = (id, a, b) => {
|
||||
const r = document.getElementById(id);
|
||||
if (!r) return;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
|
||||
r.setAttribute('x', 0); r.setAttribute('width', 0);
|
||||
return;
|
||||
}
|
||||
r.setAttribute('x', a);
|
||||
r.setAttribute('width', b - a);
|
||||
};
|
||||
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
|
||||
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
|
||||
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
|
||||
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
|
||||
setBand('ps-zone-dryRun', plotL, xMin);
|
||||
setBand('ps-zone-safetyLow', xMin, xStart);
|
||||
setBand('ps-zone-safe', xStart, xMax);
|
||||
setBand('ps-zone-safetyHigh', xMax, xOvf);
|
||||
setBand('ps-zone-overflow', xOvf, plotR);
|
||||
|
||||
// Shift level marker (line only).
|
||||
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
|
||||
if (shiftLine) {
|
||||
if (shiftEnabled && Number.isFinite(shift)) {
|
||||
const x = xFor(shift);
|
||||
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
|
||||
shiftLine.style.display = '';
|
||||
} else {
|
||||
shiftLine.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Title + row visibility.
|
||||
const curveLabel = document.getElementById('ps-mode-curve-label');
|
||||
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
|
||||
const shiftRow = document.getElementById('ps-shiftLevel-row');
|
||||
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const armRow = document.getElementById('ps-shiftArmPercent-row');
|
||||
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const logRow = document.getElementById('ps-log-factor-row');
|
||||
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
|
||||
|
||||
// Auto-default shiftLevel when shift is enabled and current value
|
||||
// is missing/out-of-range. Visible default avoids a hidden ramp.
|
||||
const shiftInput = document.getElementById('node-input-shiftLevel');
|
||||
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
|
||||
const cur = parseFloat(shiftInput.value);
|
||||
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
|
||||
shiftInput.value = (max * 0.9).toFixed(2);
|
||||
}
|
||||
}
|
||||
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
|
||||
// current value is missing / out of [0, 100].
|
||||
const armInput = document.getElementById('node-input-shiftArmPercent');
|
||||
if (shiftEnabled && armInput) {
|
||||
const cur = parseFloat(armInput.value);
|
||||
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
|
||||
armInput.value = 95;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation: only mode-specific (shift) ordering. Basin-level
|
||||
// hierarchy (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
|
||||
// dryRun < start) is owned by basin-diagram.js so it shows in the
|
||||
// basin section near the offending inputs.
|
||||
const issues = [];
|
||||
if (shiftEnabled) {
|
||||
const shiftVal = Number(shiftInput?.value);
|
||||
if (Number.isFinite(shiftVal)) {
|
||||
if (Number.isFinite(start) && shiftVal <= start)
|
||||
issues.push('shiftLevel must be > startLevel');
|
||||
if (Number.isFinite(max) && shiftVal > max)
|
||||
issues.push('shiftLevel must be ≤ maxLevel');
|
||||
} else {
|
||||
issues.push('shiftLevel is required when shifted ramp is enabled');
|
||||
}
|
||||
const armVal = Number(armInput?.value);
|
||||
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
|
||||
issues.push('shiftArmPercent must be in (0, 100]');
|
||||
}
|
||||
const warnBox = document.getElementById('ps-mode-validation');
|
||||
if (warnBox) {
|
||||
if (issues.length) {
|
||||
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||
warnBox.style.display = '';
|
||||
} else {
|
||||
warnBox.style.display = 'none';
|
||||
}
|
||||
}
|
||||
window._psModeValidationIssues = issues;
|
||||
|
||||
// Read-only readouts in the side panel — number only; the row's
|
||||
// .ps-unit span already shows "m".
|
||||
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||
const setText = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = fmt(val);
|
||||
};
|
||||
setText('ps-mode-readout-dryRun', dryRun);
|
||||
setText('ps-mode-readout-inflow', inlet);
|
||||
setText('ps-mode-readout-overflow', overflow);
|
||||
},
|
||||
};
|
||||
})();
|
||||
114
src/editor/oneditprepare.js
Normal file
114
src/editor/oneditprepare.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// PumpingStation editor — oneditprepare entry. Wires up form-field
|
||||
// initialization, control-mode toggle, safety toggles, and binds
|
||||
// redraws for the basin diagram + level-based mode preview.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
ns.oneditprepare = function () {
|
||||
const node = this;
|
||||
|
||||
// Wait for menu data (asset/logger/position dropdowns) before init.
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||
window.EVOLV.nodes.pumpingStation.initEditor(node);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
waitForMenuData();
|
||||
|
||||
const refHeightEl = document.getElementById('node-input-refHeight');
|
||||
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
|
||||
|
||||
// Safety toggle pairs — each toggle enables/disables its threshold input.
|
||||
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
|
||||
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
|
||||
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
|
||||
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) return;
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!node.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (highVolumeToggle && highVolumePercent) {
|
||||
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
|
||||
? !!node.enableHighVolumeSafety
|
||||
: !!node.enableOverfillProtection;
|
||||
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
|
||||
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
|
||||
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
|
||||
toggleInput(highVolumeToggle, highVolumePercent);
|
||||
}
|
||||
|
||||
// Control-mode section toggle (levelbased / manual).
|
||||
const toggleModeSections = (val) => {
|
||||
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||
const active = document.getElementById(`ps-mode-${val}`);
|
||||
if (active) active.style.display = '';
|
||||
};
|
||||
const modeSelect = document.getElementById('node-input-controlMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = node.controlMode === 'manual' ? 'manual' : 'levelbased';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
// Numeric field defaults.
|
||||
ns.setNumberField('node-input-startLevel', node.startLevel);
|
||||
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
||||
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
|
||||
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||
|
||||
const curveSelect = document.getElementById('node-input-levelCurveType');
|
||||
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
|
||||
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
||||
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
||||
|
||||
// Bind redraws to the inputs each diagram cares about.
|
||||
ns.bindRedraw(
|
||||
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
||||
ns.basinDiagram.redraw
|
||||
);
|
||||
ns.bindRedraw(
|
||||
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
||||
// so the mode preview must redraw when either of those change.
|
||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'dryRunThresholdPercent',
|
||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||
'shiftArmPercent'],
|
||||
ns.modePreview.redraw
|
||||
);
|
||||
|
||||
// Whenever any level/percent input changes, refresh the bounds first
|
||||
// so the next redraw + validation sees the correct min/max attrs.
|
||||
ns.bindRedraw(
|
||||
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
||||
'inflowLevel', 'startLevel', 'outflowLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
||||
() => ns.bounds?.apply()
|
||||
);
|
||||
|
||||
// Initial render + hover-couple wiring once the DOM is settled.
|
||||
setTimeout(() => {
|
||||
ns.bounds?.apply();
|
||||
ns.basinDiagram.redraw();
|
||||
ns.modePreview.redraw();
|
||||
ns.hoverCouple?.init();
|
||||
}, 60);
|
||||
};
|
||||
})();
|
||||
69
src/editor/oneditsave.js
Normal file
69
src/editor/oneditsave.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// PumpingStation editor — oneditsave handler. Validates, saves shared
|
||||
// menu sections (logger/position), then persists pumpingStation-specific
|
||||
// fields onto the node. Throws if validation fails to keep the editor open.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
|
||||
ns.oneditsave = function () {
|
||||
const node = this;
|
||||
|
||||
// Block save if EITHER validator surfaced any issues. basin-diagram
|
||||
// owns hierarchy issues (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
|
||||
// dryRun < start). mode-preview owns shift-specific issues.
|
||||
const basinIssues = window._psBasinValidationIssues || [];
|
||||
const modeIssues = window._psModeValidationIssues || [];
|
||||
const issues = [...basinIssues, ...modeIssues];
|
||||
if (issues.length) {
|
||||
if (typeof RED !== 'undefined' && RED.notify) {
|
||||
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),
|
||||
{ type: 'error', timeout: 6000 });
|
||||
}
|
||||
throw new Error('PumpingStation: invalid config — ' + issues.join('; '));
|
||||
}
|
||||
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
|
||||
node.simulator = document.getElementById('node-input-simulator').checked;
|
||||
|
||||
[
|
||||
'basinVolume', 'basinHeight', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'basinBottomRef',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||
].forEach((field) => {
|
||||
const el = document.getElementById(`node-input-${field}`);
|
||||
if (el) node[field] = parseFloat(el.value) || 0;
|
||||
});
|
||||
|
||||
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
|
||||
node.enableHighVolumeSafety = document.getElementById('node-input-enableHighVolumeSafety').checked;
|
||||
// Deprecated aliases kept for existing runtime/schema compatibility.
|
||||
node.enableOverfillProtection = node.enableHighVolumeSafety;
|
||||
node.overfillThresholdPercent = node.highVolumeSafetyThresholdPercent;
|
||||
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'levelbased';
|
||||
node.levelCurveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
// minLevel is no longer a user input — it's the derived dryRunLevel
|
||||
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
||||
// uses node.minLevel as the unconditional STOP threshold; we set it
|
||||
// here so that semantic survives the UI change.
|
||||
const _dryRun = ns.deriveDryRunLevel?.();
|
||||
if (Number.isFinite(_dryRun)) node.minLevel = _dryRun;
|
||||
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftLevelVal = parseNum('node-input-shiftLevel');
|
||||
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
|
||||
const armPctVal = parseNum('node-input-shiftArmPercent');
|
||||
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
|
||||
const flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
const flowDeadband = parseNum('node-input-flowDeadband');
|
||||
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
|
||||
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
|
||||
};
|
||||
})();
|
||||
200
src/nodeClass.js
200
src/nodeClass.js
@@ -44,13 +44,49 @@ class nodeClass {
|
||||
basin: {
|
||||
volume: uiConfig.basinVolume,
|
||||
height: uiConfig.basinHeight,
|
||||
heightInlet: uiConfig.heightInlet,
|
||||
heightOutlet: uiConfig.heightOutlet,
|
||||
heightOverflow: uiConfig.heightOverflow,
|
||||
inflowLevel: uiConfig.inflowLevel,
|
||||
outflowLevel: uiConfig.outflowLevel,
|
||||
overflowLevel: uiConfig.overflowLevel,
|
||||
inletPipeDiameter: uiConfig.inletPipeDiameter,
|
||||
outletPipeDiameter: uiConfig.outletPipeDiameter,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: uiConfig.refHeight,
|
||||
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
||||
basinBottomRef: uiConfig.basinBottomRef,
|
||||
maxInflowRate: uiConfig.maxInflowRate,
|
||||
staticHead: uiConfig.staticHead,
|
||||
maxDischargeHead: uiConfig.maxDischargeHead,
|
||||
pipelineLength: uiConfig.pipelineLength,
|
||||
defaultFluid: uiConfig.defaultFluid,
|
||||
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
||||
},
|
||||
control:{
|
||||
mode: uiConfig.controlMode,
|
||||
levelbased:{
|
||||
minLevel:uiConfig.minLevel,
|
||||
startLevel:uiConfig.startLevel,
|
||||
stopLevel: uiConfig.stopLevel,
|
||||
maxLevel:uiConfig.maxLevel,
|
||||
curveType: uiConfig.levelCurveType || uiConfig.curveType,
|
||||
logCurveFactor: uiConfig.logCurveFactor,
|
||||
enableShiftedRamp: uiConfig.enableShiftedRamp,
|
||||
shiftLevel: uiConfig.shiftLevel,
|
||||
shiftArmPercent: uiConfig.shiftArmPercent
|
||||
}
|
||||
},
|
||||
safety:{
|
||||
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
||||
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
||||
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
|
||||
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
|
||||
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
||||
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
||||
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
|
||||
},
|
||||
output: {
|
||||
process: uiConfig.processOutputFormat,
|
||||
dbase: uiConfig.dbaseOutputFormat
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,65 +122,60 @@ class nodeClass {
|
||||
|
||||
_updateNodeStatus() {
|
||||
const ps = this.source;
|
||||
try {
|
||||
// --- Basin & measurements -------------------------------------------------
|
||||
const maxVolBeforeOverflow = ps.basin?.maxVolOverflow ?? ps.basin?.maxVol ?? 0;
|
||||
const volumeMeasurement = ps.measurements.type("volume").variant("measured").position("atEquipment");
|
||||
const currentVolume = volumeMeasurement.getCurrentValue("m3") ?? 0;
|
||||
const netFlowMeasurement = ps.measurements.type("netFlowRate").variant("predicted").position("atEquipment");
|
||||
const netFlowM3s = netFlowMeasurement?.getCurrentValue("m3/s") ?? 0;
|
||||
const netFlowM3h = netFlowM3s * 3600;
|
||||
const percentFull = ps.measurements.type("volume").variant("procent").position("atEquipment").getCurrentValue() ?? 0;
|
||||
|
||||
// --- State information ----------------------------------------------------
|
||||
const direction = ps.state?.direction || "unknown";
|
||||
const secondsRemaining = ps.state?.seconds ?? null;
|
||||
|
||||
const timeRemaining = secondsRemaining ? `${Math.round(secondsRemaining / 60)}` : 0 + " min";
|
||||
|
||||
// --- Icon / colour selection ---------------------------------------------
|
||||
let symbol = "❔";
|
||||
let fill = "grey";
|
||||
|
||||
switch (direction) {
|
||||
case "filling":
|
||||
symbol = "⬆️";
|
||||
fill = "blue";
|
||||
break;
|
||||
case "draining":
|
||||
symbol = "⬇️";
|
||||
fill = "orange";
|
||||
break;
|
||||
case "stable":
|
||||
symbol = "⏸️";
|
||||
fill = "green";
|
||||
break;
|
||||
default:
|
||||
symbol = "❔";
|
||||
fill = "grey";
|
||||
break;
|
||||
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 };
|
||||
};
|
||||
|
||||
// --- Status text ----------------------------------------------------------
|
||||
const textParts = [
|
||||
`${symbol} ${percentFull.toFixed(1)}%`,
|
||||
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)} m³`,
|
||||
`net=${netFlowM3h.toFixed(1)} m³/h`,
|
||||
`t≈${timeRemaining}`
|
||||
];
|
||||
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');
|
||||
|
||||
return {
|
||||
fill,
|
||||
shape: "dot",
|
||||
text: textParts.join(" | ")
|
||||
};
|
||||
} catch (error) {
|
||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||
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(' | ')
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// any time based functions here
|
||||
_startTickLoop() {
|
||||
setTimeout(() => {
|
||||
@@ -167,8 +198,8 @@ class nodeClass {
|
||||
//pumping station needs time based ticks to recalc level when predicted
|
||||
this.source.tick();
|
||||
const raw = this.source.getOutput();
|
||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||
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]);
|
||||
@@ -181,18 +212,60 @@ class nodeClass {
|
||||
this.node.on('input', (msg, send, done) => {
|
||||
switch (msg.topic) {
|
||||
//example
|
||||
/*case 'simulator':
|
||||
this.source.toggleSimulation();
|
||||
case 'changemode':
|
||||
this.source.changeMode(msg.payload);
|
||||
break;
|
||||
default:
|
||||
this.source.handleInput(msg);
|
||||
break;
|
||||
*/
|
||||
case 'registerChild': {
|
||||
// Register this node as a child of the parent node
|
||||
const childId = msg.payload;
|
||||
const childObj = this.RED.nodes.getNode(childId);
|
||||
this.source.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -207,6 +280,7 @@ class nodeClass {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.node.status({}); // clear node status badge
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
1980
src/specificClass.js
1980
src/specificClass.js
File diff suppressed because it is too large
Load Diff
74
test/basic/nodeClass-config.test.js
Normal file
74
test/basic/nodeClass-config.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const NodeClass = require('../../src/nodeClass');
|
||||
|
||||
function loadConfig(uiConfig = {}) {
|
||||
const ctx = { name: 'pumpingStation' };
|
||||
NodeClass.prototype._loadConfig.call(ctx, {
|
||||
name: 'PS Config Test',
|
||||
basinVolume: 80,
|
||||
basinHeight: 8,
|
||||
inflowLevel: 3.2,
|
||||
outflowLevel: 0.4,
|
||||
overflowLevel: 7.4,
|
||||
inletPipeDiameter: 0.5,
|
||||
outletPipeDiameter: 0.35,
|
||||
refHeight: 'NAP',
|
||||
minHeightBasedOn: 'outlet',
|
||||
basinBottomRef: -1.2,
|
||||
maxInflowRate: 300,
|
||||
staticHead: 11,
|
||||
maxDischargeHead: 22,
|
||||
pipelineLength: 120,
|
||||
defaultFluid: 'wastewater',
|
||||
temperatureReferenceDegC: 16,
|
||||
controlMode: 'levelbased',
|
||||
minLevel: 0.8,
|
||||
startLevel: 2,
|
||||
maxLevel: 6.5,
|
||||
levelCurveType: 'log',
|
||||
logCurveFactor: 7,
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 3,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 96,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 60,
|
||||
processOutputFormat: 'process',
|
||||
dbaseOutputFormat: 'influxdb',
|
||||
...uiConfig,
|
||||
}, { id: 'node-1' });
|
||||
return ctx.config;
|
||||
}
|
||||
|
||||
test('nodeClass config mapping — basin, hydraulics, mode and safety fields', () => {
|
||||
const cfg = loadConfig();
|
||||
|
||||
assert.equal(cfg.basin.inletPipeDiameter, 0.5);
|
||||
assert.equal(cfg.basin.outletPipeDiameter, 0.35);
|
||||
assert.equal(cfg.hydraulics.maxInflowRate, 300);
|
||||
assert.equal(cfg.hydraulics.staticHead, 11);
|
||||
assert.equal(cfg.hydraulics.maxDischargeHead, 22);
|
||||
assert.equal(cfg.hydraulics.pipelineLength, 120);
|
||||
assert.equal(cfg.hydraulics.defaultFluid, 'wastewater');
|
||||
assert.equal(cfg.hydraulics.temperatureReferenceDegC, 16);
|
||||
assert.equal(cfg.control.mode, 'levelbased');
|
||||
assert.equal(cfg.control.levelbased.curveType, 'log');
|
||||
assert.equal(cfg.control.levelbased.logCurveFactor, 7);
|
||||
assert.equal(cfg.safety.enableHighVolumeSafety, true);
|
||||
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 96);
|
||||
assert.equal(cfg.output.process, 'process');
|
||||
assert.equal(cfg.output.dbase, 'influxdb');
|
||||
});
|
||||
|
||||
test('nodeClass config mapping — accepts deprecated overfill UI fields', () => {
|
||||
const cfg = loadConfig({
|
||||
enableHighVolumeSafety: undefined,
|
||||
highVolumeSafetyThresholdPercent: undefined,
|
||||
enableOverfillProtection: false,
|
||||
overfillThresholdPercent: 91,
|
||||
});
|
||||
|
||||
assert.equal(cfg.safety.enableHighVolumeSafety, false);
|
||||
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 91);
|
||||
});
|
||||
601
test/basic/specificClass.test.js
Normal file
601
test/basic/specificClass.test.js
Normal file
@@ -0,0 +1,601 @@
|
||||
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
|
||||
// Run with: node --test test/basic/specificClass.test.js
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
|
||||
// Standard config shape. Override any section by passing { section: {...} }.
|
||||
function makeConfig(overrides = {}) {
|
||||
const base = {
|
||||
general: {
|
||||
name: 'TestStation',
|
||||
id: 'ps-test',
|
||||
unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
flowThreshold: 1e-4,
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'pumpingStation',
|
||||
role: 'stationcontroller',
|
||||
positionVsParent: 'atEquipment',
|
||||
},
|
||||
basin: {
|
||||
volume: 50,
|
||||
height: 5,
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 4.5,
|
||||
inletPipeDiameter: 0.4,
|
||||
outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 0,
|
||||
minHeightBasedOn: 'outlet',
|
||||
},
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false,
|
||||
enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
};
|
||||
for (const k of Object.keys(overrides)) {
|
||||
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
|
||||
? { ...base[k], ...overrides[k] }
|
||||
: overrides[k];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
test('Basin geometry — derived values', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('surfaceArea = volume / height', () => {
|
||||
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
|
||||
});
|
||||
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
|
||||
assert.equal(ps.basin.maxVol, 50);
|
||||
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
|
||||
});
|
||||
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
|
||||
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
|
||||
});
|
||||
await t.test('minVolAtInflow = inflowLevel × area', () => {
|
||||
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
|
||||
});
|
||||
await t.test('minVolAtOutflow = outflowLevel × area', () => {
|
||||
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
|
||||
});
|
||||
await t.test('minVol honours minHeightBasedOn=outlet', () => {
|
||||
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
|
||||
});
|
||||
await t.test('minVol honours minHeightBasedOn=inlet', () => {
|
||||
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||
assert.equal(ps2.basin.minVol, 30);
|
||||
});
|
||||
await t.test('pipe diameters are part of basin contract', () => {
|
||||
assert.equal(ps.basin.inletPipeDiameter, 0.4);
|
||||
assert.equal(ps.basin.outletPipeDiameter, 0.3);
|
||||
});
|
||||
});
|
||||
|
||||
test('Level ↔ volume roundtrip', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('_calcVolumeFromLevel multiplies by area', () => {
|
||||
assert.equal(ps._calcVolumeFromLevel(2), 20);
|
||||
});
|
||||
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
|
||||
assert.equal(ps._calcVolumeFromLevel(-3), 0);
|
||||
});
|
||||
await t.test('_calcLevelFromVolume divides by area', () => {
|
||||
assert.equal(ps._calcLevelFromVolume(20), 2);
|
||||
});
|
||||
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
|
||||
assert.equal(ps._calcLevelFromVolume(-10), 0);
|
||||
});
|
||||
await t.test('roundtrip preserves level', () => {
|
||||
const v = ps._calcVolumeFromLevel(2.7);
|
||||
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
await t.test('valid config returns no issues', () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
assert.equal(ps.thresholdIssues.length, 0);
|
||||
});
|
||||
|
||||
await t.test('minLevel > startLevel flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('overflowLevel > basinHeight flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
|
||||
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
|
||||
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
hydraulics: { minHeightBasedOn: 'inlet' },
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
|
||||
});
|
||||
});
|
||||
|
||||
test('Direction derivation — _deriveDirection', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('positive flow above dead-band → filling', () => {
|
||||
assert.equal(ps._deriveDirection(0.01), 'filling');
|
||||
});
|
||||
await t.test('negative flow below dead-band → draining', () => {
|
||||
assert.equal(ps._deriveDirection(-0.01), 'draining');
|
||||
});
|
||||
await t.test('flow inside dead-band → steady', () => {
|
||||
assert.equal(ps._deriveDirection(0), 'steady');
|
||||
assert.equal(ps._deriveDirection(1e-5), 'steady');
|
||||
assert.equal(ps._deriveDirection(-1e-5), 'steady');
|
||||
});
|
||||
});
|
||||
|
||||
test('Mode change — changeMode', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('valid mode swap updates this.mode', () => {
|
||||
ps.changeMode('manual');
|
||||
assert.equal(ps.mode, 'manual');
|
||||
});
|
||||
await t.test('rejected mode leaves this.mode unchanged', () => {
|
||||
ps.changeMode('manual');
|
||||
ps.changeMode('notamode');
|
||||
assert.equal(ps.mode, 'manual');
|
||||
});
|
||||
});
|
||||
|
||||
test('Calibration — predicted volume and level', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
|
||||
await t.test('calibratePredictedVolume rewrites volume series', () => {
|
||||
ps.calibratePredictedVolume(25);
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 25) < 1e-9);
|
||||
});
|
||||
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
|
||||
ps.calibratePredictedVolume(30);
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
|
||||
});
|
||||
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
|
||||
ps.calibratePredictedLevel(2.5);
|
||||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
|
||||
});
|
||||
});
|
||||
|
||||
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
let turnOffCalls = 0;
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => { turnOffCalls++; },
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(turnOffCalls, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
const demands = [];
|
||||
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
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(demands[0], 0);
|
||||
});
|
||||
|
||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(demands[0], 0);
|
||||
});
|
||||
|
||||
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
||||
await ps._controlLevelBased('filling');
|
||||
// lerp(3.5, [3,4], [0,100]) = 50
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
assert.equal(demands.length, 1);
|
||||
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
|
||||
});
|
||||
|
||||
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
||||
ps.calibratePredictedLevel(3.8);
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl > 0);
|
||||
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
|
||||
await ps._controlLevelBased();
|
||||
// Without shift the foot is inflowLevel → 0% in the hold zone.
|
||||
assert.equal(ps.percControl, 0);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
|
||||
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
|
||||
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
||||
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
|
||||
ps.calibratePredictedLevel(3.5);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
|
||||
ps.calibratePredictedLevel(3.85);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
|
||||
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
|
||||
ps.calibratePredictedLevel(3.6);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
|
||||
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
|
||||
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
|
||||
ps.calibratePredictedLevel(2.75);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
|
||||
// Below startLevel ⇒ output 0 % AND disarm.
|
||||
ps.calibratePredictedLevel(1.9);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(3.85);
|
||||
await ps._controlLevelBased('filling');
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||
// Direction back to filling ⇒ up curve, hold cleared, still armed.
|
||||
ps.calibratePredictedLevel(3.9);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
|
||||
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
|
||||
});
|
||||
|
||||
await t.test('log curve has fast early response', async () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.ok(ps.percControl > 50);
|
||||
assert.ok(ps.percControl < 100);
|
||||
});
|
||||
|
||||
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl >= 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOutput — flattens basin + state + demand', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 37;
|
||||
|
||||
await t.test('includes basin geometry fields', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.volEmptyBasin, 50);
|
||||
assert.equal(out.maxVolAtOverflow, 45);
|
||||
assert.equal(out.minVolAtInflow, 30);
|
||||
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||
assert.equal(out.inletPipeDiameter, 0.4);
|
||||
assert.equal(out.outletPipeDiameter, 0.3);
|
||||
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
|
||||
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 1e-9);
|
||||
});
|
||||
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.ok('direction' in out);
|
||||
assert.ok('flowSource' in out);
|
||||
assert.ok('timeleft' in out);
|
||||
});
|
||||
await t.test('includes percControl', () => {
|
||||
assert.equal(ps.getOutput().percControl, 37);
|
||||
});
|
||||
});
|
||||
|
||||
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
|
||||
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
|
||||
assert.ok(Math.abs(v - 0.05) < 1e-9);
|
||||
});
|
||||
|
||||
// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
|
||||
// tracks any excess as cumulative `overflowVolume` plus a synthetic
|
||||
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
|
||||
// pinned. We drive ticks manually with monotonic timestamps to keep tests
|
||||
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
|
||||
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
|
||||
}));
|
||||
// Seed predicted volume just below the spill point.
|
||||
// maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
|
||||
const t0 = 1_700_000_000_000;
|
||||
ps.calibratePredictedVolume(44, t0);
|
||||
// Heavy inflow, no real outflow (no pumps wired).
|
||||
ps.setManualInflow(2, t0, 'm3/s'); // 2 m³/s, dt=1s → 2 m³/tick
|
||||
|
||||
await t.test('first overflow tick clamps volume and records spill increment', () => {
|
||||
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45); // pinned at overflow
|
||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(cumulative, 1); // proposed=44+2=46, excess=1 m³ this tick
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 2); // instantaneous balance: inflow − outflowReal
|
||||
});
|
||||
|
||||
await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45);
|
||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(cumulative, 3); // 1 + 2
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 2);
|
||||
});
|
||||
|
||||
await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
|
||||
const net = ps._selectBestNetFlow();
|
||||
// inflow=2, outflow_total=2 (synthetic spill), net = 0
|
||||
assert.ok(Math.abs(net.value) < 1e-9);
|
||||
assert.equal(net.source, 'predicted');
|
||||
});
|
||||
|
||||
await t.test('once inflow stops, spill flow clears and clamp releases', () => {
|
||||
ps.setManualInflow(0, t0 + 2000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||
Date.now = () => t0 + 3000;
|
||||
ps._updatePredictedVolume();
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 0);
|
||||
// Volume stays at 45 (no draining force) but is no longer "pinned".
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45);
|
||||
});
|
||||
});
|
||||
|
||||
test('Predicted volume — dry-run lower clamp', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
// dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||
}));
|
||||
const t0 = 1_700_000_000_000;
|
||||
|
||||
await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
|
||||
// Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 2); // unchanged — clamp doesn't fire because we started below it
|
||||
});
|
||||
|
||||
await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
|
||||
// Calibrate well above, then push outflow that would cross the threshold.
|
||||
ps.calibratePredictedVolume(3, t0 + 1000);
|
||||
// outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
|
||||
ps.setManualOutflow(2, t0 + 1000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 2.1) < 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
// Seed an overflow scenario.
|
||||
const t0 = 1_700_000_000_000;
|
||||
ps.calibratePredictedVolume(44, t0);
|
||||
ps.setManualInflow(2, t0, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.predictedOverflowVolume, 1);
|
||||
assert.equal(out.predictedOverflowRate, 2);
|
||||
});
|
||||
|
||||
// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
|
||||
// from above, so a basin seeded below + continued outflow used to integrate
|
||||
// the volume arbitrarily negative. The level helper masked this by flooring
|
||||
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
|
||||
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||
}));
|
||||
const t0 = 1_700_000_000_000;
|
||||
|
||||
await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
|
||||
ps.calibratePredictedVolume(0.5, t0); // below dryRunSafetyVol (2.1)
|
||||
ps.setManualOutflow(2, t0, 'm3/s'); // 2 m³/s for 1s → would drop to -1.5
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 0); // floored at 0, not -1.5
|
||||
const underflow = ps.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(underflow, 1.5); // tracked as diagnostic
|
||||
});
|
||||
|
||||
await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 0);
|
||||
const underflow = ps.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(underflow, 3.5); // 1.5 + 2.0
|
||||
});
|
||||
|
||||
await t.test('getOutput exposes predictedUnderflowVolume', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.predictedUnderflowVolume, 3.5);
|
||||
});
|
||||
|
||||
await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
|
||||
ps.setManualInflow(1, t0 + 2000, 'm3/s');
|
||||
ps.setManualOutflow(0, t0 + 2000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||
Date.now = () => t0 + 3000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 1) < 1e-9); // 0 + 1 = 1, NOT pinned to 2.1
|
||||
});
|
||||
});
|
||||
94
test/integration/basic-dashboard-flow.test.js
Normal file
94
test/integration/basic-dashboard-flow.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function loadDashboardFlow() {
|
||||
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
|
||||
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
||||
}
|
||||
|
||||
function makeContextStub() {
|
||||
const store = {};
|
||||
return {
|
||||
get(key) {
|
||||
return store[key];
|
||||
},
|
||||
set(key, value) {
|
||||
store[key] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const ps = flow.find((n) => n.id === 'ps_node_basic');
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
|
||||
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
|
||||
|
||||
assert.ok(ps, 'ps_node_basic should exist');
|
||||
assert.equal(ps.type, 'pumpingStation');
|
||||
assert.equal(ps.controlMode, 'levelbased');
|
||||
assert.equal(ps.levelCurveType, 'linear');
|
||||
assert.equal(ps.inletPipeDiameter, 0.4);
|
||||
assert.equal(ps.outletPipeDiameter, 0.3);
|
||||
assert.ok(parser, 'ps_parse_output should exist');
|
||||
assert.equal(parser.outputs, 6);
|
||||
assert.equal(levelChart.type, 'ui-chart');
|
||||
assert.equal(demandChart.type, 'ui-chart');
|
||||
});
|
||||
|
||||
test('basic dashboard parser routes process fields to charts and state text', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
assert.ok(parser, 'ps_parse_output should exist');
|
||||
|
||||
const func = new Function('msg', 'context', 'node', parser.func);
|
||||
const context = makeContextStub();
|
||||
const node = { send() {} };
|
||||
|
||||
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
|
||||
// runtime writes without an explicit .child(), childId='default'. Mirror
|
||||
// the real shape here. (See generalFunctions/src/measurements/
|
||||
// MeasurementContainer.js getFlattenedOutput.)
|
||||
const out = func({
|
||||
payload: {
|
||||
'level.predicted.atequipment.default': 3.25,
|
||||
'volume.predicted.atequipment.default': 32.5,
|
||||
'netFlowRate.predicted.atequipment.default': 0.003,
|
||||
percControl: 25,
|
||||
direction: 'filling',
|
||||
safetyState: 'normal',
|
||||
isOverflowing: false,
|
||||
timeleft: 400,
|
||||
},
|
||||
}, context, node);
|
||||
|
||||
assert.ok(Array.isArray(out));
|
||||
assert.equal(out.length, 6);
|
||||
assert.equal(out[0].topic, 'level');
|
||||
assert.equal(out[0].payload, 3.25);
|
||||
assert.equal(out[1].topic, 'volume');
|
||||
assert.equal(out[1].payload, 32.5);
|
||||
assert.equal(out[2].topic, 'demand');
|
||||
assert.equal(out[2].payload, 25);
|
||||
assert.equal(out[3].topic, 'net_flow');
|
||||
assert.equal(out[3].payload, 0.003);
|
||||
assert.match(out[4].payload, /normal/);
|
||||
assert.match(out[5].payload, /level=3.25 m/);
|
||||
});
|
||||
|
||||
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
|
||||
const flow = loadDashboardFlow();
|
||||
const parser = flow.find((n) => n.id === 'ps_parse_output');
|
||||
const func = new Function('msg', 'context', 'node', parser.func);
|
||||
const context = makeContextStub();
|
||||
const node = { send() {} };
|
||||
|
||||
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
|
||||
const out = func({ payload: { percControl: 20 } }, context, node);
|
||||
|
||||
assert.equal(out[0].payload, 3.1);
|
||||
assert.equal(out[2].payload, 20);
|
||||
});
|
||||
198
test/integration/shifted-ramp-end-to-end.test.js
Normal file
198
test/integration/shifted-ramp-end-to-end.test.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
|
||||
// Drives a full fill→arm→drain cycle through the same code path the
|
||||
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
|
||||
// hold-then-ramp output behaviour.
|
||||
//
|
||||
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PumpingStation = require('../../src/specificClass');
|
||||
|
||||
const SURFACE_AREA = 10; // basin volume / height = 50/5
|
||||
const TICK_MS = 1000; // simulate 1 s per tick
|
||||
|
||||
function makeConfig() {
|
||||
return {
|
||||
general: {
|
||||
name: 'TestPS',
|
||||
id: 'ps-e2e',
|
||||
unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
flowThreshold: 1e-4,
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'pumpingStation',
|
||||
role: 'stationcontroller',
|
||||
positionVsParent: 'atEquipment',
|
||||
},
|
||||
basin: {
|
||||
volume: 50, height: 5,
|
||||
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
|
||||
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4,
|
||||
curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false, enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
|
||||
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build a PS with a fake MGC that captures every demand sent to it,
|
||||
// and a clock we control so _updatePredictedVolume integrates over a
|
||||
// known dt regardless of wall-clock.
|
||||
function buildHarness() {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
// Seed level at startLevel so the run begins idle.
|
||||
ps.calibratePredictedLevel(2.0);
|
||||
// Override Date.now via a controllable clock that advances `step()`.
|
||||
let now = ps._predictedFlowState.lastTimestamp || 0;
|
||||
ps._fakeNow = () => now;
|
||||
ps._fakeAdvance = (ms) => { now += ms; };
|
||||
// Patch global Date.now JUST inside the scope of these tests.
|
||||
const realNow = Date.now;
|
||||
Date.now = ps._fakeNow;
|
||||
// Restore on completion.
|
||||
ps._restore = () => { Date.now = realNow; };
|
||||
return { ps, demands };
|
||||
}
|
||||
|
||||
async function step(ps, qIn, qOut) {
|
||||
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
|
||||
// topic handlers in nodeClass.js), advance time, then tick once.
|
||||
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
|
||||
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
|
||||
ps._fakeAdvance(TICK_MS);
|
||||
ps.tick();
|
||||
}
|
||||
|
||||
function levelOf(ps) {
|
||||
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
}
|
||||
|
||||
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
|
||||
const { ps } = buildHarness();
|
||||
try {
|
||||
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
|
||||
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
|
||||
// 0.05/SURFACE_AREA = 0.005 m per second.
|
||||
let armedAt = null;
|
||||
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
|
||||
await step(ps, 0.05, 0);
|
||||
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
|
||||
}
|
||||
assert.ok(armedAt, 'shift should arm during fill');
|
||||
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
|
||||
// jitter for time-discretization.
|
||||
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
|
||||
`expected arm near level=3.8, got ${armedAt.level}`);
|
||||
assert.ok(armedAt.pct >= 80 - 1e-6,
|
||||
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
|
||||
|
||||
// While still filling and armed, output should track the up curve
|
||||
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
|
||||
const fillingPct = ps.percControl;
|
||||
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
|
||||
`filling-armed output should still be on up curve, got ${fillingPct}`);
|
||||
// No hold captured yet (still filling).
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
|
||||
// ─── PHASE B: flip to draining ─────────────────────────────────────
|
||||
// First drain tick captures the hold. We need direction='draining' as
|
||||
// determined by _selectBestNetFlow → so q_in - q_out must be negative
|
||||
// by more than the dead-band (1e-4).
|
||||
await step(ps, 0, 0.05); // net = -0.05
|
||||
assert.equal(ps.state.direction, 'draining');
|
||||
// Hold captured = up curve at the level when direction flipped. The
|
||||
// captured value is recorded BEFORE this drain tick lowered the level
|
||||
// further, so it should match the last filling tick's output (within
|
||||
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
|
||||
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
|
||||
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
|
||||
const hold = ps._shiftHoldValue;
|
||||
|
||||
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
|
||||
// Drain until level just above shiftLevel=3.5. Output stays = hold.
|
||||
let held = true;
|
||||
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
|
||||
await step(ps, 0, 0.05);
|
||||
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
|
||||
}
|
||||
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
|
||||
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
|
||||
`still expected hold=${hold}, got ${ps.percControl}`);
|
||||
|
||||
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
|
||||
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
|
||||
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
|
||||
const justBelow = ps.percControl;
|
||||
assert.ok(justBelow < hold,
|
||||
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
|
||||
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
|
||||
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
|
||||
const mid = ps.percControl;
|
||||
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
|
||||
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
|
||||
|
||||
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
|
||||
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
|
||||
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps.percControl, 0);
|
||||
} finally {
|
||||
ps._restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
|
||||
const { ps } = buildHarness();
|
||||
try {
|
||||
// Fill to arm + some headroom.
|
||||
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
|
||||
// First drain transition → hold #1.
|
||||
await step(ps, 0, 0.05);
|
||||
const hold1 = ps._shiftHoldValue;
|
||||
assert.ok(hold1 >= 80 - 1e-6);
|
||||
|
||||
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
|
||||
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
|
||||
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
|
||||
|
||||
// Flip back to filling at higher rate; up curve resumes; hold cleared.
|
||||
await step(ps, 0.05, 0);
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
|
||||
|
||||
// Fill higher than before (output goes higher).
|
||||
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
|
||||
const fillingPct = ps.percControl;
|
||||
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
|
||||
|
||||
// Drain again → fresh hold #2 = current up curve %.
|
||||
await step(ps, 0, 0.05);
|
||||
const hold2 = ps._shiftHoldValue;
|
||||
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
|
||||
} finally {
|
||||
ps._restore();
|
||||
}
|
||||
});
|
||||
18
wiki/README.md
Normal file
18
wiki/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# pumpingStation — Documentation
|
||||
|
||||
All docs and diagrams for this node live in this folder so they version-lock with the code they describe.
|
||||
|
||||
## Pages
|
||||
|
||||
- **[Functional Description](functional-description.md)** — operator-facing reference derived from `src/specificClass.js`: basin model, net-flow selection, safety interlocks, registration topology.
|
||||
- **[Control modes](modes/README.md)** — one page per control mode (`levelbased`, `flowbased`, …) describing how the mode uses the shared basin model to compute demand.
|
||||
|
||||
## Diagrams
|
||||
|
||||
Editable draw.io SVGs live in [`diagrams/`](diagrams/). See [`diagrams/README.md`](diagrams/README.md) for the editing workflow — open the `.drawio.svg` in [draw.io](https://app.diagrams.net/), edit it, then export back to SVG with the source embedded.
|
||||
|
||||
The basin model is the shared physical canvas ([`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg)); per-mode transfer-function diagrams live under [`diagrams/modes/`](diagrams/modes/). Mode-specific thresholds such as `startLevel` belong in those mode diagrams, not in the generic basin model.
|
||||
|
||||
## Part of
|
||||
|
||||
This node is a git submodule of [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV). The EVOLV superproject has its own [`wiki/`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki) with platform-level docs (architecture, concepts, shared manuals).
|
||||
72
wiki/diagrams/README.md
Normal file
72
wiki/diagrams/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Diagrams
|
||||
|
||||
Editable source diagrams for the pumpingStation wiki. The current diagrams are **`.drawio.svg` files with the draw.io source embedded**, so anyone can edit the SVG directly in [draw.io](https://app.diagrams.net/) without touching any Markdown.
|
||||
|
||||
## File roles
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `<name>.drawio` | Optional native draw.io XML source, if a diagram also keeps a standalone source file. |
|
||||
| `<name>.drawio.svg` | SVG export of the same diagram (with source embedded). What the wiki actually renders, and what round-trips back into draw.io. |
|
||||
|
||||
An optional standalone `.drawio` file can be committed beside the SVG, but the embedded-source SVG is enough for the wiki to render and for the next editor to pick up from exactly where the last one left off.
|
||||
|
||||
## Editing workflow
|
||||
|
||||
1. **Clone** the repo (you likely already have it if you're editing):
|
||||
```bash
|
||||
git clone https://gitea.wbd-rd.nl/RnD/pumpingStation.git
|
||||
cd pumpingStation/wiki/diagrams
|
||||
```
|
||||
2. **Open** the `.drawio.svg` file in draw.io:
|
||||
- Web: [app.diagrams.net](https://app.diagrams.net/) → *Open Existing Diagram*, or drag-and-drop.
|
||||
- Desktop: [drawio-desktop](https://github.com/jgraph/drawio-desktop/releases).
|
||||
3. **Edit** — move shapes, change labels, adjust layout.
|
||||
4. **Export** to SVG with the source embedded:
|
||||
- `File → Export as → SVG…`
|
||||
- Check **Include a copy of my diagram** ← this is what lets future edits round-trip through the SVG.
|
||||
- Save next to the source as `<name>.drawio.svg` (overwrite).
|
||||
5. **Commit & push** the edited SVG, plus the `.drawio` file if one exists:
|
||||
```bash
|
||||
git add wiki/diagrams/<name>.drawio.svg
|
||||
git commit -m "Update <name>: <what changed>"
|
||||
git push
|
||||
```
|
||||
|
||||
## Referencing a diagram from a wiki page
|
||||
|
||||
In any Markdown page under `wiki/`:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up in exports.
|
||||
|
||||
## Naming
|
||||
|
||||
- kebab-case, one concept per diagram.
|
||||
- Current diagrams:
|
||||
|
||||
| Diagram | Shows |
|
||||
|---|---|
|
||||
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
|
||||
| `modes/level-based/basin-mode-level-linear` | Level-based linear control curve — rising ramp starts at inlet level, falling ramp shifts to `startLevel` |
|
||||
| `modes/level-based/basin-mode-level-log` | Level-based logarithmic control curve — fast early response, falling ramp shifts to `startLevel` |
|
||||
| `control-zones` | Legacy vertical level axis ("thermometer") for `levelbased` mode — STOP / DEAD ZONE / RUN with demand ramp |
|
||||
| `safety-rules` | Dry-run vs high-volume safety rule asymmetry — which children stop, which keep running |
|
||||
|
||||
## Making a brand-new diagram
|
||||
|
||||
1. Open draw.io, start blank.
|
||||
2. Draw it.
|
||||
3. `File → Export as → SVG…` with **Include a copy of my diagram** checked → save as `wiki/diagrams/<name>.drawio.svg`.
|
||||
4. Reference from the wiki page with ``.
|
||||
5. Add an entry to the table above.
|
||||
6. Commit the new `.drawio.svg` and updated `.md` together.
|
||||
|
||||
## These starters are rough
|
||||
|
||||
Some diagrams are still rough — layout is approximate, colors and fonts may be defaults, and alignment may need refinement. They're meant to be improved in draw.io as the model settles.
|
||||
|
||||
Open the `.drawio.svg` in draw.io and it will load the editable model. The SVG has the draw.io XML embedded in a `content="…"` attribute on the root `<svg>` element — that's what lets draw.io re-open its own SVG exports.
|
||||
6
wiki/diagrams/basin-model.drawio.svg
Normal file
6
wiki/diagrams/basin-model.drawio.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 686 KiB |
162
wiki/diagrams/control-zones.drawio.svg
Normal file
162
wiki/diagrams/control-zones.drawio.svg
Normal file
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 660" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="control-zones" id="controlZones">
|
||||
<mxGraphModel dx="1000" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="700" pageHeight="800" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="levelbased mode — three zones" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="20" width="500" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="axis" value="" style="endArrow=classic;html=1;strokeColor=#000;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="280" y="600" as="sourcePoint" />
|
||||
<mxPoint x="280" y="80" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="axis_label" value="level" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="60" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overflow" value="heightOverflow — weir crest (spill → measure)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="130" width="380" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="overflow_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="140" as="sourcePoint" />
|
||||
<mxPoint x="290" y="140" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="run_band" value="RUN — linear 0 → 100 %" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#E8F5E9;strokeColor=#1E8449;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="160" width="220" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="maxflow" value="maxFlowLevel — 100 % demand" style="text;html=1;fontSize=12;align=left;fontColor=#D68910;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="265" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="maxflow_tick" value="" style="endArrow=none;html=1;strokeColor=#D68910;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="275" as="sourcePoint" />
|
||||
<mxPoint x="295" y="275" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="ramp_label" value="(ramp — demand scales linearly with level)" style="text;html=1;fontSize=11;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="300" width="320" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="startlevel" value="startLevel — 0 % demand (ramp starts)" style="text;html=1;fontSize=12;align=left;fontColor=#1E8449;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="335" width="340" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="start_tick" value="" style="endArrow=none;html=1;strokeColor=#1E8449;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="345" as="sourcePoint" />
|
||||
<mxPoint x="295" y="345" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dead_band" value="DEAD ZONE — hysteresis, keep last cmd" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF8E1;strokeColor=#F57C00;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="360" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="inlet" value="heightInlet — inflow pipe" style="text;html=1;fontSize=12;align=left;fontColor=#1F4E79;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="395" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="inlet_tick" value="" style="endArrow=none;html=1;strokeColor=#1F4E79;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="405" as="sourcePoint" />
|
||||
<mxPoint x="290" y="405" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stoplevel" value="stopLevel — unconditional STOP" style="text;html=1;fontSize=12;align=left;fontColor=#6C3483;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="440" width="300" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="stop_tick" value="" style="endArrow=none;html=1;strokeColor=#6C3483;strokeWidth=2;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="265" y="450" as="sourcePoint" />
|
||||
<mxPoint x="295" y="450" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="stop_band" value="pumps OFF (MGC shutdown)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#F4ECF7;strokeColor=#6C3483;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="465" width="220" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="outlet" value="heightOutlet — outflow pipe (dry-run trip here)" style="text;html=1;fontSize=12;align=left;fontColor=#B22222;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="510" width="360" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="outlet_tick" value="" style="endArrow=none;html=1;strokeColor=#B22222;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="270" y="520" as="sourcePoint" />
|
||||
<mxPoint x="290" y="520" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="floor" value="0 (floor)" style="text;html=1;fontSize=11;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="580" width="60" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>">
|
||||
<title>levelbased mode — three zones</title>
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#000" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<text x="350" y="30" text-anchor="middle" font-weight="bold" font-size="16">levelbased mode — three zones</text>
|
||||
|
||||
<!-- Vertical level axis -->
|
||||
<line x1="280" y1="600" x2="280" y2="80" stroke="#000" stroke-width="2" marker-end="url(#arr)" />
|
||||
<text x="260" y="75" text-anchor="end" font-weight="bold" font-size="13">level</text>
|
||||
|
||||
<!-- heightOverflow -->
|
||||
<line x1="270" y1="140" x2="290" y2="140" stroke="#B22222" stroke-width="2" />
|
||||
<text x="300" y="144" fill="#B22222" font-size="12">heightOverflow — weir crest (spill → measure)</text>
|
||||
|
||||
<!-- RUN band -->
|
||||
<rect x="300" y="160" width="240" height="110" fill="#E8F5E9" stroke="#1E8449" />
|
||||
<text x="420" y="220" text-anchor="middle" font-size="13" fill="#1E8449" font-weight="bold">RUN</text>
|
||||
<text x="420" y="238" text-anchor="middle" font-size="12" fill="#1E8449">linear 0 → 100 %</text>
|
||||
|
||||
<!-- maxFlowLevel -->
|
||||
<line x1="265" y1="275" x2="295" y2="275" stroke="#D68910" stroke-width="3" />
|
||||
<text x="305" y="279" fill="#D68910" font-size="12" font-weight="bold">maxFlowLevel — 100 % demand</text>
|
||||
|
||||
<!-- Ramp label -->
|
||||
<text x="305" y="314" font-size="11" font-style="italic">(ramp — demand scales linearly with level)</text>
|
||||
|
||||
<!-- startLevel -->
|
||||
<line x1="265" y1="345" x2="295" y2="345" stroke="#1E8449" stroke-width="3" />
|
||||
<text x="305" y="349" fill="#1E8449" font-size="12" font-weight="bold">startLevel — 0 % demand (ramp starts)</text>
|
||||
|
||||
<!-- DEAD ZONE band -->
|
||||
<rect x="300" y="360" width="240" height="80" fill="#FFF8E1" stroke="#F57C00" />
|
||||
<text x="420" y="390" text-anchor="middle" font-size="13" fill="#B78200" font-weight="bold">DEAD ZONE</text>
|
||||
<text x="420" y="408" text-anchor="middle" font-size="12" fill="#B78200">hysteresis — keep last cmd</text>
|
||||
|
||||
<!-- heightInlet (inside dead zone) -->
|
||||
<line x1="270" y1="405" x2="290" y2="405" stroke="#1F4E79" stroke-width="2" />
|
||||
<text x="550" y="409" fill="#1F4E79" font-size="12">heightInlet</text>
|
||||
|
||||
<!-- stopLevel -->
|
||||
<line x1="265" y1="450" x2="295" y2="450" stroke="#6C3483" stroke-width="3" />
|
||||
<text x="305" y="454" fill="#6C3483" font-size="12" font-weight="bold">stopLevel — unconditional STOP</text>
|
||||
|
||||
<!-- STOP band -->
|
||||
<rect x="300" y="465" width="240" height="80" fill="#F4ECF7" stroke="#6C3483" />
|
||||
<text x="420" y="500" text-anchor="middle" font-size="13" fill="#6C3483" font-weight="bold">pumps OFF</text>
|
||||
<text x="420" y="518" text-anchor="middle" font-size="12" fill="#6C3483">(MGC shutdown)</text>
|
||||
|
||||
<!-- heightOutlet -->
|
||||
<line x1="270" y1="540" x2="290" y2="540" stroke="#B22222" stroke-width="2" />
|
||||
<text x="305" y="544" fill="#B22222" font-size="12">heightOutlet — outflow pipe (dry-run trip)</text>
|
||||
|
||||
<!-- floor -->
|
||||
<line x1="265" y1="600" x2="295" y2="600" stroke="#000" stroke-width="2" />
|
||||
<text x="305" y="604" font-size="11">0 (floor)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 319 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 256 KiB |
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
99
wiki/diagrams/safety-rules.drawio.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 620" font-family="Arial, sans-serif" font-size="13" content="<mxfile host="app.diagrams.net" modified="2026-04-22T12:00:00.000Z" agent="Claude Code placeholder" etag="initial" version="22.0.0" type="device">
|
||||
<diagram name="safety-rules" id="safetyRules">
|
||||
<mxGraphModel dx="1200" dy="700" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="900" pageHeight="700" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="Safety rules — asymmetric by direction" style="text;html=1;fontSize=16;fontStyle=1;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="20" width="600" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="dryrun_box" value="DRY-RUN&#10;(direction = draining)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFF3E0;strokeColor=#E65100;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_upstream" value="upstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_downstream" value="downstream children — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_machinegroups" value="machineGroups — STOP" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_control" value="control loop — BLOCKED" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#E65100;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dr_note" value="safetyControllerActive = true&#10;&#10;Pumps must stop before sucking air." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="290" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="overfill_box" value="OVERFILL&#10;(direction = filling)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFEBEE;strokeColor=#C62828;strokeWidth=2;fontSize=14;fontStyle=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="480" y="80" width="340" height="340" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_upstream" value="upstream children — STOP ⚠" style="text;html=1;fontSize=13;align=left;fontStyle=1;fontColor=#C62828;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="140" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_downstream" value="downstream children — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="170" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_machinegroups" value="machineGroups — KEEP" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="200" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_control" value="control loop — ACTIVE" style="text;html=1;fontSize=13;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="230" width="300" height="24" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="of_note" value="Level control keeps commanding downstream MGC.&#10;&#10;⚠ &quot;upstream STOP&quot; is only correct in a cascaded layout. In a gravity-sewer station the inflow can&apos;t be stopped — log the spill instead." style="text;html=1;fontSize=12;align=left;fontStyle=2;" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="290" width="300" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="trigger_title" value="Triggers (either condition fires the rule):" style="text;html=1;fontSize=13;fontStyle=1;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="450" width="740" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="trigger_list" value="• vol &lt; triggerLowVol (triggerLowVol = minVol × (1 + pct/100))&#10;• vol &gt; triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)&#10;• remainingTime &lt; timeleftToFullOrEmptyThresholdSeconds (if enabled)" style="text;html=1;fontSize=12;align=left;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="480" width="740" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>">
|
||||
<title>Safety rules — asymmetric by direction</title>
|
||||
|
||||
<text x="450" y="30" text-anchor="middle" font-weight="bold" font-size="16">Safety rules — asymmetric by direction</text>
|
||||
|
||||
<!-- DRY-RUN box -->
|
||||
<rect x="80" y="80" width="340" height="340" fill="#FFF3E0" stroke="#E65100" stroke-width="2" />
|
||||
<text x="250" y="112" text-anchor="middle" font-weight="bold" font-size="14">DRY-RUN</text>
|
||||
<text x="250" y="130" text-anchor="middle" font-size="13" fill="#6F4A19">(direction = draining)</text>
|
||||
|
||||
<text x="100" y="162" font-size="13">upstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="100" y="188" font-size="13" fill="#E65100">downstream children — <tspan font-weight="bold">STOP</tspan></text>
|
||||
<text x="100" y="214" font-size="13" fill="#E65100">machineGroups — <tspan font-weight="bold">STOP</tspan></text>
|
||||
<text x="100" y="240" font-size="13" fill="#E65100">control loop — <tspan font-weight="bold">BLOCKED</tspan></text>
|
||||
|
||||
<line x1="100" y1="268" x2="400" y2="268" stroke="#E65100" stroke-dasharray="3 3" />
|
||||
<text x="100" y="294" font-size="12" font-style="italic">safetyControllerActive = true</text>
|
||||
<text x="100" y="316" font-size="12" font-style="italic">Pumps must stop before sucking air.</text>
|
||||
|
||||
<!-- OVERFILL box -->
|
||||
<rect x="480" y="80" width="340" height="340" fill="#FFEBEE" stroke="#C62828" stroke-width="2" />
|
||||
<text x="650" y="112" text-anchor="middle" font-weight="bold" font-size="14">OVERFILL</text>
|
||||
<text x="650" y="130" text-anchor="middle" font-size="13" fill="#7A1919">(direction = filling)</text>
|
||||
|
||||
<text x="500" y="162" font-size="13" fill="#C62828">upstream children — <tspan font-weight="bold">STOP</tspan> ⚠</text>
|
||||
<text x="500" y="188" font-size="13">downstream children — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="500" y="214" font-size="13">machineGroups — <tspan font-weight="bold">KEEP</tspan></text>
|
||||
<text x="500" y="240" font-size="13">control loop — <tspan font-weight="bold">ACTIVE</tspan></text>
|
||||
|
||||
<line x1="500" y1="268" x2="800" y2="268" stroke="#C62828" stroke-dasharray="3 3" />
|
||||
<text x="500" y="294" font-size="12" font-style="italic">Level control keeps commanding downstream MGC.</text>
|
||||
<text x="500" y="324" font-size="12" font-style="italic" fill="#C62828">⚠ "upstream STOP" is only correct in a cascaded layout.</text>
|
||||
<text x="500" y="342" font-size="12" font-style="italic" fill="#C62828">In a gravity-sewer station the inflow can't be</text>
|
||||
<text x="500" y="360" font-size="12" font-style="italic" fill="#C62828">stopped — log the spill instead.</text>
|
||||
|
||||
<!-- Triggers block -->
|
||||
<text x="80" y="470" font-weight="bold" font-size="13">Triggers (either condition fires the rule):</text>
|
||||
<text x="100" y="498" font-size="12">• vol < triggerLowVol (triggerLowVol = minVol × (1 + pct/100))</text>
|
||||
<text x="100" y="520" font-size="12">• vol > triggerHighVol (triggerHighVol = maxVolOverflow × pct/100)</text>
|
||||
<text x="100" y="542" font-size="12">• remainingTime < timeleftToFullOrEmptyThresholdSeconds (if enabled)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
377
wiki/functional-description.md
Normal file
377
wiki/functional-description.md
Normal file
@@ -0,0 +1,377 @@
|
||||
---
|
||||
title: pumpingStation — Functional Description
|
||||
node: pumpingStation
|
||||
updated: 2026-04-22
|
||||
status: draft
|
||||
---
|
||||
|
||||
# pumpingStation — Functional Description
|
||||
|
||||
The `pumpingStation` node models an S88 **Process Cell**: a wet-well basin with inflow and outflow, wrapped around one or more pump controllers. Every second it recomputes the basin's water balance, picks the most trustworthy net-flow source, runs its safety interlocks, and finally commands its children (individual pumps, `machineGroupControl`, or nested pumping stations) so the level stays inside the safe operating band.
|
||||
|
||||
This page is the operator-facing reference, derived from [`src/specificClass.js`](../src/specificClass.js). For the 3-tier code layout see [EVOLV — Node Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/architecture/node-architecture.md); for the atomic pump model see the [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki).
|
||||
|
||||
> **Diagrams on this page are editable.** Sources live in [`diagrams/`](diagrams/) — open the `.drawio` file in [draw.io](https://app.diagrams.net/), export to SVG, commit. See [`diagrams/README.md`](diagrams/README.md).
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Node category | EVOLV |
|
||||
| S88 level | Process Cell (`#0c99d9`, lane L5) |
|
||||
| Inputs | 1 (message-driven) |
|
||||
| Outputs | 3 — `process` / `dbase` / `parent` |
|
||||
| Tick period | 1 s |
|
||||
| Basin model | Rectangular prismatic — `volume = level × surfaceArea` |
|
||||
| Canonical units (internal) | Pa, m³/s, W, K, m, m³ |
|
||||
| Control modes implemented | `levelbased`, `manual` (placeholders for `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid`) |
|
||||
| Default flow dead-band | `1e-4 m³/s` (≈ 0.36 m³/h) |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Construct.** The node merges the user's editor config over the schema defaults, creates the measurement store, and seeds the predicted volume at the basin's operational floor (`minVol`).
|
||||
2. **Register children.** Sensors, pumps, machine groups, and nested stations register via the Port-2 handshake. The station subscribes only to the *highest-level aggregator* for predicted flow to avoid double-counting (MGC if present, otherwise the individual pump).
|
||||
3. **Tick loop (1 s).** `_updatePredictedVolume → _selectBestNetFlow → _safetyController → _controlLogic → state snapshot → output`.
|
||||
|
||||
## Editor configuration
|
||||
|
||||
Every field on the pumpingStation editor maps directly to the config schema in `generalFunctions/src/configs/pumpingStation.json`.
|
||||
|
||||
### Basin geometry (section `basin`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Basin Volume (m³)** | `1` | Total geometric storage volume from basin floor to rim. |
|
||||
| **Basin Height (m)** | `1` | Physical wall height from floor to rim. |
|
||||
| **Inlet Elevation (m)** | `2` | Bottom/invert of the incoming sewer pipe, measured from the basin floor. This is the level where backing up into the inlet starts to matter hydraulically. |
|
||||
| **Outlet Elevation (m)** | `0.2` | Top of the pump-suction/outlet pipe, measured from the basin floor. This is the practical lower hydraulic reference for pump protection. |
|
||||
| **Inlet Pipe Diameter (m)** | `0.4` | Nominal incoming sewer pipe diameter. Used with `inflowLevel` to distinguish pipe bottom, centre, and crown in future hydraulic upgrades. |
|
||||
| **Outlet Pipe Diameter (m)** | `0.4` | Nominal pump-suction/outlet pipe diameter. Used with `outflowLevel` to distinguish pipe top, centre, and invert in future hydraulic upgrades. |
|
||||
| **Overflow Level (m)** | `2.5` | Physical overflow-weir crest, measured from the floor. At or above this level the basin is actually spilling. |
|
||||
|
||||
Constant cross-section is assumed: `surfaceArea = volume / height`. All derived volumes (`minVolAtOutflow`, `minVolAtInflow`, `maxVolAtOverflow`, `maxVol`) are computed once in `initBasinProperties()` and kept on `station.basin`.
|
||||
|
||||
The current runtime still uses the level fields directly for its volume math. Pipe diameters are part of the basin model contract so later hydraulic logic can reason about pipe invert/crown and not silently treat every pipe elevation as a centreline.
|
||||
|
||||
### Hydraulics (section `hydraulics`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Minimum Height Based On** | `outlet` | `outlet` → `minVol = outflowLevel × area` (includes the buffer). `inlet` → `minVol = inflowLevel × area` (buffer treated as unavailable). |
|
||||
| **Reference Height** | `NAP` | Vertical datum: `NAP` / `EVRF` / `EGM2008`. Metadata only — not used in math today. |
|
||||
| **Basin Bottom (m Refheight)** | `0` | Absolute elevation of the basin floor, for cross-basin comparisons. |
|
||||
|
||||
### Control (section `control`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Control mode** | `levelbased` | Active control strategy. Schema enumerates seven modes; today `levelbased` is fully implemented, `manual` forwards demand via `Qd`, others are placeholders. |
|
||||
| **minLevel (m)** | `1` | Below this level → unconditional MGC shutdown. |
|
||||
| **startLevel (m)** | `1` | Mode-specific threshold. In `levelbased`, this is the bottom of the linear scaling range (0 % demand). It is not part of the generic basin model because other modes can define a different start policy. |
|
||||
| **maxLevel (m)** | `4` | Upper normal operating/storage level used by the active mode. In `levelbased`, this is where demand reaches 100 %. |
|
||||
| **Flow setpoint** | `0` | Flow-based target (m³/h). Placeholder until `flowbased` is wired. |
|
||||
| **Deadband** | `0` | Flow-based deadband (m³/h). Placeholder. |
|
||||
|
||||
### Safety (section `safety`)
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| **Time To Empty/Full (s)** | `0` | If > 0, triggers safety when predicted time-to-overflow or time-to-empty falls below this value. `0` disables time-based protection. |
|
||||
| **Enable Dry-Run Protection** | `true` | If on, pumps are shut down once volume drops below the dry-run threshold while draining. |
|
||||
| **Low Volume Threshold (%)** | `2` | Safety margin above the configured minimum volume: `dryRunSafetyVol = minVol × (1 + pct/100)`. This creates `dryRunLevel`; it is derived, not a separately entered basin height. |
|
||||
| **Enable High-volume Safety** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
|
||||
| **High Volume Threshold (%)** | `98` | Safety margin below physical overflow: `highVolumeSafetyVol = maxVolAtOverflow × pct/100`. Actual overflowing is still the boolean condition `level >= overflowLevel`. |
|
||||
|
||||
### Output formats
|
||||
|
||||
- **Process Output** — format for Port 0 (`process` / `json` / `csv`).
|
||||
- **Database Output** — format for Port 1 (`influxdb` / `json` / `csv`).
|
||||
|
||||
> **Tip — always configure every field.** The pumpingStation mixes geometry and control thresholds freely. Leaving `overflowLevel` at the schema default of 2.5 m while sizing the basin for 10 m walls produces nonsensical fill-percentages and spurious safety events. See the [EVOLV flow-layout rules §9](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/.claude/rules/node-red-flow-layout.md) for the completeness rule.
|
||||
|
||||
## Input topics
|
||||
|
||||
All commands enter on the single input port. `msg.topic` selects the handler; `msg.payload` carries the argument.
|
||||
|
||||
### `changemode`
|
||||
|
||||
```json
|
||||
{ "topic": "changemode", "payload": "manual" }
|
||||
```
|
||||
|
||||
Switches the active control strategy. The new mode must be in `config.control.allowedModes` — unknown values are rejected with a warning. Typical transitions: `levelbased ⇄ manual` for operator override during maintenance.
|
||||
|
||||
### `calibratePredictedVolume`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedVolume", "payload": 3.4 }
|
||||
```
|
||||
|
||||
Hard-reset the predicted volume time-series to the supplied value (m³). Also rewrites the predicted level (derived from the constant-area geometry) and resets the internal flow-integrator state. Use this when a trustworthy measured level becomes available.
|
||||
|
||||
### `calibratePredictedLevel`
|
||||
|
||||
```json
|
||||
{ "topic": "calibratePredictedLevel", "payload": 1.8 }
|
||||
```
|
||||
|
||||
Same as above, but caller supplies a level (m). The predicted volume is recomputed via `volume = level × surfaceArea`.
|
||||
|
||||
### `q_in`
|
||||
|
||||
```json
|
||||
{ "topic": "q_in", "payload": 300, "unit": "l/s" }
|
||||
```
|
||||
|
||||
Inject a **manual inflow** into the basin. Registered as a predicted flow under the synthetic child `manual-qin` at position `in`. Useful when no physical inflow sensor is wired but the inflow is known externally (e.g. fed from a sewer model).
|
||||
|
||||
### `Qd`
|
||||
|
||||
```json
|
||||
{ "topic": "Qd", "payload": 75 }
|
||||
```
|
||||
|
||||
Forward a manual demand to every child aggregator (MGC first, then any direct pumps). **Only honoured when `config.control.mode === 'manual'`** — in any other mode the command is logged and discarded. Mirrors how `rotatingMachine` gates commands behind its mode field. The interpretation of the number depends on the child's scaling (`absolute` = m³/h, `normalized` = 0–100 %).
|
||||
|
||||
### `registerChild`
|
||||
|
||||
Internal. Child nodes (measurements, rotatingMachines, machineGroupControls, nested pumpingStations) emit this on their Port 2 a few hundred ms after deploy. The station resolves the Node-RED node id back to the source object and registers it via `childRegistrationUtils`.
|
||||
|
||||
## Output ports
|
||||
|
||||
### Port 0 — process data
|
||||
|
||||
Delta-compressed payload (only changed fields per tick). Keys follow the standard 4-segment format `<type>.<variant>.<position>.<childId>` plus a handful of top-level state fields merged in by `getOutput()`:
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `volume.predicted.atequipment.default` | Running predicted volume from the flow integrator (m³). |
|
||||
| `volume.measured.atequipment.default` | Volume derived from a `measured` level sensor (m³). |
|
||||
| `level.predicted.atequipment.default` | Predicted level = `volume / area` (m). |
|
||||
| `level.measured.<position>.<childId>` | Raw level sensor reading (m). |
|
||||
| `volumePercent.predicted.atequipment.default` | `(vol - minVol) / (maxVolAtOverflow - minVol) × 100` (%). |
|
||||
| `flow.predicted.in.<childId>` | Inflow contribution from a registered child (m³/s internally; editor unit on output). |
|
||||
| `flow.predicted.out.<childId>` | Outflow contribution from a registered child. |
|
||||
| `flow.predicted.overflow.default` | Synthetic spill rate over the weir while predicted volume is pinned at `maxVolAtOverflow` (m³/s). Zero when not spilling. Lives at its own position (not under `out`) so the operational outflow sum stays clean; `_selectBestNetFlow` folds it into the outflow side for net-flow balance, where it reads ~0 while pinned. |
|
||||
| `flow.measured.<position>.<childId>` | Flow sensor reading. |
|
||||
| `netFlowRate.<variant>.atequipment.default` | Net flow used for control (inflow − outflow). |
|
||||
| `overflowVolume.predicted.atequipment.default` | Cumulative predicted spill volume (m³) — for compliance reporting via InfluxDB. Monotonically non-decreasing. |
|
||||
| `underflowVolume.predicted.atequipment.default` | Cumulative volume the integrator tried to drive below 0 m³ (m³). Diagnostic only, NOT compliance — a non-zero value indicates a flow-balance error (over-reported outflow / missing inflow source / pump curve too optimistic). |
|
||||
| `direction` | `filling` / `draining` / `steady` / `unknown`. |
|
||||
| `flowSource` | Which variant drove the current control cycle (`measured`, `predicted`, `level:predicted`, `null`). |
|
||||
| `timeleft` | Predicted seconds to overflow (while filling) or to dry-run (while draining). |
|
||||
| `volEmptyBasin`, `inflowLevel`, `overflowLevel`, `maxVol`, `maxVolAtOverflow`, `minVol`, `minVolAtInflow`, `minVolAtOutflow`, `minHeightBasedOn` | Echoes of the basin geometry for dashboards. |
|
||||
| `predictedOverflowVolume` | Convenience top-level mirror of `overflowVolume.predicted.atequipment.default` (m³). |
|
||||
| `predictedOverflowRate` | Convenience top-level mirror of `flow.predicted.overflow.default` (m³/s). |
|
||||
| `predictedUnderflowVolume` | Convenience top-level mirror of `underflowVolume.predicted.atequipment.default` (m³). |
|
||||
| `percControl` | Last demand (0–100+ %) forwarded to the machine group during level-based control. |
|
||||
|
||||
Consumers must cache and merge deltas — the example dashboard flows include a reusable function node that does exactly this.
|
||||
|
||||
### Port 1 — dbase (InfluxDB)
|
||||
|
||||
Line-protocol payload for the `telemetry` bucket. Tags stay low-cardinality (station name, asset type); fields carry the numeric state. See [EVOLV — InfluxDB Schema Design](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/main/wiki/concepts/influxdb-schema-design.md).
|
||||
|
||||
### Port 2 — parent
|
||||
|
||||
`{ topic: "registerChild", payload: <this-node-id>, positionVsParent, distance }` — fired once ~100 ms after deploy so an upstream cascade can discover this station. Nested stations use this to register with an outer `pumpingStation` parent.
|
||||
|
||||
## Basin model
|
||||
|
||||
The basin is modelled as a rectangular prism with constant cross-section. Everything derives from `volume = level × surfaceArea`, with every level measured upward from the basin floor.
|
||||
|
||||

|
||||
|
||||
*Editable source: [`diagrams/basin-model.drawio.svg`](diagrams/basin-model.drawio.svg) (drag into draw.io; the SVG embeds the editable source). See [`diagrams/README.md`](diagrams/README.md) for the edit-and-export workflow.*
|
||||
|
||||
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel < inflowLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
|
||||
|
||||
`minLevel`, `startLevel`, and `maxLevel` are deliberately not part of this generic basin diagram. They belong to a control mode. For the current level-based mode variants, see [`diagrams/modes/level-based/`](diagrams/modes/level-based/).
|
||||
|
||||
The pipe labels are intentional:
|
||||
|
||||
- `inflowLevel` is the bottom/invert of the incoming sewer pipe.
|
||||
- `outflowLevel` is the top of the pump-suction/outlet pipe.
|
||||
|
||||
This avoids hiding hydraulic consequences behind ambiguous pipe-centre elevations. Pipe diameters are part of the model contract so later versions can derive pipe centre/crown/invert where needed.
|
||||
|
||||
`dryRunLevel` and `highVolumeSafetyLevel` are derived safety points. They provide margin before the two hard physical conditions:
|
||||
|
||||
- Actual dry-run risk is at or below the pumpable lower hydraulic reference.
|
||||
- Actual overflowing is the boolean condition `level >= overflowLevel`.
|
||||
|
||||
The high-volume safety point exists so the station can still react before the basin is physically spilling. Once `overflowLevel` is reached, the model should report overflowing rather than treating that point as a controllable threshold.
|
||||
|
||||
**minHeightBasedOn** — which pipe defines `minVol`, the operational floor used for the initial seed, the dry-run trigger, and the 0 % point of the fill percentage:
|
||||
|
||||
```
|
||||
outlet (default): inlet:
|
||||
|
||||
● maxVolAtOverflow ● maxVolAtOverflow
|
||||
│ │
|
||||
● inflowLevel ● inflowLevel ─── minVol
|
||||
│ │
|
||||
● outflowLevel ──── minVol ● outflowLevel
|
||||
│ │
|
||||
● floor ● floor
|
||||
|
||||
Buffer counts as usable stock. Buffer reserved; 0% fill
|
||||
starts at the inlet.
|
||||
```
|
||||
|
||||
The rectangular approximation is acceptable for this node's first basin model because operational level is always in metres from the basin floor, while calculated m³ can tolerate small shape errors. A later upgrade can replace `volume = level × surfaceArea` with a level-volume curve for benching, sumps, sediment/dead zones, and irregular wet-well geometry.
|
||||
|
||||
### Predicted-volume bounds
|
||||
|
||||
The predicted-volume integrator is clamped between two physical limits. **Measured** values are never clamped — only a real sensor can show level outside this range (e.g. inflow exceeds pump+weir capacity and the basin pressurises against the ceiling).
|
||||
|
||||
**Upper bound — `maxVolAtOverflow`.** Once the integrator would push past the weir crest, the predicted level pins at `overflowLevel`. The excess is recorded two ways every tick it spills:
|
||||
|
||||
- **Cumulative `overflowVolume.predicted.atequipment.default`** — running total of spill in m³, for compliance reporting via InfluxDB.
|
||||
- **Synthetic `flow.predicted.out.overflow`** — instantaneous spill rate (m³/s) equal to `inflow − real_outflow`. Registered as a predicted outflow contribution so `_selectBestNetFlow` sees a balanced ledger and reports `netFlowRate ≈ 0` while pinned. The integrator subtracts this synthetic flow before integrating so the spill never feeds back into the volume math.
|
||||
|
||||
The `isOverflowing` flag (true when `level >= overflowLevel`) is what tells operators why net flow reads zero even though water is still moving through the basin.
|
||||
|
||||
**Lower bound — `dryRunSafetyVol`.** The integrator can't drain below the dry-run threshold because pumps physically can't pump that low (the safety controller would shut them off, and even with safety disabled the suction loses prime). The clamp only fires on the transition — if the basin starts (or is calibrated) below `dryRunSafetyVol` it's left alone; inflow is what brings it back up.
|
||||
|
||||
### Level-rate fallback during overflow
|
||||
|
||||
When the chosen flow source is `level:measured` or `level:predicted` (priorities 3–4 in the ladder below), `dL/dt × surfaceArea` *is* the net flow. While level is pinned at `overflowLevel`, `dL/dt = 0` collapses the signal even though water is still moving. In that case `_selectBestNetFlow` holds the last known non-zero net flow until level starts dropping again — so dashboards keep a usable "this is roughly what's coming in" reading. The held value is refreshed any tick the level rate is meaningful, so it auto-updates once the basin un-pins.
|
||||
|
||||
## Net-flow selection
|
||||
|
||||
Every tick, `_selectBestNetFlow()` walks a priority ladder and returns the first net flow that clears the dead-band (`|flow| ≥ flowThreshold`):
|
||||
|
||||
```
|
||||
priority source note
|
||||
|
||||
1 ────● measured.flow real sensors on inflow/outflow
|
||||
│
|
||||
2 ────● predicted.flow manual q_in + pump-curve outputs
|
||||
│
|
||||
3 ────● level:measured dL/dt × surfaceArea
|
||||
│
|
||||
4 ────● level:predicted dL/dt of the integrator
|
||||
│
|
||||
5 ────● steady (fallback) warn, return { value: 0, source: null }
|
||||
```
|
||||
|
||||
Both **measured** and **predicted** variants are always computed and stored, regardless of which one drives control. The active source surfaces on Port 0 as `flowSource`, so operators can watch sensor drift (measured diverges from predicted), validate the volume integrator, and diagnose "which source was active when X happened?".
|
||||
|
||||
The inflow / outflow alias map is deliberately wide so measurements (`upstream`/`downstream`) and predicted-flow subscriptions (`in`/`out`) both feed the same aggregator:
|
||||
|
||||
```js
|
||||
flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
|
||||
```
|
||||
|
||||
## Control logic
|
||||
|
||||
The `pumpingStation` supports multiple control modes. Each mode is a **policy that maps basin state to demand (0-100 %)**. `levelbased` uses `minLevel`, `startLevel`, and `maxLevel`; other modes may use different thresholds or compute them dynamically.
|
||||
|
||||
The basin model owns the shared physical and safety references: pipe elevations, `dryRunLevel`, `highVolumeSafetyLevel`, and `overflowLevel`. `minLevel`, `startLevel`, and `maxLevel` are mode-specific and are documented with the mode diagrams, not the generic basin drawing.
|
||||
|
||||
Every mode gets its own page under [`modes/`](modes/README.md) with a consistent layout (inputs, threshold policy, demand formula, edge cases) so they can be compared side-by-side. Currently:
|
||||
|
||||
| Mode | Status | Page |
|
||||
|---|---|---|
|
||||
| `levelbased` | ✅ implemented | [modes/levelbased.md](modes/levelbased.md) |
|
||||
| `manual` | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased`, `pressureBased`, `percentageBased`, `powerBased`, `hybrid` | 🚧 placeholder in code | — |
|
||||
|
||||
See [`modes/README.md`](modes/README.md) for the index and page template.
|
||||
|
||||
## Safety controller
|
||||
|
||||
`_safetyController` runs **before** `_controlLogic` every tick. Two rules, deliberately asymmetric — *dry-run protects the pumps from running themselves into air*, *high-volume protection tries to preserve distance to actual overflow*.
|
||||
|
||||

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

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