Compare commits
7 Commits
main
...
basin-docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ebb31816 | ||
|
|
6ab585bcc2 | ||
|
|
d8490aa949 | ||
|
|
6b46a8a8f0 | ||
|
|
62bc73f2f9 | ||
|
|
de9a79b888 | ||
|
|
8a6ca1baeb |
@@ -5,5 +5,6 @@ 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 such as the level-linear `startLevel` demand ramp.
|
||||
- [`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": []
|
||||
}
|
||||
]
|
||||
@@ -11,6 +11,16 @@
|
||||
<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",
|
||||
@@ -22,8 +32,8 @@
|
||||
simulator: { value: false },
|
||||
basinVolume: { value: 1 }, // m³, total empty basin
|
||||
basinHeight: { value: 1 }, // m, floor to top
|
||||
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
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
|
||||
@@ -35,9 +45,11 @@
|
||||
temperatureReferenceDegC: { value: 15 },
|
||||
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
|
||||
enableDryRunProtection: { value: true },
|
||||
enableOverfillProtection: { value: true },
|
||||
enableHighVolumeSafety: { value: true },
|
||||
enableOverfillProtection: { value: true }, // deprecated alias
|
||||
dryRunThresholdPercent: { value: 2 },
|
||||
overfillThresholdPercent: { value: 98 },
|
||||
highVolumeSafetyThresholdPercent: { value: 98 },
|
||||
overfillThresholdPercent: { value: 98 }, // deprecated alias
|
||||
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
@@ -67,8 +79,14 @@
|
||||
distanceDescription: { value: "" },
|
||||
|
||||
// control strategy
|
||||
controlMode: { value: "none" },
|
||||
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 },
|
||||
@@ -86,296 +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-inflowLevel");
|
||||
document.getElementById("node-input-outflowLevel");
|
||||
document.getElementById("node-input-overflowLevel");
|
||||
document.getElementById("node-input-refHeight");
|
||||
document.getElementById("node-input-basinBottomRef");
|
||||
|
||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
||||
if (refHeightEl) {
|
||||
refHeightEl.value = this.refHeight || "NAP";
|
||||
}
|
||||
|
||||
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
|
||||
if (minHeightBasedOnEl) {
|
||||
minHeightBasedOnEl.value = this.minHeightBasedOn;
|
||||
}
|
||||
|
||||
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
|
||||
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
|
||||
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
|
||||
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) { return; }
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!this.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (overfillToggle && overfillPercent) {
|
||||
overfillToggle.checked = !!this.enableOverfillProtection;
|
||||
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
|
||||
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
|
||||
toggleInput(overfillToggle, overfillPercent);
|
||||
}
|
||||
|
||||
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
|
||||
if (timeLeftInput) {
|
||||
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
|
||||
? this.timeleftToFullOrEmptyThresholdSeconds
|
||||
: 0;
|
||||
}
|
||||
|
||||
// control mode toggle UI
|
||||
const toggleModeSections = (val) => {
|
||||
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||
const active = document.getElementById(`ps-mode-${val}`);
|
||||
if (active) active.style.display = '';
|
||||
};
|
||||
|
||||
const modeSelect = document.getElementById('node-input-controlMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = this.controlMode || 'none';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
const setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
setNumberField('node-input-startLevel', this.startLevel);
|
||||
setNumberField('node-input-minLevel', this.minLevel);
|
||||
setNumberField('node-input-maxLevel', this.maxLevel);
|
||||
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
|
||||
setNumberField('node-input-flowDeadband', this.flowDeadband);
|
||||
|
||||
// Interactive diagram: place every threshold line/input at its
|
||||
// proportional y on the tank, plus compute derived safety levels
|
||||
// (dryRunLevel, overfillLevel) that are shown both in the diagram
|
||||
// and next to the safety-% fields. Same formulas as
|
||||
// specificClass._validateThresholdOrdering.
|
||||
const DIAG = { topY: 40, botY: 380 };
|
||||
const fNum = (id) => {
|
||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
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.
|
||||
// The diagram is a schematic ordered list (value order is
|
||||
// preserved, but the y-positions are distributed with a
|
||||
// guaranteed minimum gap for readability), not a strictly
|
||||
// proportional rendering.
|
||||
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');
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
|
||||
// Derived safety levels (participate in the right-column stack)
|
||||
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
|
||||
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const ovfPct = fNum('overfillThresholdPercent');
|
||||
const ovf = fNum('overflowLevel');
|
||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
|
||||
|
||||
// Right-column stack. TWO anchors: basinHeight pinned at the
|
||||
// tank rim (top) and outflowLevel pinned at its proportional y
|
||||
// (bottom). Everything between is nudged to maintain a minimum
|
||||
// vertical gap via two passes — top-down from the rim, then
|
||||
// bottom-up from the outlet — so the dashed lines keep their
|
||||
// value-order and outlet stays near the floor where it belongs.
|
||||
const items = [
|
||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
|
||||
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
|
||||
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), 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;
|
||||
// Pass 1: top-down — push DOWN to maintain GAP; pinned items don't move
|
||||
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);
|
||||
}
|
||||
// Pass 2: bottom-up — push UP so outflow's pin propagates up the stack
|
||||
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 between adjacent thresholds (italic, centered).
|
||||
// Hidden if either bracketing threshold is missing, or the gap
|
||||
// is too small to read (< 14 px).
|
||||
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) < 14) {
|
||||
el.setAttribute('visibility', 'hidden'); return;
|
||||
}
|
||||
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||
el.setAttribute('visibility', 'visible');
|
||||
};
|
||||
placeZone('spare', 'overflowLevel', 'maxLevel');
|
||||
placeZone('sewage', 'maxLevel', 'startLevel');
|
||||
placeZone('buffer1', 'startLevel', 'minLevel');
|
||||
placeZone('buffer2', 'minLevel', 'dryRunLevel');
|
||||
// "Dead volume" sits inside the blue band between outflowLevel and the floor
|
||||
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');
|
||||
}
|
||||
|
||||
// Inlet arrow — sole item on the left, no stacking concerns
|
||||
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);
|
||||
}
|
||||
|
||||
// Dead-volume band: from the (possibly-nudged) outflow line
|
||||
// down to the floor. Use the nudged y so the band meets the
|
||||
// outflow line exactly.
|
||||
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));
|
||||
}
|
||||
|
||||
// dryRunLevel label text (derived, read-only)
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = dryLvl != null
|
||||
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
|
||||
: 'dryRunLevel ≈ — m (safety — from %)';
|
||||
|
||||
// Safety-section readouts (second view, beneath the diagram)
|
||||
const d1 = document.getElementById('derived-dryRunLevel');
|
||||
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
|
||||
const d2 = document.getElementById('derived-overfillLevel');
|
||||
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
|
||||
|
||||
// Ordering warning ribbon
|
||||
const warn = document.getElementById('ps-warning');
|
||||
const issues = [];
|
||||
const pairs = [
|
||||
['outflowLevel', 'inflowLevel', '<'],
|
||||
['inflowLevel', 'overflowLevel', '<'],
|
||||
['minLevel', 'startLevel', '<='],
|
||||
['startLevel', 'maxLevel', '<'],
|
||||
['maxLevel', 'overflowLevel', '<='],
|
||||
];
|
||||
for (const [a, b, op] of pairs) {
|
||||
const av = fNum(a), bv = fNum(b);
|
||||
if (av == null || bv == null) continue;
|
||||
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
|
||||
}
|
||||
if (warn) {
|
||||
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
|
||||
else { warn.setAttribute('visibility', 'hidden'); }
|
||||
}
|
||||
};
|
||||
['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
|
||||
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'].forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
|
||||
});
|
||||
setTimeout(redraw, 60);
|
||||
|
||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
||||
oneditprepare: function () {
|
||||
window.PSEditor.oneditprepare.call(this);
|
||||
},
|
||||
oneditsave: function () {
|
||||
const node = this;
|
||||
|
||||
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
//node specific
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
|
||||
node.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
|
||||
node.simulator = document.getElementById("node-input-simulator").checked;
|
||||
|
||||
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
|
||||
.forEach(field => {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
});
|
||||
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
||||
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
|
||||
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
|
||||
|
||||
// control strategy
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.minLevel = parseNum('node-input-minLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
node.flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
node.flowDeadband = parseNum('node-input-flowDeadband');
|
||||
|
||||
window.PSEditor.oneditsave.call(this);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -395,123 +128,204 @@
|
||||
<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). Enter values next to each line — the diagram scales to whatever you enter.</p>
|
||||
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Each input on the left controls a line in the diagram on the right — hover an input to highlight its line.</p>
|
||||
<div id="ps-basin-validation" style="display:none;color:#C0392B;font-size:11px;margin:0 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
|
||||
|
||||
<style>
|
||||
#ps-basin-diagram input[type=number] {
|
||||
width: 100%; height: 20px; box-sizing: border-box;
|
||||
font-size: 11px; padding: 1px 4px; margin: 0;
|
||||
border: 1px solid #ccc; border-radius: 3px; background: #fff;
|
||||
/* 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-basin-diagram input[type=number]:focus { outline: 1px solid #0c99d9; border-color: #0c99d9; }
|
||||
.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>
|
||||
|
||||
<svg id="ps-basin-diagram" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 430"
|
||||
style="display:block;width:100%;max-width:540px;margin:0 0 12px 0;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>
|
||||
<!--
|
||||
============================================================
|
||||
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.
|
||||
|
||||
<!-- Tank body -->
|
||||
<rect x="200" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
|
||||
<!-- Dead-volume band (y + height updated dynamically below outflowLevel) -->
|
||||
<rect id="ps-deadvol" x="201" width="118" fill="#AACCE0" />
|
||||
<!-- basinVolume — pinned above the rim -->
|
||||
<text id="ps-label-basinVolume" x="330" y="19" fill="#333" font-weight="600">basin volume</text>
|
||||
<foreignObject id="ps-fo-basinVolume" x="425" y="4" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinVolume" min="0" step="0.1" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-basinVolume" x="500" y="19" fill="#555">m³</text>
|
||||
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³")
|
||||
|
||||
<!-- Zone labels (mid-tank italic, positioned dynamically at midpoint between adjacent thresholds) -->
|
||||
<text id="ps-zone-spare" x="260" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare volume before spilling</text>
|
||||
<text id="ps-zone-sewage" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text>
|
||||
<text id="ps-zone-buffer1" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
|
||||
<text id="ps-zone-buffer2" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
|
||||
<text id="ps-zone-dead" x="260" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text>
|
||||
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>.
|
||||
|
||||
<!-- basinHeight — always at tank rim (y=40 in viewBox coords) -->
|
||||
<line id="ps-line-basinHeight" x1="195" y1="40" x2="325" y2="40" stroke="#333" stroke-width="1.5" />
|
||||
<text id="ps-label-basinHeight" x="330" y="44" fill="#333">basinHeight</text>
|
||||
<foreignObject id="ps-fo-basinHeight" x="425" y="29" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-basinHeight" x="500" y="44" fill="#555">m</text>
|
||||
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" />
|
||||
|
||||
<!-- overflowLevel -->
|
||||
<line id="ps-line-overflowLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-overflowLevel" x="330" fill="#C0392B">overflowLevel</text>
|
||||
<foreignObject id="ps-fo-overflowLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-overflowLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-overflowLevel" x="500" fill="#555">m</text>
|
||||
<!-- 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>
|
||||
|
||||
<!-- maxLevel -->
|
||||
<line id="ps-line-maxLevel" x1="195" x2="325" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-maxLevel" x="330" fill="#D68910">maxLevel</text>
|
||||
<foreignObject id="ps-fo-maxLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-maxLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-maxLevel" x="500" fill="#555">m</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>
|
||||
|
||||
<!-- startLevel -->
|
||||
<line id="ps-line-startLevel" x1="195" x2="325" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-startLevel" x="330" fill="#1E8449">startLevel</text>
|
||||
<foreignObject id="ps-fo-startLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-startLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-startLevel" x="500" fill="#555">m</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>
|
||||
|
||||
<!-- Inlet — arrow + input on the left -->
|
||||
<line id="ps-line-inflowLevel" x1="140" x2="200" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||
<text id="ps-label-inflowLevel" x="135" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
|
||||
<text id="ps-sub-inflowLevel" x="135" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
|
||||
<foreignObject id="ps-fo-inflowLevel" x="5" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-inflowLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-inflowLevel" x="80" fill="#555">m</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>
|
||||
|
||||
<!-- minLevel -->
|
||||
<line id="ps-line-minLevel" x1="195" x2="325" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" />
|
||||
<text id="ps-label-minLevel" x="330" fill="#6C3483">minLevel</text>
|
||||
<foreignObject id="ps-fo-minLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-minLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-minLevel" x="500" fill="#555">m</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>
|
||||
|
||||
<!-- dryRunLevel (derived, read-only) -->
|
||||
<line id="ps-line-dryRunLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
|
||||
<text id="ps-label-dryRunLevel" x="330" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel ≈ — m (safety — from %)</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>
|
||||
|
||||
<!-- Outlet — arrow on right, input below the threshold column -->
|
||||
<line id="ps-line-outflowLevel" x1="320" x2="360" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
|
||||
<text id="ps-label-outflowLevel" x="365" fill="#1F4E79" font-weight="bold">Outlet</text>
|
||||
<text id="ps-sub-outflowLevel" x="365" fill="#777" font-size="9">top of pipe</text>
|
||||
<foreignObject id="ps-fo-outflowLevel" x="425" width="70" height="22">
|
||||
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-outflowLevel" min="0" step="0.01" />
|
||||
</foreignObject>
|
||||
<text id="ps-unit-outflowLevel" x="500" fill="#555">m</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>
|
||||
|
||||
<!-- Floor / datum -->
|
||||
<line x1="195" y1="380" x2="325" y2="380" stroke="#000" stroke-width="2" />
|
||||
<text x="330" y="384" fill="#000">0 m (datum)</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>
|
||||
|
||||
<!-- Leader lines: shown when the input row had to be nudged off its threshold's ideal y -->
|
||||
<line id="ps-leader-basinHeight" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-overflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-maxLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-startLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-minLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-dryRunLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<line id="ps-leader-outflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
|
||||
<!-- 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>
|
||||
|
||||
<!-- Ordering-warning ribbon -->
|
||||
<text id="ps-warning" x="260" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
|
||||
</svg>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -519,39 +333,187 @@
|
||||
<div class="form-row">
|
||||
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
|
||||
<select id="node-input-controlMode">
|
||||
<option value="none">None / Manual</option>
|
||||
<option value="levelbased">Level-based</option>
|
||||
<option value="flowbased">Flow-based</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="ps-mode-levelbased" class="ps-mode-section">
|
||||
<p style="font-size:12px;color:#777;margin:0;">Level-based uses <code>minLevel</code> / <code>startLevel</code> / <code>maxLevel</code> from the diagram above.</p>
|
||||
<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 id="ps-mode-flowbased" class="ps-mode-section" style="display:none">
|
||||
<div class="form-row">
|
||||
<label for="node-input-flowSetpoint">Flow setpoint</label>
|
||||
<input type="number" id="node-input-flowSetpoint" placeholder="m3/h" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-flowDeadband">Deadband</label>
|
||||
<input type="number" id="node-input-flowDeadband" placeholder="m3/h" />
|
||||
</div>
|
||||
<div id="ps-mode-manual" class="ps-mode-section" style="display:none">
|
||||
<p style="font-size:12px;color:#777;margin:0;">Manual mode accepts external <code>Qd</code> demand commands and does not compute demand from basin level.</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Reference</h4>
|
||||
|
||||
<!-- Reference data -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
|
||||
<select id="node-input-minHeightBasedOn" style="width:60%;">
|
||||
<option value="inlet">Inlet Elevation</option>
|
||||
<option value="outlet">Outlet Elevation</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Reference data — basinBottomRef moved into basin side-panel above. -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
||||
<select id="node-input-refHeight" style="width:60%;">
|
||||
@@ -559,21 +521,11 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin floor above datum (m)</label>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Safety</h4>
|
||||
|
||||
<!-- Safety settings -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
|
||||
<input type="number" id="node-input-timeleftToFullOrEmptyThresholdSeconds" min="0" step="1" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableDryRunProtection">
|
||||
<i class="fa fa-shield"></i> Dry-run Protection
|
||||
@@ -588,16 +540,16 @@
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableOverfillProtection">
|
||||
<i class="fa fa-exclamation-triangle"></i> Overfill Protection
|
||||
<label for="node-input-enableHighVolumeSafety">
|
||||
<i class="fa fa-exclamation-triangle"></i> High-volume Safety
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Stop filling when approaching overflow</span>
|
||||
<input type="checkbox" id="node-input-enableHighVolumeSafety" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Act before physical overflow</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label>
|
||||
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-overfillLevel" style="margin-left:8px;color:#777;font-size:12px;">→ overfillLevel ≈ — m</span>
|
||||
<label for="node-input-highVolumeSafetyThresholdPercent" style="padding-left:20px;">High-volume Safety (%)</label>
|
||||
<input type="number" id="node-input-highVolumeSafetyThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
|
||||
<span id="derived-highVolumeSafetyLevel" style="margin-left:8px;color:#777;font-size:12px;">→ highVolumeSafetyLevel ≈ — m</span>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const nameOfNode = 'pumpingStation'; // this is the name of the node, it should match the file name and the node type in Node-RED
|
||||
const path = require('path');
|
||||
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
|
||||
const { MenuManager, configManager } = require('generalFunctions');
|
||||
|
||||
@@ -37,4 +38,16 @@ module.exports = function(RED) {
|
||||
}
|
||||
});
|
||||
|
||||
// Editor JS modules — loaded by pumpingStation.html via <script src=...> tags.
|
||||
// Files live in src/editor/. Filename is restricted to a safe charset to
|
||||
// prevent path-traversal.
|
||||
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
|
||||
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
|
||||
res.type('application/javascript');
|
||||
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
|
||||
if (err && !res.headersSent) res.status(404).send('// editor module not found');
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
@@ -49,6 +49,7 @@ module.exports = {
|
||||
| `max_level_bounded` | max level across the run must be `≤ value` |
|
||||
| `min_level_bounded` | min level across the run must be `≥ value` |
|
||||
| `max_demand_bounded` | max percControl must be `≤ value` |
|
||||
| `max_demand_gt` | max percControl must be `> value` |
|
||||
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
|
||||
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
|
||||
| `end_state_eq` | final record's `field` must equal `value` |
|
||||
|
||||
@@ -54,6 +54,10 @@ function evalExpectation(ex, records) {
|
||||
const v = Math.max(...demands);
|
||||
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
|
||||
}
|
||||
case 'max_demand_gt': {
|
||||
const v = Math.max(...demands);
|
||||
return { ok: v > ex.value, msg: `max demand = ${v.toFixed(0)} % (expected > ${ex.value})` };
|
||||
}
|
||||
case 'safety_trips_eq': {
|
||||
const n = records.filter((r) => r.safetyActive).length;
|
||||
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
//
|
||||
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
|
||||
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
|
||||
// startLevel and maxLevel) at roughly the point where demand matches
|
||||
// inflowLevel and maxLevel while filling) at roughly the point where demand matches
|
||||
// inflow. No safety trips should fire.
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased-steady',
|
||||
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
|
||||
durationSec: 1200,
|
||||
durationSec: 3600,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableOverfillProtection: true,
|
||||
overfillThresholdPercent: 98,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
@@ -44,7 +44,7 @@ module.exports = {
|
||||
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
|
||||
},
|
||||
};
|
||||
ps.calibratePredictedLevel(2.0); // start at the bottom of the RAMP zone
|
||||
ps.calibratePredictedLevel(2.0); // start at the mode start level, below the rising ramp
|
||||
},
|
||||
|
||||
inputs: (t, ps) => {
|
||||
@@ -55,6 +55,7 @@ module.exports = {
|
||||
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
|
||||
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
|
||||
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
|
||||
{ name: 'rising ramp engages after inlet level', type: 'max_demand_gt', value: 0 },
|
||||
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
|
||||
// level rises toward overflow then recedes.
|
||||
// Storm surge — inflow triples briefly, pumps should increase demand as
|
||||
// the level enters the rising ramp.
|
||||
//
|
||||
// Expectation: during the surge (t=300..600), demand reaches 100% and
|
||||
// level may transiently climb above maxLevel. Overflow safety should
|
||||
// fire if the surge overwhelms pump capacity; dry-run should not fire.
|
||||
// Expectation: during the surge (t=300..600), demand rises but remains
|
||||
// bounded. High-volume safety should fire if the surge overwhelms pump
|
||||
// capacity; dry-run should not fire.
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased-storm',
|
||||
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. Overfill safety may engage.',
|
||||
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. High-volume safety may engage.',
|
||||
durationSec: 1500,
|
||||
|
||||
config: {
|
||||
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 2,
|
||||
enableOverfillProtection: true,
|
||||
overfillThresholdPercent: 95,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 95,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
@@ -55,6 +55,6 @@ module.exports = {
|
||||
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
|
||||
// Level may exceed maxLevel transiently but must stay under basinHeight
|
||||
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
|
||||
{ name: 'demand saturates at 100% during surge', type: 'max_demand_bounded', value: 100 },
|
||||
{ name: 'demand remains bounded during surge', type: 'max_demand_bounded', value: 100 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,18 +12,18 @@ module.exports = {
|
||||
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, inletPipeDiameter: 0.4, outletPipeDiameter: 0.3 },
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'manual',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4 },
|
||||
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 50,
|
||||
enableOverfillProtection: false,
|
||||
overfillThresholdPercent: 98,
|
||||
enableHighVolumeSafety: false,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
},
|
||||
|
||||
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;
|
||||
};
|
||||
})();
|
||||
@@ -47,26 +47,46 @@ class nodeClass {
|
||||
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,
|
||||
maxLevel:uiConfig.maxLevel
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -220,6 +240,13 @@ class nodeClass {
|
||||
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'
|
||||
|
||||
@@ -45,7 +45,7 @@ class PumpingStation {
|
||||
// keep the basin geometry math unit-consistent.
|
||||
this.measurements = new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' }
|
||||
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3', overflowVolume: 'm3' }
|
||||
});
|
||||
|
||||
// --- Child registries ---
|
||||
@@ -105,6 +105,42 @@ class PumpingStation {
|
||||
// levelbased mode. Exposed in getOutput() for dashboards.
|
||||
this.percControl = 0;
|
||||
|
||||
// --- Level-armed hysteresis state (see _controlLevelBased) ---
|
||||
// _shiftArmed: true once up-curve output % crosses shiftArmPercent on
|
||||
// the way up. Cleared when level drops to startLevel.
|
||||
// _shiftHoldValue: captured on every filling→draining transition while
|
||||
// armed. The output stays at this value while level drops from the
|
||||
// flip point to shiftLevel; below shiftLevel it ramps to 0 % at
|
||||
// startLevel (linear or log shape).
|
||||
// _lastDirection: tracks the previous tick's direction so we can
|
||||
// detect filling→draining transitions. We don't update it on
|
||||
// 'steady' ticks so transitions through the dead-band are preserved.
|
||||
this._shiftArmed = false;
|
||||
this._shiftHoldValue = null;
|
||||
this._lastDirection = null;
|
||||
|
||||
// --- stopLevel hysteresis (Schmitt trigger) ---
|
||||
// Levelbased control uses two thresholds:
|
||||
// - startLevel: ramp foot AND rising-edge engage point. Demand
|
||||
// scales 0..100 % over [startLevel, maxLevel].
|
||||
// - stopLevel: falling-edge disengage point. Pumps stay engaged
|
||||
// (running at minimum flow) while level drains through
|
||||
// [stopLevel, startLevel]; below stopLevel they're turned off.
|
||||
//
|
||||
// _stopHystRunning is the engaged-state flag: flips TRUE when level
|
||||
// crosses startLevel on the way up, FALSE when level crosses stopLevel
|
||||
// on the way down. While engaged AND level < startLevel (i.e. the
|
||||
// basin is draining through the dead band) the controller emits a
|
||||
// small keep-alive percControl so MGC keeps a single pump running
|
||||
// until level reaches stopLevel. Without this, percControl=0 in the
|
||||
// dead band would let MGC turn the pump off, the basin would refill,
|
||||
// and the pump would oscillate at startLevel instead of running for
|
||||
// a full drain stroke.
|
||||
//
|
||||
// Editor preview also reads _stopHystRunning to shade the hysteresis
|
||||
// band; runtime semantics are now explicit (no longer "bookkeeping").
|
||||
this._stopHystRunning = false;
|
||||
|
||||
// --- Flow dead-band ---
|
||||
// flowThreshold (m3/s) prevents control actions on noise.
|
||||
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
|
||||
@@ -271,6 +307,11 @@ class PumpingStation {
|
||||
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
|
||||
}
|
||||
|
||||
setManualOutflow(value, timestamp = Date.now(), unit) {
|
||||
const num = Number(value);
|
||||
this.measurements.type('flow').variant('predicted').position('out').child('manual-qout').value(num, timestamp, unit);
|
||||
}
|
||||
|
||||
/* --------------------------- Tick / Control --------------------------- */
|
||||
|
||||
tick() {
|
||||
@@ -314,7 +355,7 @@ class PumpingStation {
|
||||
_controlLogic(direction) {
|
||||
switch (this.mode) {
|
||||
case 'levelbased':
|
||||
this._controlLevelBased();
|
||||
this._controlLevelBased(direction);
|
||||
break;
|
||||
case 'flowbased':
|
||||
this._controlFlowBased?.();
|
||||
@@ -326,8 +367,9 @@ class PumpingStation {
|
||||
}
|
||||
}
|
||||
|
||||
async _controlLevelBased() {
|
||||
const { startLevel, minLevel } = this.config.control.levelbased;
|
||||
async _controlLevelBased(direction) {
|
||||
const cfg = this.config.control.levelbased;
|
||||
const { startLevel, minLevel } = cfg;
|
||||
const levelUnit = this.measurements.getUnit('level');
|
||||
|
||||
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
|
||||
@@ -336,37 +378,167 @@ class PumpingStation {
|
||||
return;
|
||||
}
|
||||
|
||||
// Level-based pump control via MGC — three zones:
|
||||
// Level-based pump control via MGC. See wiki/modes/levelbased.md.
|
||||
//
|
||||
// Always:
|
||||
// level < minLevel → STOP (unconditional MGC shutdown)
|
||||
// minLevel ≤ level < startLevel → DEAD ZONE (hysteresis; keep last cmd)
|
||||
// level ≥ startLevel → RUN (linear [startLevel..maxLevel] → [0..100 %])
|
||||
// See wiki/modes/levelbased.md for the full transfer-function diagram.
|
||||
// level < inflowLevel → 0 % (HOLD zone, pumps idle)
|
||||
// level in [inflow..max] → up curve 0..100 % (linear or log)
|
||||
// level > maxLevel → 100 % (MGC clamps internally)
|
||||
//
|
||||
// With enableShiftedRamp (hysteresis):
|
||||
// When up-curve % rises past shiftArmPercent → ARMED.
|
||||
// On the next filling→draining transition while armed → capture
|
||||
// hold = current up-curve %.
|
||||
// While armed AND draining:
|
||||
// level >= shiftLevel → output = hold (held)
|
||||
// level in [start..shift] → output ramps hold→0 % over the range
|
||||
// level < startLevel → output = 0 %
|
||||
// While armed AND filling/steady → output = up curve (resets hold).
|
||||
// Disarms only when level <= startLevel.
|
||||
|
||||
// STOP — below minLevel, always shut down regardless of direction.
|
||||
if (level < minLevel) {
|
||||
this.percControl = 0;
|
||||
this._shiftHoldValue = null;
|
||||
this._shiftArmed = false;
|
||||
this._stopHystRunning = false;
|
||||
this._lastDirection = direction;
|
||||
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
|
||||
// DEAD ZONE — between minLevel and startLevel, do nothing.
|
||||
// Pumps that are running keep their last command; pumps that
|
||||
// are off stay off. This prevents rapid on/off cycling.
|
||||
if (level < startLevel) {
|
||||
// stopLevel hysteresis (Schmitt trigger).
|
||||
// _stopHystRunning becomes TRUE on rising edge at startLevel
|
||||
// FALSE on falling edge at stopLevel
|
||||
// While engaged AND level < startLevel (basin draining through the
|
||||
// dead band), the controller emits a small keep-alive percControl so
|
||||
// a single pump keeps running until level reaches stopLevel. Without
|
||||
// hysteresis the pump would oscillate at startLevel because the
|
||||
// up-curve goes through 0 there.
|
||||
const stopLvl = Number(cfg.stopLevel);
|
||||
const stopThresholdActive = Number.isFinite(stopLvl) && stopLvl >= 0 && stopLvl < cfg.maxLevel;
|
||||
|
||||
if (stopThresholdActive && level <= stopLvl) {
|
||||
// Hard off: drained past stopLevel.
|
||||
this.percControl = 0;
|
||||
this._stopHystRunning = false;
|
||||
this._lastDirection = direction;
|
||||
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
// Update Schmitt-trigger engaged state.
|
||||
if (stopThresholdActive) {
|
||||
if (!this._stopHystRunning && level >= startLevel) this._stopHystRunning = true;
|
||||
// disengage on falling edge is handled by the `level <= stopLvl` block above.
|
||||
} else {
|
||||
// No stopLevel configured → no hysteresis; engaged only while level >= startLevel.
|
||||
this._stopHystRunning = level >= startLevel;
|
||||
}
|
||||
|
||||
// Up-curve value. Foot stays at startLevel (per the user-set demand
|
||||
// ramp), top is maxLevel. Below startLevel the curve gives 0 %; above
|
||||
// maxLevel it saturates at 100 %.
|
||||
const rampFoot = startLevel;
|
||||
const upPct = this._scaleLevelToFlowPercent(level, rampFoot, cfg.maxLevel);
|
||||
|
||||
// Update arming flag.
|
||||
if (cfg.enableShiftedRamp) {
|
||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||
if (!this._shiftArmed && upPct >= armPct) {
|
||||
this._shiftArmed = true;
|
||||
this.logger.debug(`Shift armed: upPct=${upPct} >= ${armPct}`);
|
||||
}
|
||||
} else {
|
||||
this._shiftArmed = false;
|
||||
}
|
||||
if (level <= startLevel) {
|
||||
this._shiftArmed = false;
|
||||
this._shiftHoldValue = null;
|
||||
}
|
||||
|
||||
// Capture hold on filling→draining transition while armed.
|
||||
if (cfg.enableShiftedRamp && this._shiftArmed) {
|
||||
if (this._lastDirection !== 'draining' && direction === 'draining') {
|
||||
this._shiftHoldValue = upPct;
|
||||
this.logger.debug(`Shift hold captured: ${upPct} % at level=${level}`);
|
||||
} else if (direction === 'filling') {
|
||||
// Returning to filling clears any captured hold; the next drain
|
||||
// transition will recapture from the up curve.
|
||||
this._shiftHoldValue = null;
|
||||
}
|
||||
}
|
||||
if (direction === 'filling' || direction === 'draining') {
|
||||
this._lastDirection = direction;
|
||||
}
|
||||
|
||||
// Compute output.
|
||||
let percControl;
|
||||
const inDrainingHold = cfg.enableShiftedRamp && this._shiftArmed
|
||||
&& direction === 'draining' && this._shiftHoldValue != null;
|
||||
|
||||
if (!inDrainingHold) {
|
||||
// Up curve: 0 % below the ramp foot (startLevel), scaled
|
||||
// startLevel..maxLevel → 0..100 %, saturates above maxLevel.
|
||||
// While engaged via the stopLevel Schmitt trigger AND level is
|
||||
// inside the dead band [stopLevel, startLevel], emit a small
|
||||
// keep-alive value so MGC's normalized scaling resolves to flow.min
|
||||
// (a single pump at minimum stable speed) and the basin actually
|
||||
// drains. Configurable via levelbased.deadZoneKeepAlivePercent
|
||||
// (default 1%). Ramp foot stays at startLevel — keep-alive is a
|
||||
// separate "engaged in dead band" signal, not a shifted ramp.
|
||||
if (level < rampFoot) {
|
||||
if (stopThresholdActive && this._stopHystRunning) {
|
||||
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
||||
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||
percControl = Math.max(0, keepAlive);
|
||||
} else {
|
||||
percControl = 0;
|
||||
}
|
||||
} else {
|
||||
percControl = Math.max(0, upPct);
|
||||
}
|
||||
} else {
|
||||
const hold = this._shiftHoldValue;
|
||||
const shift = cfg.shiftLevel;
|
||||
if (!Number.isFinite(shift) || shift <= startLevel) {
|
||||
// Bad config — fall back to up curve.
|
||||
percControl = Math.max(0, upPct);
|
||||
} else if (level >= shift) {
|
||||
percControl = hold;
|
||||
} else if (level > startLevel) {
|
||||
// Ramp from (shiftLevel, hold) down to (startLevel, 0).
|
||||
// Use the same curve shape (linear/log) as the up curve, scaled to
|
||||
// peak at hold% at level=shiftLevel.
|
||||
const x = (level - startLevel) / (shift - startLevel);
|
||||
const shaped = this._curveShape(x);
|
||||
percControl = Math.max(0, hold * shaped);
|
||||
} else {
|
||||
percControl = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// RUN — above startLevel, compute demand and forward to MGC.
|
||||
// _scaleLevelToFlowPercent maps [startLevel..maxLevel] → [0..100].
|
||||
// Above maxLevel the MGC clamps internally.
|
||||
const rawPercControl = this._scaleLevelToFlowPercent(level);
|
||||
const percControl = Math.max(0, rawPercControl);
|
||||
this.percControl = percControl;
|
||||
this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`);
|
||||
this.logger.debug(
|
||||
`Level-based: level=${level} dir=${direction} armed=${this._shiftArmed} hold=${this._shiftHoldValue} pct=${percControl}`
|
||||
);
|
||||
|
||||
await this._applyMachineGroupLevelControl(percControl);
|
||||
}
|
||||
|
||||
// Apply the configured curve shape to a normalized x in [0,1].
|
||||
// Returns shaped value in [0,1]. Linear by default; log when curveType
|
||||
// is 'log' (with logCurveFactor).
|
||||
_curveShape(x) {
|
||||
const { curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
|
||||
const clamped = Math.max(0, Math.min(1, x));
|
||||
if (curveType === 'log') {
|
||||
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
|
||||
? Number(logCurveFactor) : 9;
|
||||
return Math.log1p(factor * clamped) / Math.log1p(factor);
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
_controlFlowBased() {
|
||||
// placeholder for flow-based logic
|
||||
}
|
||||
@@ -380,6 +552,16 @@ class PumpingStation {
|
||||
*/
|
||||
async forwardDemandToChildren(demand) {
|
||||
this.logger.info(`Manual demand forwarded: ${demand}`);
|
||||
// Manual-mode explicit stop: MGC's handleInput now treats demand=0 as
|
||||
// "hold current pump states" so the levelbased stopLevel hysteresis
|
||||
// works. In manual mode the operator setting Qd=0 should still mean
|
||||
// "stop now", so we issue an explicit turnOff and short-circuit.
|
||||
if (Number(demand) <= 0) {
|
||||
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
|
||||
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Forward to machine groups (MGC)
|
||||
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
|
||||
await Promise.all(
|
||||
@@ -503,10 +685,26 @@ class PumpingStation {
|
||||
return null;
|
||||
}
|
||||
|
||||
_scaleLevelToFlowPercent(level) {
|
||||
const { startLevel, maxLevel } = this.config.control.levelbased;
|
||||
this.logger.debug(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
|
||||
return this.interpolate.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
|
||||
// (legacy _levelBasedRampStart/_levelBasedRampTop/_updateShiftArmed
|
||||
// helpers were removed in favour of the inline state machine in
|
||||
// _controlLevelBased — see that method's doc block.)
|
||||
|
||||
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
|
||||
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
|
||||
const start = Number.isFinite(rampStartLevel) ? rampStartLevel : this.config.control.levelbased.startLevel;
|
||||
const top = Number.isFinite(rampTopLevel) ? rampTopLevel : maxLevel;
|
||||
if (!Number.isFinite(level) || !Number.isFinite(start) || !Number.isFinite(top)) return 0;
|
||||
if (top <= start) return level >= top ? 100 : 0;
|
||||
|
||||
const x = Math.max(0, Math.min(1, (level - start) / (top - start)));
|
||||
if (curveType === 'log') {
|
||||
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
|
||||
? Number(logCurveFactor)
|
||||
: 9;
|
||||
return 100 * (Math.log1p(factor * x) / Math.log1p(factor));
|
||||
}
|
||||
|
||||
return x * 100;
|
||||
}
|
||||
|
||||
_levelRate(variant) {
|
||||
@@ -525,24 +723,100 @@ class PumpingStation {
|
||||
const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below
|
||||
const now = Date.now();
|
||||
|
||||
// The synthetic spill flow lives at its OWN position ('overflow') —
|
||||
// not as a child of 'out'. That keeps it out of the operational-outflow
|
||||
// sum here (which only sees pumps + downstream measurements), so no
|
||||
// self-subtraction is needed. _selectBestNetFlow folds it back in for
|
||||
// net-flow balance while pinned at overflow.
|
||||
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||
const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
|
||||
if (!this._predictedFlowState) {
|
||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: now };
|
||||
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||
}
|
||||
|
||||
const timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||||
const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0);
|
||||
const netVolumeChange = deltaSeconds > 0 ? (inflow - outflow) * deltaSeconds : 0;
|
||||
const netVolumeChange = deltaSeconds > 0 ? (inflow - outflowReal) * deltaSeconds : 0;
|
||||
|
||||
const volumeSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||
const currentVolume = volumeSeries.getCurrentValue('m3');
|
||||
// Read currentVolume via a fresh chain — MeasurementContainer's chain
|
||||
// methods mutate a shared cursor, so any later chain into a different
|
||||
// type/variant invalidates a saved reference. We re-resolve every read
|
||||
// and write below for the same reason.
|
||||
const currentVolume = this.measurements
|
||||
.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
|
||||
const nextVolume = currentVolume + netVolumeChange;
|
||||
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
|
||||
|
||||
volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); //olifant
|
||||
// Predicted-volume bounds.
|
||||
// Upper (hard physical): maxVolAtOverflow — past this the basin spills
|
||||
// over the weir; predicted level pins at overflowLevel and the
|
||||
// excess is tracked as overflow volume + spill flow.
|
||||
// Lower (operational): dryRunSafetyVol — where pumps must stop. Only
|
||||
// clamps on transition from above; a basin seeded below (e.g.
|
||||
// startup-from-empty) is left alone so it can fill from 0.
|
||||
// Lower (hard physical): 0 — basin cannot hold negative water. Always
|
||||
// clamps. Without this, a seeded-low basin under continued
|
||||
// net-outflow integrates volume arbitrarily negative (the level
|
||||
// output looks fine because _calcLevelFromVolume floors at 0,
|
||||
// masking the underlying drift).
|
||||
const safety = this._computeSafetyPoints();
|
||||
const upperClamp = this.basin.maxVolAtOverflow;
|
||||
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
|
||||
|
||||
const proposedVolume = currentVolume + netVolumeChange;
|
||||
let nextVolume = proposedVolume;
|
||||
let overflowIncrement = 0;
|
||||
let underflowIncrement = 0;
|
||||
if (proposedVolume > upperClamp) {
|
||||
overflowIncrement = proposedVolume - upperClamp;
|
||||
nextVolume = upperClamp;
|
||||
} else if (proposedVolume < lowerClamp && currentVolume >= lowerClamp) {
|
||||
nextVolume = lowerClamp;
|
||||
}
|
||||
if (nextVolume < 0) {
|
||||
underflowIncrement = -nextVolume;
|
||||
nextVolume = 0;
|
||||
}
|
||||
|
||||
// Synthetic spill flow at position 'overflow'.
|
||||
// While pinned at upper bound with continuing net-positive inflow, the
|
||||
// weir is carrying away (inflow − outflowReal). _selectBestNetFlow folds
|
||||
// this into the outflow side so the predicted net-flow balance reads ~0
|
||||
// (matches the level-pinned reality).
|
||||
let spillRate = 0;
|
||||
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
|
||||
spillRate = inflow - outflowReal;
|
||||
}
|
||||
this.measurements
|
||||
.type('flow').variant('predicted').position('overflow')
|
||||
.value(spillRate, writeTimestamp, 'm3/s').unit('m3/s');
|
||||
|
||||
// Cumulative overflow volume — for compliance reporting via InfluxDB.
|
||||
if (overflowIncrement > 0) {
|
||||
const prevCumulative = this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment')
|
||||
.value(prevCumulative + overflowIncrement, writeTimestamp, 'm3').unit('m3');
|
||||
}
|
||||
|
||||
// Cumulative integrator underflow — diagnostic, NOT compliance.
|
||||
// A nonzero value means the predicted-volume integrator tried to go
|
||||
// below the physical floor (negative water). Root causes are usually
|
||||
// upstream: outflow over-reported (sensor drift, pump curve too
|
||||
// optimistic) or an inflow source missing from the measurement set.
|
||||
if (underflowIncrement > 0) {
|
||||
const prevUnderflow = this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment')
|
||||
.value(prevUnderflow + underflowIncrement, writeTimestamp, 'm3').unit('m3');
|
||||
}
|
||||
|
||||
this.measurements
|
||||
.type('volume').variant('predicted').position('atequipment')
|
||||
.value(nextVolume, writeTimestamp, 'm3').unit('m3');
|
||||
|
||||
const nextLevel = this._calcLevelFromVolume(nextVolume);
|
||||
this.measurements
|
||||
@@ -566,7 +840,7 @@ class PumpingStation {
|
||||
.position('atequipment')
|
||||
.value(percent, writeTimestamp, '%');
|
||||
|
||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTimestamp };
|
||||
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTimestamp };
|
||||
}
|
||||
|
||||
_selectBestNetFlow() {
|
||||
@@ -578,7 +852,12 @@ class PumpingStation {
|
||||
if (!bucket || Object.keys(bucket).length === 0) continue;
|
||||
|
||||
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
|
||||
const outflow = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
|
||||
const outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
|
||||
// Fold synthetic spill (position 'overflow') into the outflow side.
|
||||
// It only exists for the predicted variant and only while pinned, so
|
||||
// for measured this is 0.
|
||||
const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0;
|
||||
const outflow = outflowReal + spill;
|
||||
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
|
||||
|
||||
const net = inflow - outflow;
|
||||
@@ -586,11 +865,28 @@ class PumpingStation {
|
||||
return { value: net, source: variant, direction: this._deriveDirection(net) };
|
||||
}
|
||||
|
||||
// Fallback: level trend
|
||||
// Fallback: level trend.
|
||||
// When level pins at overflow, dL/dt collapses to 0 and the level-rate
|
||||
// method loses the inflow signal — but flow IS still moving (in → spill).
|
||||
// In that case we hold the last known non-zero net-flow so dashboards
|
||||
// keep showing roughly what's coming in until level starts dropping.
|
||||
for (const variant of this.levelVariants) {
|
||||
const rate = this._levelRate(variant);
|
||||
if (!Number.isFinite(rate)) continue;
|
||||
const netFlow = rate * this.basin.surfaceArea;
|
||||
|
||||
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
|
||||
const pinnedAtOverflow = Number.isFinite(lvl)
|
||||
&& Number.isFinite(this.basin.overflowLevel)
|
||||
&& lvl >= this.basin.overflowLevel - 1e-9;
|
||||
const rateNearZero = Math.abs(rate) < 1e-9;
|
||||
|
||||
let netFlow = rate * this.basin.surfaceArea;
|
||||
if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) {
|
||||
netFlow = this._lastLevelRateNetFlow;
|
||||
} else if (!rateNearZero) {
|
||||
this._lastLevelRateNetFlow = netFlow;
|
||||
}
|
||||
|
||||
return { value: netFlow, source: `level:${variant}`, direction: this._deriveDirection(netFlow) };
|
||||
}
|
||||
|
||||
@@ -634,7 +930,7 @@ class PumpingStation {
|
||||
* Only a manual override or emergency can restart them.
|
||||
* safetyControllerActive = true → blocks _controlLogic.
|
||||
*
|
||||
* 2. ABOVE overflow level (overfill): pumps CANNOT stop.
|
||||
* 2. ABOVE high-volume safety level: pumps CANNOT stop.
|
||||
* Shuts down UPSTREAM equipment only (stop more water coming in).
|
||||
* Does NOT shut down downstream pumps or machine groups — they
|
||||
* must keep draining. Does NOT set safetyControllerActive — the
|
||||
@@ -642,7 +938,7 @@ class PumpingStation {
|
||||
* dictated by the current level (which will be >100% near overflow,
|
||||
* meaning all pumps at maximum via the normal demand curve).
|
||||
* Only a manual override or emergency stop can shut pumps during
|
||||
* an overfill event.
|
||||
* a high-volume or overflowing event.
|
||||
*/
|
||||
_safetyController(remainingTime, direction) {
|
||||
this.safetyControllerActive = false;
|
||||
@@ -660,21 +956,34 @@ class PumpingStation {
|
||||
enableDryRunProtection,
|
||||
dryRunThresholdPercent,
|
||||
enableOverfillProtection,
|
||||
overfillThresholdPercent,
|
||||
enableHighVolumeSafety,
|
||||
timeleftToFullOrEmptyThresholdSeconds
|
||||
} = this.config.safety || {};
|
||||
|
||||
const dryRunEnabled = Boolean(enableDryRunProtection);
|
||||
const overfillEnabled = Boolean(enableOverfillProtection);
|
||||
const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
|
||||
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||
const triggerHighVol = this.basin.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
|
||||
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100));
|
||||
const safety = this._computeSafetyPoints();
|
||||
const triggerHighVol = safety.highVolumeSafetyVol;
|
||||
const triggerLowVol = safety.dryRunSafetyVol;
|
||||
const currentLevel = this._pickVariant('level', this.levelVariants, 'atequipment', 'm');
|
||||
|
||||
this.safetyState = {
|
||||
dryRunActive: false,
|
||||
highVolumeActive: false,
|
||||
isOverflowing: Number.isFinite(currentLevel) && currentLevel >= this.basin.overflowLevel,
|
||||
dryRunLevel: safety.dryRunLevel,
|
||||
highVolumeSafetyLevel: safety.highVolumeSafetyLevel,
|
||||
dryRunSafetyVol: safety.dryRunSafetyVol,
|
||||
highVolumeSafetyVol: safety.highVolumeSafetyVol
|
||||
};
|
||||
|
||||
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
|
||||
if (direction === 'draining') {
|
||||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||||
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
|
||||
if (timeTriggered || dryRunTriggered) {
|
||||
this.safetyState.dryRunActive = true;
|
||||
// Shut down all downstream equipment — pumps must stop.
|
||||
Object.values(this.machines).forEach((machine) => {
|
||||
const pos = machine?.config?.functionality?.positionVsParent;
|
||||
@@ -700,8 +1009,9 @@ class PumpingStation {
|
||||
// running to maintain pump demand.
|
||||
if (direction === 'filling') {
|
||||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||||
const overfillTriggered = overfillEnabled && vol > triggerHighVol;
|
||||
if (timeTriggered || overfillTriggered) {
|
||||
const highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
|
||||
if (timeTriggered || highVolumeTriggered) {
|
||||
this.safetyState.highVolumeActive = true;
|
||||
// Shut down UPSTREAM only — stop more water coming in.
|
||||
Object.values(this.machines).forEach((machine) => {
|
||||
const pos = machine?.config?.functionality?.positionVsParent;
|
||||
@@ -713,7 +1023,7 @@ class PumpingStation {
|
||||
// NOTE: machine groups (downstream pumps) are NOT shut down.
|
||||
// They must keep draining to prevent overflow from worsening.
|
||||
this.logger.warn(
|
||||
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
|
||||
`High-volume safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
|
||||
);
|
||||
// NOTE: safetyControllerActive is NOT set — level control
|
||||
// keeps commanding pumps at maximum demand.
|
||||
@@ -721,6 +1031,25 @@ class PumpingStation {
|
||||
}
|
||||
}
|
||||
|
||||
_computeSafetyPoints() {
|
||||
const safety = this.config.safety || {};
|
||||
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||||
const highPct = Number(
|
||||
safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent ?? 98
|
||||
) || 0;
|
||||
const dryRunSafetyVol = this.basin.minVol * (1 + (dryRunPct / 100));
|
||||
const dryRunLevel = this._calcLevelFromVolume(dryRunSafetyVol);
|
||||
const highVolumeSafetyVol = this.basin.maxVolAtOverflow * (highPct / 100);
|
||||
const highVolumeSafetyLevel = this._calcLevelFromVolume(highVolumeSafetyVol);
|
||||
|
||||
return {
|
||||
dryRunSafetyVol,
|
||||
dryRunLevel,
|
||||
highVolumeSafetyVol,
|
||||
highVolumeSafetyLevel
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------- Basin --------------------------- */
|
||||
|
||||
/**
|
||||
@@ -740,9 +1069,11 @@ class PumpingStation {
|
||||
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
|
||||
const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity
|
||||
const heightBasin = this.config.basin.height; // m — floor to rim
|
||||
const inflowLevel = this.config.basin.inflowLevel; // m — sewer feed pipe centre
|
||||
const outflowLevel = this.config.basin.outflowLevel; // m — pump suction pipe centre
|
||||
const inflowLevel = this.config.basin.inflowLevel; // m — inlet pipe bottom/invert
|
||||
const outflowLevel = this.config.basin.outflowLevel; // m — outlet/pump suction pipe top
|
||||
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest
|
||||
const inletPipeDiameter = this.config.basin.inletPipeDiameter;
|
||||
const outletPipeDiameter = this.config.basin.outletPipeDiameter;
|
||||
|
||||
// Constant cross-section assumption: volume = level × area
|
||||
const surfaceArea = volEmptyBasin / heightBasin;
|
||||
@@ -762,6 +1093,8 @@ class PumpingStation {
|
||||
inflowLevel,
|
||||
outflowLevel,
|
||||
overflowLevel,
|
||||
inletPipeDiameter,
|
||||
outletPipeDiameter,
|
||||
surfaceArea,
|
||||
maxVol,
|
||||
maxVolAtOverflow,
|
||||
@@ -786,26 +1119,21 @@ class PumpingStation {
|
||||
*
|
||||
* Strict invariants (bottom → top):
|
||||
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
|
||||
* dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||
*
|
||||
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
|
||||
* dryRunLevel and highVolumeSafetyLevel are DERIVED — computed
|
||||
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
|
||||
* overfillThresholdPercent/100 in the safety layer. Validating those
|
||||
* highVolumeSafetyThresholdPercent/100 in the safety layer. Validating those
|
||||
* catches config that would let minLevel sit below where safety has
|
||||
* already force-stopped the pumps (no-op control band).
|
||||
*/
|
||||
_validateThresholdOrdering() {
|
||||
const basin = this.basin;
|
||||
const lvl = this.config.control?.levelbased || {};
|
||||
const safety = this.config.safety || {};
|
||||
|
||||
// Derived safety trigger levels (level-space equivalents of what
|
||||
// _safetyController does in volume-space).
|
||||
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||||
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
|
||||
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
|
||||
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
|
||||
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
|
||||
const safetyPoints = this._computeSafetyPoints();
|
||||
const dryRunLevel = safetyPoints.dryRunLevel;
|
||||
const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
|
||||
|
||||
const checks = [
|
||||
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||
@@ -813,8 +1141,10 @@ class PumpingStation {
|
||||
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
|
||||
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
|
||||
['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
||||
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||
];
|
||||
|
||||
const issues = [];
|
||||
@@ -844,21 +1174,44 @@ class PumpingStation {
|
||||
|
||||
getOutput() {
|
||||
const output = this.measurements.getFlattenedOutput();
|
||||
const safety = this._computeSafetyPoints();
|
||||
output.direction = this.state.direction;
|
||||
output.flowSource = this.state.flowSource;
|
||||
output.timeleft = this.state.seconds;
|
||||
output.volEmptyBasin = this.basin.volEmptyBasin;
|
||||
output.inflowLevel = this.basin.inflowLevel;
|
||||
output.outflowLevel = this.basin.outflowLevel;
|
||||
output.overflowLevel = this.basin.overflowLevel;
|
||||
output.inletPipeDiameter = this.basin.inletPipeDiameter;
|
||||
output.outletPipeDiameter = this.basin.outletPipeDiameter;
|
||||
output.maxVol = this.basin.maxVol;
|
||||
output.minVol = this.basin.minVol;
|
||||
output.maxVolAtOverflow = this.basin.maxVolAtOverflow;
|
||||
output.minVolAtOutflow = this.basin.minVolAtOutflow;
|
||||
output.minVolAtInflow = this.basin.minVolAtInflow;
|
||||
output.minHeightBasedOn = this.basin.minHeightBasedOn;
|
||||
output.dryRunLevel = safety.dryRunLevel;
|
||||
output.dryRunSafetyVol = safety.dryRunSafetyVol;
|
||||
output.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
|
||||
output.highVolumeSafetyVol = safety.highVolumeSafetyVol;
|
||||
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
|
||||
output.safetyState = this._deriveSafetyState();
|
||||
output.percControl = this.percControl;
|
||||
output.predictedOverflowVolume = this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
output.predictedOverflowRate = this.measurements
|
||||
.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0;
|
||||
output.predictedUnderflowVolume = this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
return output;
|
||||
}
|
||||
|
||||
_deriveSafetyState() {
|
||||
if (this.safetyState?.isOverflowing) return 'overflowing';
|
||||
if (this.safetyState?.highVolumeActive) return 'highVolume';
|
||||
if (this.safetyState?.dryRunActive) return 'dryRun';
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PumpingStation;
|
||||
@@ -887,15 +1240,19 @@ if (require.main === module) {
|
||||
height: 10,
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 3.2
|
||||
overflowLevel: 3.2,
|
||||
inletPipeDiameter: 0.4,
|
||||
outletPipeDiameter: 0.3
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 0
|
||||
basinBottomRef: 0,
|
||||
minHeightBasedOn: 'outlet'
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection:false,
|
||||
enableOverfillProtection:false
|
||||
enableHighVolumeSafety:false,
|
||||
highVolumeSafetyThresholdPercent: 98
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -27,6 +27,8 @@ function makeConfig(overrides = {}) {
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 4.5,
|
||||
inletPipeDiameter: 0.4,
|
||||
outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
@@ -36,12 +38,13 @@ function makeConfig(overrides = {}) {
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false,
|
||||
enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
@@ -80,6 +83,10 @@ test('Basin geometry — derived values', async (t) => {
|
||||
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||
assert.equal(ps2.basin.minVol, 30);
|
||||
});
|
||||
await t.test('pipe diameters are part of basin contract', () => {
|
||||
assert.equal(ps.basin.inletPipeDiameter, 0.4);
|
||||
assert.equal(ps.basin.outletPipeDiameter, 0.3);
|
||||
});
|
||||
});
|
||||
|
||||
test('Level ↔ volume roundtrip', async (t) => {
|
||||
@@ -131,6 +138,17 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
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 },
|
||||
@@ -223,20 +241,22 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
assert.equal(turnOffCalls, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
|
||||
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 () => { throw new Error('should not be called in dead zone'); },
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 42); // unchanged
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(demands[0], 0);
|
||||
});
|
||||
|
||||
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
@@ -244,14 +264,144 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
// lerp(3, [2,4], [0,100]) = 50
|
||||
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'] = {
|
||||
@@ -275,6 +425,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
|
||||
assert.equal(out.maxVolAtOverflow, 45);
|
||||
assert.equal(out.minVolAtInflow, 30);
|
||||
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||
assert.equal(out.inletPipeDiameter, 0.4);
|
||||
assert.equal(out.outletPipeDiameter, 0.3);
|
||||
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
|
||||
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 1e-9);
|
||||
});
|
||||
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||
const out = ps.getOutput();
|
||||
@@ -293,3 +447,155 @@ test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
|
||||
assert.ok(Math.abs(v - 0.05) < 1e-9);
|
||||
});
|
||||
|
||||
// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
|
||||
// tracks any excess as cumulative `overflowVolume` plus a synthetic
|
||||
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
|
||||
// pinned. We drive ticks manually with monotonic timestamps to keep tests
|
||||
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
|
||||
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
|
||||
}));
|
||||
// Seed predicted volume just below the spill point.
|
||||
// maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
|
||||
const t0 = 1_700_000_000_000;
|
||||
ps.calibratePredictedVolume(44, t0);
|
||||
// Heavy inflow, no real outflow (no pumps wired).
|
||||
ps.setManualInflow(2, t0, 'm3/s'); // 2 m³/s, dt=1s → 2 m³/tick
|
||||
|
||||
await t.test('first overflow tick clamps volume and records spill increment', () => {
|
||||
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45); // pinned at overflow
|
||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(cumulative, 1); // proposed=44+2=46, excess=1 m³ this tick
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 2); // instantaneous balance: inflow − outflowReal
|
||||
});
|
||||
|
||||
await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45);
|
||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(cumulative, 3); // 1 + 2
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 2);
|
||||
});
|
||||
|
||||
await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
|
||||
const net = ps._selectBestNetFlow();
|
||||
// inflow=2, outflow_total=2 (synthetic spill), net = 0
|
||||
assert.ok(Math.abs(net.value) < 1e-9);
|
||||
assert.equal(net.source, 'predicted');
|
||||
});
|
||||
|
||||
await t.test('once inflow stops, spill flow clears and clamp releases', () => {
|
||||
ps.setManualInflow(0, t0 + 2000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||
Date.now = () => t0 + 3000;
|
||||
ps._updatePredictedVolume();
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 0);
|
||||
// Volume stays at 45 (no draining force) but is no longer "pinned".
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45);
|
||||
});
|
||||
});
|
||||
|
||||
test('Predicted volume — dry-run lower clamp', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
// dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||
}));
|
||||
const t0 = 1_700_000_000_000;
|
||||
|
||||
await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
|
||||
// Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 2); // unchanged — clamp doesn't fire because we started below it
|
||||
});
|
||||
|
||||
await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
|
||||
// Calibrate well above, then push outflow that would cross the threshold.
|
||||
ps.calibratePredictedVolume(3, t0 + 1000);
|
||||
// outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
|
||||
ps.setManualOutflow(2, t0 + 1000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 2.1) < 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
// Seed an overflow scenario.
|
||||
const t0 = 1_700_000_000_000;
|
||||
ps.calibratePredictedVolume(44, t0);
|
||||
ps.setManualInflow(2, t0, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.predictedOverflowVolume, 1);
|
||||
assert.equal(out.predictedOverflowRate, 2);
|
||||
});
|
||||
|
||||
// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
|
||||
// from above, so a basin seeded below + continued outflow used to integrate
|
||||
// the volume arbitrarily negative. The level helper masked this by flooring
|
||||
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
|
||||
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||
}));
|
||||
const t0 = 1_700_000_000_000;
|
||||
|
||||
await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
|
||||
ps.calibratePredictedVolume(0.5, t0); // below dryRunSafetyVol (2.1)
|
||||
ps.setManualOutflow(2, t0, 'm3/s'); // 2 m³/s for 1s → would drop to -1.5
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 0); // floored at 0, not -1.5
|
||||
const underflow = ps.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(underflow, 1.5); // tracked as diagnostic
|
||||
});
|
||||
|
||||
await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 0);
|
||||
const underflow = ps.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(underflow, 3.5); // 1.5 + 2.0
|
||||
});
|
||||
|
||||
await t.test('getOutput exposes predictedUnderflowVolume', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.predictedUnderflowVolume, 3.5);
|
||||
});
|
||||
|
||||
await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
|
||||
ps.setManualInflow(1, t0 + 2000, 'm3/s');
|
||||
ps.setManualOutflow(0, t0 + 2000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||
Date.now = () => t0 + 3000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 1) < 1e-9); // 0 + 1 = 1, NOT pinned to 2.1
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -51,9 +51,10 @@ Use a descriptive `alt` text; it's the fallback if the SVG fails and it shows up
|
||||
| Diagram | Shows |
|
||||
|---|---|
|
||||
| `basin-model` | Shared physical basin cross-section — walls, pipe reference heights, derived safety zones, storage/dead volumes |
|
||||
| `modes/basin-mode-level-linear` | Level-linear control mode — `startLevel`, demand ramp, threshold-shift behaviour |
|
||||
| `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 overfill rule asymmetry — which children stop, which keep running |
|
||||
| `safety-rules` | Dry-run vs high-volume safety rule asymmetry — which children stop, which keep running |
|
||||
|
||||
## Making a brand-new diagram
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 271 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 |
@@ -79,7 +79,7 @@ The current runtime still uses the level fields directly for its volume math. Pi
|
||||
| **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 Overfill Protection** | `true` | If on, upstream inflows are shut down once volume climbs above the high-volume safety point while filling. |
|
||||
| **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
|
||||
@@ -152,12 +152,18 @@ Delta-compressed payload (only changed fields per tick). Keys follow the standar
|
||||
| `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.
|
||||
@@ -178,9 +184,9 @@ The basin is modelled as a rectangular prism with constant cross-section. Everyt
|
||||
|
||||
*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 ≤ minLevel < inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
|
||||
**Generic basin ordering** (bottom → top): `outflowLevel ≤ dryRunLevel < inflowLevel ≤ highVolumeSafetyLevel < overflowLevel ≤ basinHeight`.
|
||||
|
||||
`startLevel` is deliberately not part of this generic basin diagram. It belongs to a control mode. For the current level-linear mode, see [`diagrams/modes/basin-mode-level-linear.drawio.svg`](diagrams/modes/basin-mode-level-linear.drawio.svg).
|
||||
`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:
|
||||
|
||||
@@ -215,6 +221,23 @@ The high-volume safety point exists so the station can still react before the ba
|
||||
|
||||
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`):
|
||||
@@ -245,7 +268,7 @@ flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }
|
||||
|
||||
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`. `startLevel` is mode-specific and is documented with the mode diagrams, not the generic basin drawing.
|
||||
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:
|
||||
|
||||
@@ -261,7 +284,7 @@ See [`modes/README.md`](modes/README.md) for the index and page template.
|
||||
|
||||
`_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`.
|
||||
|
||||
@@ -319,8 +342,10 @@ The canonical end-to-end demo lives in the EVOLV superproject at [`examples/pump
|
||||
| "No volume data available to safe guard system; shutting down all machines." in logs | No measured level, predicted volume not calibrated, and no inflow/outflow samples yet. | Issue `calibratePredictedVolume` (or `calibratePredictedLevel`) once at startup, or wire a level sensor. |
|
||||
| `flowSource: null` and `direction: 'steady'` forever | Every flow / level signal falls inside the dead-band (default `1e-4 m³/s`). | Confirm flows are non-zero, or lower `config.general.flowThreshold` for a small-scale demo. |
|
||||
| `Qd` ignored | Station is not in `manual` mode. | Send `{ topic: 'changemode', payload: 'manual' }` first, or fall back to level-based control. |
|
||||
| Pumps keep running during overfill | Intended — overfill safety only stops **upstream** equipment; downstream pumps must drain. | To override, switch to `manual` and set `Qd = 0`, or issue an emergency-stop at the MGC. |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ updated: 2026-04-22
|
||||
|
||||
# Level-based mode
|
||||
|
||||
The simplest and most widely deployed control strategy. Demand is a direct, *static* piecewise-linear function of basin level — no feedback loop, no predictions beyond the level measurement itself. This page uses the [shared basin model](../functional-description.md#basin-model); see [`modes/README.md`](README.md) for the template other mode pages follow.
|
||||
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
|
||||
|
||||
@@ -20,9 +20,9 @@ The simplest and most widely deployed control strategy. Demand is a direct, *sta
|
||||
|
||||
## Diagram
|
||||
|
||||

|
||||

|
||||
|
||||
*Editable source: [`../diagrams/modes/basin-mode-level-linear.drawio.svg`](../diagrams/modes/basin-mode-level-linear.drawio.svg) (drag into [draw.io](https://app.diagrams.net/) — it round-trips).*
|
||||
*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
|
||||
|
||||
@@ -30,10 +30,11 @@ The simplest and most widely deployed control strategy. Demand is a direct, *sta
|
||||
|---|---|---|
|
||||
| current level | `measurement` child (`measured`) → predicted from volume integrator (fallback) | X-axis of the transfer function |
|
||||
| `config.control.levelbased.minLevel` | editor, static | below → pumps hard OFF |
|
||||
| `config.control.levelbased.startLevel` | editor, static | where demand-ramp starts |
|
||||
| `config.control.levelbased.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 are the **only** mode-specific configuration. Nothing here is recomputed at runtime.
|
||||
The three control thresholds plus curve type are the mode-specific configuration. Nothing here is recomputed at runtime.
|
||||
|
||||
## Threshold policy
|
||||
|
||||
@@ -42,6 +43,7 @@ The three control thresholds are the **only** mode-specific configuration. Nothi
|
||||
| `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.
|
||||
|
||||
@@ -51,15 +53,15 @@ That this policy is trivial (all static) is **the defining simplicity of this mo
|
||||
if level < minLevel:
|
||||
demand = 0
|
||||
MGC → turnOffAllMachines() # explicit shutdown, not just "0 %"
|
||||
elif level < startLevel:
|
||||
demand = <previous demand> # dead zone — hold last command (hysteresis)
|
||||
elif level <= maxLevel:
|
||||
demand = lerp(level, [startLevel, maxLevel], [0 %, 100 %])
|
||||
elif direction == filling:
|
||||
demand = curve(level, [inflowLevel, maxLevel], [0 %, 100 %])
|
||||
elif direction == draining:
|
||||
demand = curve(level, [startLevel, maxLevel], [0 %, 100 %])
|
||||
else:
|
||||
demand = 100 % # saturated; MGC clamps internally if overshoot
|
||||
demand = previous demand
|
||||
```
|
||||
|
||||
Where `lerp` is linear interpolation. The MGC is free to distribute the demand across its pumps however its own policy dictates (equal split, lead-lag, staging — that's the MGC's business).
|
||||
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
|
||||
|
||||
|
||||
@@ -67,11 +67,11 @@ demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
|
||||
demand = min(rawDemand, demandCap)
|
||||
```
|
||||
|
||||
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence.
|
||||
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 overfill safety trips, it overrides the clip (safety wins).
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user