Level-armed shift, derived dryRunLevel, side-panel editor + manual q_out

Runtime (specificClass.js):
- Replace direction-based hysteresis with level-armed _shiftArmed state.
  Arms when level rises past shiftLevel; disarms when level drops below
  startLevel. While armed, ramp foot moves to startLevel and ramp top
  to shiftLevel — both ends shift left, then saturate at 100 % up to
  maxLevel.
- _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so
  the saturation point follows the shift state.
- New setManualOutflow mirroring setManualInflow.

Adapter (nodeClass.js):
- Pipe enableShiftedRamp / shiftLevel through to control.levelbased.
- New q_out topic handler.

Editor (pumpingStation.html + new src/editor/ modules):
- Split monolithic <script> into modules: index.js (helpers),
  basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js,
  oneditsave.js — served via /pumpingStation/editor/:file.
- Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 %
  flat from start→inlet, ramp inlet→max, optional shifted-down curve
  start→shift with 100 % saturation past shift.
- Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh /
  overflow), level markers (dryRun derived, start, inlet, max, shift,
  overflow), validation ribbon that blocks save on bad ordering.
- Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is
  always visible.
- All level inputs moved to a side panel left of each diagram, color-
  coded to match line strokes; hover-couple highlights the paired SVG
  line on input focus / mouseover.
- Removed UI for non-static parameters: minHeightBasedOn,
  pipelineLength, maxDischargeHead, staticHead, defaultFluid,
  maxInflowRate, temperatureReferenceDegC,
  timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter,
  outletPipeDiameter, minLevel (now derived = dryRunLevel).
- foreignObject inputs in basin SVG removed (single source of truth in
  side panel).

Dashboard example (examples/basic-dashboard.flow.json):
- Add manual Q_OUT slider + q_out builder mirroring the existing q_in
  trio so the basin can be exercised end-to-end without a connected
  rotating-machine downstream.

Tests (test/basic/specificClass.test.js):
- Replace direction-shift test with two new cases covering shift-disabled
  hold-zone behaviour and shift-armed/disarmed transitions through
  shiftLevel and startLevel boundaries. 53/53 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-05 19:29:34 +02:00
parent da50403c76
commit 8a6ca1baeb
12 changed files with 1877 additions and 487 deletions

View 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": "const 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', 'level.measured.atequipment');\nconst volume = firstFinite('volume.predicted.atequipment', 'volume.measured.atequipment');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment', 'netFlowRate.measured.atequipment');\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": []
}
]

View File

@@ -8,8 +8,17 @@
| **Control Module** | `#a9daee` | zwart | | **Control Module** | `#a9daee` | zwart |
--> -->
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns --> <script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information --> <script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
<script src="/pumpingStation/editor/index.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 <script>//test
RED.nodes.registerType("pumpingStation", { RED.nodes.registerType("pumpingStation", {
@@ -22,8 +31,8 @@
simulator: { value: false }, simulator: { value: false },
basinVolume: { value: 1 }, // m³, total empty basin basinVolume: { value: 1 }, // m³, total empty basin
basinHeight: { value: 1 }, // m, floor to top basinHeight: { value: 1 }, // m, floor to top
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor inflowLevel: { value: 0.8 }, // m, bottom/invert of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
overflowLevel: { value: 0.9 }, // m, overflow elevation overflowLevel: { value: 0.9 }, // m, overflow elevation
defaultFluid: { value: "wastewater" }, defaultFluid: { value: "wastewater" },
inletPipeDiameter: { value: 0.3 }, // m inletPipeDiameter: { value: 0.3 }, // m
@@ -35,9 +44,11 @@
temperatureReferenceDegC: { value: 15 }, temperatureReferenceDegC: { value: 15 },
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
enableDryRunProtection: { value: true }, enableDryRunProtection: { value: true },
enableOverfillProtection: { value: true }, enableHighVolumeSafety: { value: true },
enableOverfillProtection: { value: true }, // deprecated alias
dryRunThresholdPercent: { value: 2 }, 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 minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
processOutputFormat: { value: "process" }, processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" }, dbaseOutputFormat: { value: "influxdb" },
@@ -67,7 +78,11 @@
distanceDescription: { value: "" }, distanceDescription: { value: "" },
// control strategy // control strategy
controlMode: { value: "none" }, controlMode: { value: "levelbased" },
levelCurveType: { value: "linear" },
logCurveFactor: { value: 9 },
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
startLevel: { value: null }, startLevel: { value: null },
minLevel: { value: null }, minLevel: { value: null },
maxLevel: { value: null }, maxLevel: { value: null },
@@ -86,296 +101,11 @@
return this.positionIcon + " PumpingStation"; return this.positionIcon + " PumpingStation";
}, },
oneditprepare: function() { oneditprepare: function () {
const waitForMenuData = () => { window.PSEditor.oneditprepare.call(this);
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 ------------------- //
}, },
oneditsave: function () { oneditsave: function () {
const node = this; window.PSEditor.oneditsave.call(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');
}, },
}); });
@@ -395,123 +125,199 @@
<hr> <hr>
<h4>Basin parameters</h4> <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>
<style> <style>
#ps-basin-diagram input[type=number] { /* Two-column layout: stacked colour-coded inputs on the left,
width: 100%; height: 20px; box-sizing: border-box; SVG on the right. Hover an input row → its paired SVG line
font-size: 11px; padding: 1px 4px; margin: 0; (referenced by data-couples-line) gets a thicker stroke. */
border: 1px solid #ccc; border-radius: 3px; background: #fff; .ps-diag { display:flex; gap:14px; align-items:flex-start; margin:0 0 14px 0; }
.ps-diag-side { width: 200px; flex: 0 0 200px; display:flex; flex-direction:column; gap:6px; }
.ps-diag-side .ps-row {
display:grid; grid-template-columns: 1fr 78px 18px; 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;
} }
#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> </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"> BASIN DIAGRAM (ps-basin-diagram)
<defs> ============================================================
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> Coordinate system: SVG viewBox is 520 (wide) × 430 (tall).
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" /> Origin (0,0) is top-left. +x goes right. +y goes DOWN.
</marker> Bigger y = lower on screen.
</defs>
<!-- Tank body --> X-LANES (all viewBox units, edit any of these to shift a column):
<rect x="200" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" /> x 5..75 left input column (inlet number input)
<!-- Dead-volume band (y + height updated dynamically below outflowLevel) --> x = 80 inlet unit "m"
<rect id="ps-deadvol" x="201" width="118" fill="#AACCE0" /> x = 135 inlet text labels (right-aligned, anchor at x)
<!-- basinVolume pinned above the rim --> x = 140..200 inlet arrow (line + arrow head into tank)
<text id="ps-label-basinVolume" x="330" y="19" fill="#333" font-weight="600">basin volume</text> x = 200..320 tank body (rect.x=200 width=120) interior 201..319
<foreignObject id="ps-fo-basinVolume" x="425" y="4" width="70" height="22"> x = 195/325 threshold tick lines (extend 5 px outside tank)
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinVolume" min="0" step="0.1" /> x = 260 mid-tank zone labels (centered)
</foreignObject> x = 320..360 outlet arrow
<text id="ps-unit-basinVolume" x="500" y="19" fill="#555"></text> 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) --> Y-COORDINATES:
<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> y = 40 tank rim (basinHeight line)
<text id="ps-zone-sewage" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text> y = 380 tank floor / datum
<text id="ps-zone-buffer1" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text> y = 410 ordering warning ribbon
<text id="ps-zone-buffer2" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text> y = 19,44 "basin volume" / "basinHeight" labels (static)
<text id="ps-zone-dead" x="260" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text> 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) --> Note: dynamic line/label positioning lives in oneditprepare redraw()
<line id="ps-line-basinHeight" x1="195" y1="40" x2="325" y2="40" stroke="#333" stroke-width="1.5" /> further up in this file. Changing only the inline y here will be
<text id="ps-label-basinHeight" x="330" y="44" fill="#333">basinHeight</text> overridden on first redraw for any element whose id appears in redraw().
<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> <div class="ps-diag" id="ps-basin-wrap">
<text id="ps-unit-basinHeight" x="500" y="44" fill="#555">m</text> <!-- 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"></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:380px;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 — x=120,width=120 (was 200,120 in the old wider viewBox).
Threshold tick lines extend a few px outside the tank walls. -->
<rect x="120" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
<rect id="ps-deadvol" x="121" width="118" fill="#AACCE0" />
<!-- overflowLevel --> <!-- Mid-tank zone labels — centred at x=180. -->
<line id="ps-line-overflowLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" /> <text id="ps-zone-spare" x="180" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare volume before spilling</text>
<text id="ps-label-overflowLevel" x="330" fill="#C0392B">overflowLevel</text> <text id="ps-zone-sewage" x="180" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text>
<foreignObject id="ps-fo-overflowLevel" x="425" width="70" height="22"> <text id="ps-zone-buffer1" x="180" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-overflowLevel" min="0" step="0.01" /> <text id="ps-zone-buffer2" x="180" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
</foreignObject> <text id="ps-zone-dead" x="180" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text>
<text id="ps-unit-overflowLevel" x="500" fill="#555">m</text>
<!-- maxLevel --> <!-- basinHeight tick at tank rim (y=40, static). -->
<line id="ps-line-maxLevel" x1="195" x2="325" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" /> <line id="ps-line-basinHeight" x1="115" y1="40" x2="245" y2="40" stroke="#333" stroke-width="1.5" />
<text id="ps-label-maxLevel" x="330" fill="#D68910">maxLevel</text> <text id="ps-label-basinHeight" x="250" y="44" fill="#333">basinHeight</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>
<!-- startLevel --> <line id="ps-line-overflowLevel" x1="115" x2="245" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<line id="ps-line-startLevel" x1="195" x2="325" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" /> <text id="ps-label-overflowLevel" x="250" fill="#C0392B">overflowLevel</text>
<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>
<!-- Inlet arrow + input on the left --> <line id="ps-line-highVolumeSafetyLevel" x1="115" x2="245" stroke="#D68910" stroke-dasharray="1 2" stroke-width="1" opacity="0.7" />
<line id="ps-line-inflowLevel" x1="140" x2="200" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" /> <text id="ps-label-highVolumeSafetyLevel" x="250" fill="#D68910" font-size="10" font-style="italic">highVolumeSafety</text>
<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>
<!-- minLevel --> <line id="ps-line-inflowLevelGuide" x1="120" x2="240" stroke="#1F4E79" stroke-dasharray="2 3" stroke-width="1" opacity="0.55" />
<line id="ps-line-minLevel" x1="195" x2="325" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" /> <text id="ps-label-inflowLevelGuide" x="250" fill="#1F4E79" font-size="10" font-style="italic">inlet invert</text>
<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>
<!-- dryRunLevel (derived, read-only) --> <line id="ps-line-inflowLevel" x1="60" x2="120" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<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-inflowLevel" x="55" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
<text id="ps-label-dryRunLevel" x="330" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel m (safety from %)</text> <text id="ps-sub-inflowLevel" x="55" 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-dryRunLevel" x1="115" x2="245" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
<line id="ps-line-outflowLevel" x1="320" x2="360" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" /> <text id="ps-label-dryRunLevel" x="250" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel</text>
<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>
<!-- Floor / datum --> <line id="ps-line-outflowLevel" x1="240" x2="280" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<line x1="195" y1="380" x2="325" y2="380" stroke="#000" stroke-width="2" /> <text id="ps-label-outflowLevel" x="285" fill="#1F4E79" font-weight="bold">Outlet</text>
<text x="330" y="384" fill="#000">0 m (datum)</text> <text id="ps-sub-outflowLevel" x="285" 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 --> <!-- Floor / datum -->
<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 x1="115" y1="380" x2="245" y2="380" stroke="#000" stroke-width="2" />
<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" /> <text x="250" y="384" fill="#000">0 m (datum)</text>
<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" /> <!-- Ordering-warning ribbon -->
<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" /> <text id="ps-warning" x="180" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
<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" /> </svg>
<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" /> </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> <hr>
@@ -519,39 +325,167 @@
<div class="form-row"> <div class="form-row">
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label> <label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
<select id="node-input-controlMode"> <select id="node-input-controlMode">
<option value="none">None / Manual</option>
<option value="levelbased">Level-based</option> <option value="levelbased">Level-based</option>
<option value="flowbased">Flow-based</option> <option value="manual">Manual</option>
</select> </select>
</div> </div>
<div id="ps-mode-levelbased" class="ps-mode-section"> <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 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">arms hysteresis</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 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=inlettop=max. Down curve foot=starttop=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-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;" />
<!-- Axis labels y=180 row sits below the OFF baseline (y=160). x set dynamically. -->
<text id="ps-mode-label-dryRunLevel" y="180" text-anchor="middle" fill="#C0392B">dry run</text>
<text id="ps-mode-label-startLevel" y="180" text-anchor="middle" fill="#1E8449">start</text>
<text id="ps-mode-label-inflowLevel" y="180" text-anchor="middle" fill="#1F4E79">inlet</text>
<text id="ps-mode-label-maxLevel" y="180" text-anchor="middle" fill="#D68910">max</text>
<text id="ps-mode-label-overflowLevel" y="180" text-anchor="middle" fill="#C0392B">overflow</text>
<text id="ps-mode-label-shiftLevel" y="180" text-anchor="middle" fill="#D68910" style="display:none;">shift</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 inletmax</text>
<text id="ps-mode-curve-down-label" x="210" y="205" fill="#D68910" font-size="10" style="display:none;"> shifted startshift</text>
</svg>
</div>
</div> </div>
<div id="ps-mode-flowbased" class="ps-mode-section" style="display:none"> <div id="ps-mode-manual" class="ps-mode-section" style="display:none">
<div class="form-row"> <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>
<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> </div>
<hr> <hr>
<h4>Reference</h4> <h4>Reference</h4>
<!-- Reference data --> <!-- Reference data basinBottomRef moved into basin side-panel above. -->
<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>
<div class="form-row"> <div class="form-row">
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label> <label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
<select id="node-input-refHeight" style="width:60%;"> <select id="node-input-refHeight" style="width:60%;">
@@ -559,21 +493,11 @@
</select> </select>
</div> </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> <hr>
<h4>Safety</h4> <h4>Safety</h4>
<!-- Safety settings --> <!-- 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"> <div class="form-row">
<label for="node-input-enableDryRunProtection"> <label for="node-input-enableDryRunProtection">
<i class="fa fa-shield"></i> Dry-run Protection <i class="fa fa-shield"></i> Dry-run Protection
@@ -588,16 +512,16 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-enableOverfillProtection"> <label for="node-input-enableHighVolumeSafety">
<i class="fa fa-exclamation-triangle"></i> Overfill Protection <i class="fa fa-exclamation-triangle"></i> High-volume Safety
</label> </label>
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" /> <input type="checkbox" id="node-input-enableHighVolumeSafety" style="width:20px;vertical-align:baseline;" />
<span>Stop filling when approaching overflow</span> <span>Act before physical overflow</span>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label> <label for="node-input-highVolumeSafetyThresholdPercent" style="padding-left:20px;">High-volume Safety (%)</label>
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" /> <input type="number" id="node-input-highVolumeSafetyThresholdPercent" 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> <span id="derived-highVolumeSafetyLevel" style="margin-left:8px;color:#777;font-size:12px;"> highVolumeSafetyLevel m</span>
</div> </div>
<hr> <hr>

View File

@@ -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 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 nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions'); 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');
});
});
}; };

147
src/editor/basin-diagram.js Normal file
View File

@@ -0,0 +1,147 @@
// 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);
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', '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);
const warn = document.getElementById('ps-warning');
const issues = [];
const pairs = [
['outflowLevel', 'inflowLevel', '<'],
['inflowLevel', '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'); }
}
},
};
})();

View 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
View 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);
}
});
};
})();

228
src/editor/mode-preview.js Normal file
View File

@@ -0,0 +1,228 @@
// 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');
// 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 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(' ');
};
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, inlet, max));
if (down) {
if (shiftEnabled) {
const shiftedTop = Number.isFinite(shift) && shift > start ? shift : max;
down.setAttribute('points', buildPath(start, start, shiftedTop));
down.style.display = '';
if (downLabel) downLabel.style.display = '';
} else {
down.setAttribute('points', '');
down.style.display = 'none';
if (downLabel) downLabel.style.display = 'none';
}
}
// Vertical level markers + axis labels.
[
['dryRunLevel', dryRun],
['startLevel', start],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],
].forEach(([id, level]) => {
const line = document.getElementById(`ps-mode-line-${id}`);
const label = document.getElementById(`ps-mode-label-${id}`);
if (!line || !label) return;
if (!Number.isFinite(level)) {
line.style.display = 'none';
label.style.display = 'none';
return;
}
const x = xFor(level);
line.style.display = '';
label.style.display = '';
line.setAttribute('x1', x); line.setAttribute('x2', x);
label.setAttribute('x', 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.
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
const shiftLabel = document.getElementById('ps-mode-label-shiftLevel');
if (shiftLine && shiftLabel) {
if (shiftEnabled && Number.isFinite(shift)) {
const x = xFor(shift);
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
shiftLabel.setAttribute('x', x);
shiftLine.style.display = '';
shiftLabel.style.display = '';
} else {
shiftLine.style.display = 'none';
shiftLabel.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 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);
}
}
// Validation: ordering constraints.
const issues = [];
if (Number.isFinite(dryRun) && Number.isFinite(start) && dryRun >= start)
issues.push('dryRunLevel (derived) must be < startLevel — increase startLevel or lower dryRun%');
if (Number.isFinite(start) && Number.isFinite(inlet) && start >= inlet)
issues.push('startLevel must be < inflowLevel (set in basin above)');
if (Number.isFinite(inlet) && Number.isFinite(max) && inlet >= max)
issues.push('inflowLevel must be < maxLevel');
if (Number.isFinite(max) && Number.isFinite(overflow) && max > overflow)
issues.push('maxLevel must be ≤ overflowLevel');
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 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);
},
};
})();

101
src/editor/oneditprepare.js Normal file
View File

@@ -0,0 +1,101 @@
// 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-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'],
ns.modePreview.redraw
);
// Initial render + hover-couple wiring once the DOM is settled.
setTimeout(() => {
ns.basinDiagram.redraw();
ns.modePreview.redraw();
ns.hoverCouple?.init();
}, 60);
};
})();

63
src/editor/oneditsave.js Normal file
View File

@@ -0,0 +1,63 @@
// 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 the inline validator surfaced any issues.
const issues = window._psModeValidationIssues || [];
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 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;
};
})();

View File

@@ -47,26 +47,44 @@ class nodeClass {
inflowLevel: uiConfig.inflowLevel, inflowLevel: uiConfig.inflowLevel,
outflowLevel: uiConfig.outflowLevel, outflowLevel: uiConfig.outflowLevel,
overflowLevel: uiConfig.overflowLevel, overflowLevel: uiConfig.overflowLevel,
inletPipeDiameter: uiConfig.inletPipeDiameter,
outletPipeDiameter: uiConfig.outletPipeDiameter,
}, },
hydraulics: { hydraulics: {
refHeight: uiConfig.refHeight, refHeight: uiConfig.refHeight,
minHeightBasedOn: uiConfig.minHeightBasedOn, minHeightBasedOn: uiConfig.minHeightBasedOn,
basinBottomRef: uiConfig.basinBottomRef, basinBottomRef: uiConfig.basinBottomRef,
maxInflowRate: uiConfig.maxInflowRate,
staticHead: uiConfig.staticHead,
maxDischargeHead: uiConfig.maxDischargeHead,
pipelineLength: uiConfig.pipelineLength,
defaultFluid: uiConfig.defaultFluid,
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
}, },
control:{ control:{
mode: uiConfig.controlMode, mode: uiConfig.controlMode,
levelbased:{ levelbased:{
minLevel:uiConfig.minLevel, minLevel:uiConfig.minLevel,
startLevel:uiConfig.startLevel, startLevel:uiConfig.startLevel,
maxLevel:uiConfig.maxLevel maxLevel:uiConfig.maxLevel,
curveType: uiConfig.levelCurveType || uiConfig.curveType,
logCurveFactor: uiConfig.logCurveFactor,
enableShiftedRamp: uiConfig.enableShiftedRamp,
shiftLevel: uiConfig.shiftLevel
} }
}, },
safety:{ safety:{
enableDryRunProtection: uiConfig.enableDryRunProtection, enableDryRunProtection: uiConfig.enableDryRunProtection,
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent, dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
enableHighVolumeSafety: uiConfig.enableHighVolumeSafety ?? uiConfig.enableOverfillProtection,
highVolumeSafetyThresholdPercent: uiConfig.highVolumeSafetyThresholdPercent ?? uiConfig.overfillThresholdPercent,
enableOverfillProtection: uiConfig.enableOverfillProtection, enableOverfillProtection: uiConfig.enableOverfillProtection,
overfillThresholdPercent: uiConfig.overfillThresholdPercent, overfillThresholdPercent: uiConfig.overfillThresholdPercent,
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
},
output: {
process: uiConfig.processOutputFormat,
dbase: uiConfig.dbaseOutputFormat
} }
}); });
@@ -220,6 +238,13 @@ class nodeClass {
this.source.setManualInflow(val, ts, unit); this.source.setManualInflow(val, ts, unit);
break; 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': { case 'Qd': {
// Manual demand: operator sets the target output via a // Manual demand: operator sets the target output via a
// dashboard slider. Only accepted when PS is in 'manual' // dashboard slider. Only accepted when PS is in 'manual'

View File

@@ -105,6 +105,14 @@ class PumpingStation {
// levelbased mode. Exposed in getOutput() for dashboards. // levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0; this.percControl = 0;
// --- Level-armed hysteresis state ---
// _shiftArmed flips true when level rises past shiftLevel (with
// enableShiftedRamp). While armed, the demand ramp's lower foot
// is startLevel instead of inflowLevel — so on the way down the
// pumps stay aggressive until level falls below startLevel, at
// which point the arm clears.
this._shiftArmed = false;
// --- Flow dead-band --- // --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise. // flowThreshold (m3/s) prevents control actions on noise.
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is // Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
@@ -271,6 +279,11 @@ class PumpingStation {
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit); 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 / Control --------------------------- */
tick() { tick() {
@@ -314,7 +327,7 @@ class PumpingStation {
_controlLogic(direction) { _controlLogic(direction) {
switch (this.mode) { switch (this.mode) {
case 'levelbased': case 'levelbased':
this._controlLevelBased(); this._controlLevelBased(direction);
break; break;
case 'flowbased': case 'flowbased':
this._controlFlowBased?.(); this._controlFlowBased?.();
@@ -326,7 +339,7 @@ class PumpingStation {
} }
} }
async _controlLevelBased() { async _controlLevelBased(_direction) {
const { startLevel, minLevel } = this.config.control.levelbased; const { startLevel, minLevel } = this.config.control.levelbased;
const levelUnit = this.measurements.getUnit('level'); const levelUnit = this.measurements.getUnit('level');
@@ -336,33 +349,45 @@ class PumpingStation {
return; return;
} }
// Level-based pump control via MGC — three zones: // Level-based pump control via MGC (see wiki/modes/levelbased.md).
//
// level < minLevel → STOP (unconditional MGC shutdown) // level < minLevel → STOP (unconditional MGC shutdown)
// minLevel ≤ level < startLevel → DEAD ZONE (hysteresis; keep last cmd) // level < startLevel → 0 % (pumps held off)
// level ≥ startLevel → RUN (linear [startLevel..maxLevel] → [0..100 %]) // level in [startLevel..rampStart] → 0 % (HOLD zone)
// See wiki/modes/levelbased.md for the full transfer-function diagram. // level in [rampStart..maxLevel] → 0..100 % (linear or log curve)
// level > maxLevel → ≥100 % (MGC clamps internally)
//
// With enableShiftedRamp:
// rampStart = inflowLevel by default
// when level rises past shiftLevel → arm → rampStart = startLevel
// when level drops below startLevel → disarm → rampStart = inflowLevel
// Without enableShiftedRamp: rampStart = inflowLevel always.
// STOP — below minLevel, always shut down regardless of direction.
if (level < minLevel) { if (level < minLevel) {
this.percControl = 0; this.percControl = 0;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines()); Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return; return;
} }
// DEAD ZONE — between minLevel and startLevel, do nothing. this._updateShiftArmed(level);
// Pumps that are running keep their last command; pumps that const rampStartLevel = this._levelBasedRampStart();
// are off stay off. This prevents rapid on/off cycling. const rampTopLevel = this._levelBasedRampTop();
if (level < startLevel) {
// HOLD/MINIMUM DEMAND — below the active ramp start, command 0 %
// without latching dry-run. Dry-run remains the safety layer's job.
if (level < rampStartLevel) {
this.percControl = 0;
await this._applyMachineGroupLevelControl(0);
return; return;
} }
// RUN — above startLevel, compute demand and forward to MGC. // RUN — above rampStartLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [startLevel..maxLevel] → [0..100]. // _scaleLevelToFlowPercent maps [rampStartLevel..rampTopLevel] → [0..100].
// Above maxLevel the MGC clamps internally. // Above rampTopLevel demand saturates at 100 %.
const rawPercControl = this._scaleLevelToFlowPercent(level); const rawPercControl = this._scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel);
const percControl = Math.max(0, rawPercControl); const percControl = Math.max(0, rawPercControl);
this.percControl = percControl; this.percControl = percControl;
this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`); this.logger.debug(`Level-based control: level=${level} armed=${this._shiftArmed} foot=${rampStartLevel} top=${rampTopLevel} percControl=${percControl}`);
await this._applyMachineGroupLevelControl(percControl); await this._applyMachineGroupLevelControl(percControl);
} }
@@ -503,10 +528,60 @@ class PumpingStation {
return null; return null;
} }
_scaleLevelToFlowPercent(level) { _levelBasedRampStart() {
const { startLevel, maxLevel } = this.config.control.levelbased; const { startLevel, enableShiftedRamp } = this.config.control.levelbased;
this.logger.debug(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`); const inflowLevel = this.basin?.inflowLevel;
return this.interpolate.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100); if (enableShiftedRamp && this._shiftArmed) return startLevel;
if (Number.isFinite(inflowLevel)) return inflowLevel;
return startLevel;
}
_levelBasedRampTop() {
// Returns the upper level at which demand saturates at 100 %.
// While the shift is armed, top moves left from maxLevel to shiftLevel
// so output reaches 100 % earlier and stays at 100 % until level
// falls back through shiftLevel on the way down.
const { maxLevel, enableShiftedRamp, shiftLevel } = this.config.control.levelbased;
if (enableShiftedRamp && this._shiftArmed
&& Number.isFinite(shiftLevel) && shiftLevel > 0
&& shiftLevel <= maxLevel) {
return shiftLevel;
}
return maxLevel;
}
_updateShiftArmed(level) {
const { enableShiftedRamp, shiftLevel, startLevel } = this.config.control.levelbased;
if (!enableShiftedRamp) {
this._shiftArmed = false;
return;
}
const trigger = Number.isFinite(shiftLevel) && shiftLevel > 0 ? shiftLevel : null;
if (!this._shiftArmed && trigger != null && level >= trigger) {
this._shiftArmed = true;
this.logger.debug(`Shift armed at level=${level} (shiftLevel=${trigger})`);
} else if (this._shiftArmed && Number.isFinite(startLevel) && level < startLevel) {
this._shiftArmed = false;
this.logger.debug(`Shift disarmed at level=${level} (startLevel=${startLevel})`);
}
}
_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) { _levelRate(variant) {
@@ -634,7 +709,7 @@ class PumpingStation {
* Only a manual override or emergency can restart them. * Only a manual override or emergency can restart them.
* safetyControllerActive = true → blocks _controlLogic. * 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). * Shuts down UPSTREAM equipment only (stop more water coming in).
* Does NOT shut down downstream pumps or machine groups — they * Does NOT shut down downstream pumps or machine groups — they
* must keep draining. Does NOT set safetyControllerActive — the * must keep draining. Does NOT set safetyControllerActive — the
@@ -642,7 +717,7 @@ class PumpingStation {
* dictated by the current level (which will be >100% near overflow, * dictated by the current level (which will be >100% near overflow,
* meaning all pumps at maximum via the normal demand curve). * meaning all pumps at maximum via the normal demand curve).
* Only a manual override or emergency stop can shut pumps during * Only a manual override or emergency stop can shut pumps during
* an overfill event. * a high-volume or overflowing event.
*/ */
_safetyController(remainingTime, direction) { _safetyController(remainingTime, direction) {
this.safetyControllerActive = false; this.safetyControllerActive = false;
@@ -660,21 +735,34 @@ class PumpingStation {
enableDryRunProtection, enableDryRunProtection,
dryRunThresholdPercent, dryRunThresholdPercent,
enableOverfillProtection, enableOverfillProtection,
overfillThresholdPercent, enableHighVolumeSafety,
timeleftToFullOrEmptyThresholdSeconds timeleftToFullOrEmptyThresholdSeconds
} = this.config.safety || {}; } = this.config.safety || {};
const dryRunEnabled = Boolean(enableDryRunProtection); const dryRunEnabled = Boolean(enableDryRunProtection);
const overfillEnabled = Boolean(enableOverfillProtection); const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0; const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
const triggerHighVol = this.basin.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100); const safety = this._computeSafetyPoints();
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100)); 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. // Rule 1: DRY-RUN — below minLevel, pumps cannot run.
if (direction === 'draining') { if (direction === 'draining') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds; const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol; const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
if (timeTriggered || dryRunTriggered) { if (timeTriggered || dryRunTriggered) {
this.safetyState.dryRunActive = true;
// Shut down all downstream equipment — pumps must stop. // Shut down all downstream equipment — pumps must stop.
Object.values(this.machines).forEach((machine) => { Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent; const pos = machine?.config?.functionality?.positionVsParent;
@@ -700,8 +788,9 @@ class PumpingStation {
// running to maintain pump demand. // running to maintain pump demand.
if (direction === 'filling') { if (direction === 'filling') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds; const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const overfillTriggered = overfillEnabled && vol > triggerHighVol; const highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
if (timeTriggered || overfillTriggered) { if (timeTriggered || highVolumeTriggered) {
this.safetyState.highVolumeActive = true;
// Shut down UPSTREAM only — stop more water coming in. // Shut down UPSTREAM only — stop more water coming in.
Object.values(this.machines).forEach((machine) => { Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent; const pos = machine?.config?.functionality?.positionVsParent;
@@ -713,7 +802,7 @@ class PumpingStation {
// NOTE: machine groups (downstream pumps) are NOT shut down. // NOTE: machine groups (downstream pumps) are NOT shut down.
// They must keep draining to prevent overflow from worsening. // They must keep draining to prevent overflow from worsening.
this.logger.warn( 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 // NOTE: safetyControllerActive is NOT set — level control
// keeps commanding pumps at maximum demand. // keeps commanding pumps at maximum demand.
@@ -721,6 +810,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 --------------------------- */ /* --------------------------- Basin --------------------------- */
/** /**
@@ -740,9 +848,11 @@ class PumpingStation {
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn; const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity
const heightBasin = this.config.basin.height; // m — floor to rim const heightBasin = this.config.basin.height; // m — floor to rim
const inflowLevel = this.config.basin.inflowLevel; // m — sewer feed pipe centre const inflowLevel = this.config.basin.inflowLevel; // m — inlet pipe bottom/invert
const outflowLevel = this.config.basin.outflowLevel; // m — pump suction pipe centre const outflowLevel = this.config.basin.outflowLevel; // m — outlet/pump suction pipe top
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest 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 // Constant cross-section assumption: volume = level × area
const surfaceArea = volEmptyBasin / heightBasin; const surfaceArea = volEmptyBasin / heightBasin;
@@ -762,6 +872,8 @@ class PumpingStation {
inflowLevel, inflowLevel,
outflowLevel, outflowLevel,
overflowLevel, overflowLevel,
inletPipeDiameter,
outletPipeDiameter,
surfaceArea, surfaceArea,
maxVol, maxVol,
maxVolAtOverflow, maxVolAtOverflow,
@@ -786,26 +898,21 @@ class PumpingStation {
* *
* Strict invariants (bottom → top): * Strict invariants (bottom → top):
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight * 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 × * 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 * catches config that would let minLevel sit below where safety has
* already force-stopped the pumps (no-op control band). * already force-stopped the pumps (no-op control band).
*/ */
_validateThresholdOrdering() { _validateThresholdOrdering() {
const basin = this.basin; const basin = this.basin;
const lvl = this.config.control?.levelbased || {}; const lvl = this.config.control?.levelbased || {};
const safety = this.config.safety || {};
// Derived safety trigger levels (level-space equivalents of what const safetyPoints = this._computeSafetyPoints();
// _safetyController does in volume-space). const dryRunLevel = safetyPoints.dryRunLevel;
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0; const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
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 checks = [ const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel], ['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
@@ -813,8 +920,10 @@ class PumpingStation {
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin], ['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel], ['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel], ['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel], ['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel], ['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
]; ];
const issues = []; const issues = [];
@@ -844,21 +953,38 @@ class PumpingStation {
getOutput() { getOutput() {
const output = this.measurements.getFlattenedOutput(); const output = this.measurements.getFlattenedOutput();
const safety = this._computeSafetyPoints();
output.direction = this.state.direction; output.direction = this.state.direction;
output.flowSource = this.state.flowSource; output.flowSource = this.state.flowSource;
output.timeleft = this.state.seconds; output.timeleft = this.state.seconds;
output.volEmptyBasin = this.basin.volEmptyBasin; output.volEmptyBasin = this.basin.volEmptyBasin;
output.inflowLevel = this.basin.inflowLevel; output.inflowLevel = this.basin.inflowLevel;
output.outflowLevel = this.basin.outflowLevel;
output.overflowLevel = this.basin.overflowLevel; output.overflowLevel = this.basin.overflowLevel;
output.inletPipeDiameter = this.basin.inletPipeDiameter;
output.outletPipeDiameter = this.basin.outletPipeDiameter;
output.maxVol = this.basin.maxVol; output.maxVol = this.basin.maxVol;
output.minVol = this.basin.minVol; output.minVol = this.basin.minVol;
output.maxVolAtOverflow = this.basin.maxVolAtOverflow; output.maxVolAtOverflow = this.basin.maxVolAtOverflow;
output.minVolAtOutflow = this.basin.minVolAtOutflow; output.minVolAtOutflow = this.basin.minVolAtOutflow;
output.minVolAtInflow = this.basin.minVolAtInflow; output.minVolAtInflow = this.basin.minVolAtInflow;
output.minHeightBasedOn = this.basin.minHeightBasedOn; 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.percControl = this.percControl;
return output; 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; module.exports = PumpingStation;
@@ -887,15 +1013,19 @@ if (require.main === module) {
height: 10, height: 10,
inflowLevel: 3, inflowLevel: 3,
outflowLevel: 0.2, outflowLevel: 0.2,
overflowLevel: 3.2 overflowLevel: 3.2,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3
}, },
hydraulics: { hydraulics: {
refHeight: 'NAP', refHeight: 'NAP',
basinBottomRef: 0 basinBottomRef: 0,
minHeightBasedOn: 'outlet'
}, },
safety: { safety: {
enableDryRunProtection:false, enableDryRunProtection:false,
enableOverfillProtection:false enableHighVolumeSafety:false,
highVolumeSafetyThresholdPercent: 98
} }
}; };
} }
@@ -1036,4 +1166,4 @@ if (require.main === module) {
console.error('Demo failed:', err); console.error('Demo failed:', err);
}); });
} }
//*/ //*/

View File

@@ -27,6 +27,8 @@ function makeConfig(overrides = {}) {
inflowLevel: 3, inflowLevel: 3,
outflowLevel: 0.2, outflowLevel: 0.2,
overflowLevel: 4.5, overflowLevel: 4.5,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3,
}, },
hydraulics: { hydraulics: {
refHeight: 'NAP', refHeight: 'NAP',
@@ -36,12 +38,13 @@ function makeConfig(overrides = {}) {
control: { control: {
mode: 'levelbased', mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']), allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 }, levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
}, },
safety: { safety: {
enableDryRunProtection: false, enableDryRunProtection: false,
enableOverfillProtection: false, enableOverfillProtection: false,
dryRunThresholdPercent: 2, dryRunThresholdPercent: 2,
highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98, overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0, timeleftToFullOrEmptyThresholdSeconds: 0,
}, },
@@ -80,6 +83,10 @@ test('Basin geometry — derived values', async (t) => {
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } })); const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
assert.equal(ps2.basin.minVol, 30); 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) => { 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')); 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', () => { await t.test('outflowLevel >= inflowLevel flagged', () => {
const ps = new PumpingStation(makeConfig({ const ps = new PumpingStation(makeConfig({
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 }, 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); 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()); const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand ps.percControl = 42; // simulated previous demand
const demands = [];
ps.machineGroups['mgc1'] = { ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } }, config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {}, 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 ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased(); 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 ps = new PumpingStation(makeConfig());
const demands = []; const demands = [];
ps.machineGroups['mgc1'] = { ps.machineGroups['mgc1'] = {
@@ -244,14 +264,101 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
turnOffAllMachines: () => {}, turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); }, handleInput: async (_src, d) => { demands.push(d); },
}; };
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4 ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
await ps._controlLevelBased(); await ps._controlLevelBased('filling');
// lerp(3, [2,4], [0,100]) = 50 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.ok(Math.abs(ps.percControl - 50) < 1e-9);
assert.equal(demands.length, 1); assert.equal(demands.length, 1);
assert.ok(Math.abs(demands[0] - 50) < 1e-9); 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 when level crosses shiftLevel; foot moves to startLevel', 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,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Below shiftLevel: not yet armed → foot=inflowLevel, level 2.5 is in hold zone → 0%.
ps.calibratePredictedLevel(2.5);
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(ps._shiftArmed, false);
// Cross the shift trigger going up — at level >= shiftLevel, output saturates at 100%.
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, true);
assert.ok(ps.percControl >= 100 - 1e-9);
// Drop to midpoint of shifted ramp [start=2 .. shiftLevel=3.5] → x=0.5 → 50%.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Drop below startLevel → disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased();
assert.equal(ps._shiftArmed, false);
});
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 () => { await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
const ps = new PumpingStation(makeConfig()); const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = { ps.machineGroups['mgc1'] = {
@@ -275,6 +382,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
assert.equal(out.maxVolAtOverflow, 45); assert.equal(out.maxVolAtOverflow, 45);
assert.equal(out.minVolAtInflow, 30); assert.equal(out.minVolAtInflow, 30);
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9); 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)', () => { await t.test('includes state fields (direction, flowSource, timeleft)', () => {
const out = ps.getOutput(); const out = ps.getOutput();