Compare commits

2 Commits

Author SHA1 Message Date
Rene De Ren
de9a79b888 Hold-then-ramp shift semantics + shiftArmPercent + e2e tests
Runtime (specificClass.js):
- Replace the "shift left both ramp ends" geometry with a true
  hold-then-ramp hysteresis driven by output %, not level:
  • Up-curve % crosses shiftArmPercent on the way up → ARM.
  • Filling→draining transition while armed → capture the up-curve %
    at that moment as _shiftHoldValue.
  • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue
    (horizontal hold, matching the dashed segment in the SVG).
  • Draining + level in [start, shift] → output ramps holdValue → 0 %
    along the same curve shape (linear or log) as the up curve.
  • Draining + level < startLevel → 0 % AND disarm.
  • Returning to filling clears holdValue, stays armed; next drain
    transition captures a fresh hold so bouncing fills rearm cleanly.
  • Disarm only when level ≤ startLevel.
- New _curveShape(x) helper for shared linear/log shaping.
- Removed legacy _levelBasedRampStart / _levelBasedRampTop /
  _updateShiftArmed in favour of the inline state machine.

Adapter (nodeClass.js):
- Pipe shiftArmPercent through to control.levelbased.

Editor (pumpingStation.html + src/editor/):
- Add shiftArmPercent input row (% with unit) to the mode side panel
  (only shown when shifted ramp is enabled). Default 95 %.
- Add the horizontal arming-% line + label inside the mode SVG —
  this is the "% Threshold triggering shifted ramp down" line from
  the original drawing that had been missing.
- Redraw the shifted-down curve to match the SVG geometry literally:
  100 % flat from maxLevel → shiftLevel, then ramp shiftLevel →
  startLevel down to 0 %, OFF below startLevel. Preview shows the
  worst-case envelope (hold = 100 %); runtime hold is captured live.
- Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules
  preserved (start < shift ≤ max etc.).
- Auto-default shiftArmPercent to 95 when shift is enabled and the
  current value is missing or out of range.

Dashboard example (examples/basic-dashboard.flow.json):
- Parser now reads `level.predicted.atequipment.default` etc. The
  MeasurementContainer flatten format includes the implicit 'default'
  childId; consumers must include it. Comment in the parser points
  at the documenting source in generalFunctions.

Tests:
- test/basic: replace old level-armed-shift tests with two new ones
  that exercise the hold-then-ramp arming, capture, hold, ramp-down,
  disarm, and the bounce case (filling→draining→filling→draining
  captures a fresh hold each time).
- test/integration/shifted-ramp-end-to-end.test.js: new file. Drives
  Q_IN/Q_OUT through the full runtime tick with a controllable clock,
  asserting the same hysteresis path the dashboard exercises.
- test/integration/basic-dashboard-flow.test.js: fixture keys updated
  to the .default-suffixed form so they match the real flatten output.
56/56 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
Rene De Ren
8a6ca1baeb 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>
2026-05-05 19:29:34 +02:00
14 changed files with 2335 additions and 490 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": "// MeasurementContainer flat keys are `${type}.${variant}.${position}.${childId}`.\n// When PS writes without an explicit .child(), the childId is the literal\n// string 'default' — DON'T strip it. See generalFunctions/src/measurements/\n// MeasurementContainer.js getFlattenedOutput for details.\nconst fields = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\nconst snapshot = Object.assign({}, context.get('snapshot') || {}, fields);\ncontext.set('snapshot', snapshot);\nconst firstFinite = (...keys) => {\n for (const key of keys) {\n const value = Number(snapshot[key]);\n if (Number.isFinite(value)) return value;\n }\n return null;\n};\nconst level = firstFinite('level.predicted.atequipment.default', 'level.measured.atequipment.default');\nconst volume = firstFinite('volume.predicted.atequipment.default', 'volume.measured.atequipment.default');\nconst netFlow = firstFinite('netFlowRate.predicted.atequipment.default', 'netFlowRate.measured.atequipment.default');\nconst demand = firstFinite('percControl');\nconst safety = snapshot.safetyState || 'normal';\nconst direction = snapshot.direction || 'unknown';\nconst overflow = snapshot.isOverflowing === true || snapshot.isOverflowing === 'true';\nconst timeleft = Number(snapshot.timeleft);\nconst fmt = (value, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '-';\nreturn [\n level == null ? null : { topic: 'level', payload: level },\n volume == null ? null : { topic: 'volume', payload: volume },\n demand == null ? null : { topic: 'demand', payload: demand },\n netFlow == null ? null : { topic: 'net_flow', payload: netFlow },\n { topic: 'safety', payload: `${safety} | overflowing=${overflow}` },\n { topic: 'snapshot', payload: `level=${fmt(level)} m | volume=${fmt(volume)} m3 | demand=${fmt(demand, 0)}% | direction=${direction} | t=${Number.isFinite(timeleft) ? Math.round(timeleft) + ' s' : '-'}` }\n];",
"outputs": 6,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 980,
"y": 220,
"wires": [
[
"ps_chart_level"
],
[
"ps_chart_volume"
],
[
"ps_chart_demand"
],
[
"ps_chart_netflow"
],
[
"ps_text_safety"
],
[
"ps_text_snapshot"
]
]
},
{
"id": "ps_chart_level",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Level",
"label": "Level (m)",
"order": 1,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "m",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1230,
"y": 140,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "5",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#0c99d9"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_chart_volume",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Volume",
"label": "Volume (m3)",
"order": 2,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "m3",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1230,
"y": 200,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "50",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#2ca02c"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_chart_demand",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Demand",
"label": "Demand (%)",
"order": 3,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "%",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1230,
"y": 260,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "0",
"ymax": "120",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#d68910"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_chart_netflow",
"type": "ui-chart",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_trends",
"name": "Net Flow",
"label": "Net flow (m3/s)",
"order": 4,
"width": 4,
"height": 4,
"chartType": "line",
"category": "topic",
"xAxisType": "time",
"yAxisLabel": "m3/s",
"removeOlder": "15",
"removeOlderUnit": "60",
"x": 1240,
"y": 320,
"wires": [],
"showLegend": false,
"categoryType": "msg",
"xAxisProperty": "",
"xAxisPropertyType": "timestamp",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"yAxisProperty": "payload",
"yAxisPropertyType": "msg",
"xmin": "",
"xmax": "",
"ymin": "",
"ymax": "",
"bins": 10,
"action": "append",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"interpolation": "linear",
"className": "",
"colors": [
"#9467bd"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true
},
{
"id": "ps_text_safety",
"type": "ui-text",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_state",
"name": "Safety",
"label": "Safety",
"order": 1,
"width": 4,
"height": 1,
"format": "{{msg.payload}}",
"layout": "row-spread",
"x": 1230,
"y": 380,
"wires": []
},
{
"id": "ps_text_snapshot",
"type": "ui-text",
"z": "ps_tab_basic_dashboard",
"group": "ui_group_ps_state",
"name": "Snapshot",
"label": "Snapshot",
"order": 2,
"width": 8,
"height": 1,
"format": "{{msg.payload}}",
"layout": "row-spread",
"x": 1240,
"y": 440,
"wires": []
},
{
"id": "ps_debug_influx",
"type": "debug",
"z": "ps_tab_basic_dashboard",
"name": "Influx output",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 980,
"y": 320,
"wires": []
},
{
"id": "ps_debug_parent",
"type": "debug",
"z": "ps_tab_basic_dashboard",
"name": "Parent output",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 980,
"y": 380,
"wires": []
}
]

View File

@@ -8,8 +8,17 @@
| **Control Module** | `#a9daee` | zwart |
-->
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
<!-- Editor JS modules — see nodes/pumpingStation/src/editor/. Loaded in
dependency order: index.js (namespace + helpers) → diagrams → handlers. -->
<script src="/pumpingStation/editor/index.js"></script>
<script src="/pumpingStation/editor/basin-diagram.js"></script>
<script src="/pumpingStation/editor/mode-preview.js"></script>
<script src="/pumpingStation/editor/hover-couple.js"></script>
<script src="/pumpingStation/editor/oneditprepare.js"></script>
<script src="/pumpingStation/editor/oneditsave.js"></script>
<script>//test
RED.nodes.registerType("pumpingStation", {
@@ -22,8 +31,8 @@
simulator: { value: false },
basinVolume: { value: 1 }, // m³, total empty basin
basinHeight: { value: 1 }, // m, floor to top
inflowLevel: { value: 0.8 }, // m, centre of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, centre of outlet pipe above floor
inflowLevel: { value: 0.8 }, // m, bottom/invert of inlet pipe above floor
outflowLevel: { value: 0.2 }, // m, top of outlet/suction pipe above floor
overflowLevel: { value: 0.9 }, // m, overflow elevation
defaultFluid: { value: "wastewater" },
inletPipeDiameter: { value: 0.3 }, // m
@@ -35,9 +44,11 @@
temperatureReferenceDegC: { value: 15 },
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
enableDryRunProtection: { value: true },
enableOverfillProtection: { value: true },
enableHighVolumeSafety: { value: true },
enableOverfillProtection: { value: true }, // deprecated alias
dryRunThresholdPercent: { value: 2 },
overfillThresholdPercent: { value: 98 },
highVolumeSafetyThresholdPercent: { value: 98 },
overfillThresholdPercent: { value: 98 }, // deprecated alias
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
@@ -67,7 +78,12 @@
distanceDescription: { value: "" },
// control strategy
controlMode: { value: "none" },
controlMode: { value: "levelbased" },
levelCurveType: { value: "linear" },
logCurveFactor: { value: 9 },
enableShiftedRamp: { value: false },
shiftLevel: { value: 0 },
shiftArmPercent: { value: 95 },
startLevel: { value: null },
minLevel: { value: null },
maxLevel: { value: null },
@@ -86,296 +102,11 @@
return this.positionIcon + " PumpingStation";
},
oneditprepare: function() {
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
window.EVOLV.nodes.pumpingStation.initEditor(this);
} else {
setTimeout(waitForMenuData, 50);
}
};
// Wait for the menu data to be ready before initializing the editor
waitForMenuData();
// NODE SPECIFIC
document.getElementById("node-input-basinVolume");
document.getElementById("node-input-basinHeight");
document.getElementById("node-input-inflowLevel");
document.getElementById("node-input-outflowLevel");
document.getElementById("node-input-overflowLevel");
document.getElementById("node-input-refHeight");
document.getElementById("node-input-basinBottomRef");
const refHeightEl = document.getElementById("node-input-refHeight");
if (refHeightEl) {
refHeightEl.value = this.refHeight || "NAP";
}
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
if (minHeightBasedOnEl) {
minHeightBasedOnEl.value = this.minHeightBasedOn;
}
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
const toggleInput = (toggleEl, inputEl) => {
if (!toggleEl || !inputEl) { return; }
inputEl.disabled = !toggleEl.checked;
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
};
if (dryRunToggle && dryRunPercent) {
dryRunToggle.checked = !!this.enableDryRunProtection;
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
toggleInput(dryRunToggle, dryRunPercent);
}
if (overfillToggle && overfillPercent) {
overfillToggle.checked = !!this.enableOverfillProtection;
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
toggleInput(overfillToggle, overfillPercent);
}
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
if (timeLeftInput) {
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
? this.timeleftToFullOrEmptyThresholdSeconds
: 0;
}
// control mode toggle UI
const toggleModeSections = (val) => {
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
const active = document.getElementById(`ps-mode-${val}`);
if (active) active.style.display = '';
};
const modeSelect = document.getElementById('node-input-controlMode');
if (modeSelect) {
modeSelect.value = this.controlMode || 'none';
toggleModeSections(modeSelect.value);
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
}
const setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
};
setNumberField('node-input-startLevel', this.startLevel);
setNumberField('node-input-minLevel', this.minLevel);
setNumberField('node-input-maxLevel', this.maxLevel);
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
setNumberField('node-input-flowDeadband', this.flowDeadband);
// Interactive diagram: place every threshold line/input at its
// proportional y on the tank, plus compute derived safety levels
// (dryRunLevel, overfillLevel) that are shown both in the diagram
// and next to the safety-% fields. Same formulas as
// specificClass._validateThresholdOrdering.
const DIAG = { topY: 40, botY: 380 };
const fNum = (id) => {
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
return Number.isFinite(v) ? v : null;
};
const yForLevel = (val, basinH) => {
if (val == null || !basinH) return null;
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
};
// Place a row — line, label, input, unit all share the same y.
// The diagram is a schematic ordered list (value order is
// preserved, but the y-positions are distributed with a
// guaranteed minimum gap for readability), not a strictly
// proportional rendering.
const placeItem = (id, y) => {
const line = document.getElementById(`ps-line-${id}`);
const label = document.getElementById(`ps-label-${id}`);
const unit = document.getElementById(`ps-unit-${id}`);
const fo = document.getElementById(`ps-fo-${id}`);
const sub = document.getElementById(`ps-sub-${id}`);
const lead = document.getElementById(`ps-leader-${id}`);
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
if (label) label.setAttribute('y', y + 4);
if (unit) unit.setAttribute('y', y + 4);
if (fo) fo.setAttribute('y', y - 11);
if (sub) sub.setAttribute('y', y + 15);
if (lead) lead.setAttribute('visibility', 'hidden');
};
const redraw = () => {
const basinH = fNum('basinHeight') || 5;
// Derived safety levels (participate in the right-column stack)
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
const ovfPct = fNum('overfillThresholdPercent');
const ovf = fNum('overflowLevel');
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
// Right-column stack. TWO anchors: basinHeight pinned at the
// tank rim (top) and outflowLevel pinned at its proportional y
// (bottom). Everything between is nudged to maintain a minimum
// vertical gap via two passes — top-down from the rim, then
// bottom-up from the outlet — so the dashed lines keep their
// value-order and outlet stays near the floor where it belongs.
const items = [
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), basinH) },
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
].filter(it => it.yIdeal != null);
const GAP = 36;
items.sort((a, b) => a.yIdeal - b.yIdeal);
for (const it of items) it.y = it.yIdeal;
// Pass 1: top-down — push DOWN to maintain GAP; pinned items don't move
for (let i = 1; i < items.length; i++) {
if (items[i].pinned) continue;
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
}
// Pass 2: bottom-up — push UP so outflow's pin propagates up the stack
for (let i = items.length - 2; i >= 0; i--) {
if (items[i].pinned) continue;
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
}
for (const it of items) placeItem(it.id, it.y);
// Zone labels between adjacent thresholds (italic, centered).
// Hidden if either bracketing threshold is missing, or the gap
// is too small to read (< 14 px).
const placeZone = (zoneId, topId, botId) => {
const el = document.getElementById(`ps-zone-${zoneId}`);
if (!el) return;
const top = items.find(it => it.id === topId);
const bot = items.find(it => it.id === botId);
if (!top || !bot || (bot.y - top.y) < 14) {
el.setAttribute('visibility', 'hidden'); return;
}
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
el.setAttribute('visibility', 'visible');
};
placeZone('spare', 'overflowLevel', 'maxLevel');
placeZone('sewage', 'maxLevel', 'startLevel');
placeZone('buffer1', 'startLevel', 'minLevel');
placeZone('buffer2', 'minLevel', 'dryRunLevel');
// "Dead volume" sits inside the blue band between outflowLevel and the floor
const outflowPinned = items.find(it => it.id === 'outflowLevel');
const deadLbl = document.getElementById('ps-zone-dead');
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
deadLbl.setAttribute('visibility', 'visible');
} else if (deadLbl) {
deadLbl.setAttribute('visibility', 'hidden');
}
// Inlet arrow — sole item on the left, no stacking concerns
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
if (inflowY != null) {
const line = document.getElementById('ps-line-inflowLevel');
const lbl = document.getElementById('ps-label-inflowLevel');
const sub = document.getElementById('ps-sub-inflowLevel');
const fo = document.getElementById('ps-fo-inflowLevel');
const unit = document.getElementById('ps-unit-inflowLevel');
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
if (lbl) lbl.setAttribute('y', inflowY - 4);
if (sub) sub.setAttribute('y', inflowY + 8);
if (fo) fo.setAttribute('y', inflowY - 11);
if (unit) unit.setAttribute('y', inflowY + 4);
}
// Dead-volume band: from the (possibly-nudged) outflow line
// down to the floor. Use the nudged y so the band meets the
// outflow line exactly.
const outflowItem = items.find(it => it.id === 'outflowLevel');
const deadvol = document.getElementById('ps-deadvol');
if (deadvol && outflowItem) {
deadvol.setAttribute('y', outflowItem.y);
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
}
// dryRunLevel label text (derived, read-only)
const dryLbl = document.getElementById('ps-label-dryRunLevel');
if (dryLbl) dryLbl.textContent = dryLvl != null
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
: 'dryRunLevel ≈ — m (safety — from %)';
// Safety-section readouts (second view, beneath the diagram)
const d1 = document.getElementById('derived-dryRunLevel');
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
const d2 = document.getElementById('derived-overfillLevel');
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
// Ordering warning ribbon
const warn = document.getElementById('ps-warning');
const issues = [];
const pairs = [
['outflowLevel', 'inflowLevel', '<'],
['inflowLevel', 'overflowLevel', '<'],
['minLevel', 'startLevel', '<='],
['startLevel', 'maxLevel', '<'],
['maxLevel', 'overflowLevel', '<='],
];
for (const [a, b, op] of pairs) {
const av = fNum(a), bv = fNum(b);
if (av == null || bv == null) continue;
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
}
if (warn) {
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
else { warn.setAttribute('visibility', 'hidden'); }
}
};
['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'].forEach((id) => {
const el = document.getElementById(`node-input-${id}`);
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
});
setTimeout(redraw, 60);
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
oneditprepare: function () {
window.PSEditor.oneditprepare.call(this);
},
oneditsave: function () {
const node = this;
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
//node specific
node.refHeight = document.getElementById("node-input-refHeight").value || "NAP";
node.minHeightBasedOn = document.getElementById("node-input-minHeightBasedOn").value || "outlet";
node.simulator = document.getElementById("node-input-simulator").checked;
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
.forEach(field => {
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
});
node.refHeight = document.getElementById("node-input-refHeight").value || "";
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
// control strategy
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
node.startLevel = parseNum('node-input-startLevel');
node.minLevel = parseNum('node-input-minLevel');
node.maxLevel = parseNum('node-input-maxLevel');
node.flowSetpoint = parseNum('node-input-flowSetpoint');
node.flowDeadband = parseNum('node-input-flowDeadband');
window.PSEditor.oneditsave.call(this);
},
});
@@ -395,123 +126,199 @@
<hr>
<h4>Basin parameters</h4>
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Enter values next to each line the diagram scales to whatever you enter.</p>
<p style="font-size:12px;color:#777;margin:0 0 8px 0;">Heights are measured from the basin floor (0 m). Each input on the left controls a line in the diagram on the right hover an input to highlight its line.</p>
<style>
#ps-basin-diagram input[type=number] {
width: 100%; height: 20px; box-sizing: border-box;
font-size: 11px; padding: 1px 4px; margin: 0;
border: 1px solid #ccc; border-radius: 3px; background: #fff;
/* Two-column layout: stacked colour-coded inputs on the left,
SVG on the right. Hover an input row → its paired SVG line
(referenced by data-couples-line) gets a thicker stroke. */
.ps-diag { display:flex; gap: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>
<svg id="ps-basin-diagram" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 430"
style="display:block;width:100%;max-width:540px;margin:0 0 12px 0;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
font-family="Arial,sans-serif" font-size="11">
<defs>
<marker id="ps-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79" />
</marker>
</defs>
<!--
============================================================
BASIN DIAGRAM (ps-basin-diagram)
============================================================
Coordinate system: SVG viewBox is 520 (wide) × 430 (tall).
Origin (0,0) is top-left. +x goes right. +y goes DOWN.
Bigger y = lower on screen.
<!-- Tank body -->
<rect x="200" y="40" width="120" height="340" fill="#F0F8FF" stroke="#333" stroke-width="1.5" />
<!-- Dead-volume band (y + height updated dynamically below outflowLevel) -->
<rect id="ps-deadvol" x="201" width="118" fill="#AACCE0" />
<!-- basinVolume pinned above the rim -->
<text id="ps-label-basinVolume" x="330" y="19" fill="#333" font-weight="600">basin volume</text>
<foreignObject id="ps-fo-basinVolume" x="425" y="4" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinVolume" min="0" step="0.1" />
</foreignObject>
<text id="ps-unit-basinVolume" x="500" y="19" fill="#555"></text>
X-LANES (all viewBox units, edit any of these to shift a column):
x 5..75 left input column (inlet number input)
x = 80 inlet unit "m"
x = 135 inlet text labels (right-aligned, anchor at x)
x = 140..200 inlet arrow (line + arrow head into tank)
x = 200..320 tank body (rect.x=200 width=120) interior 201..319
x = 195/325 threshold tick lines (extend 5 px outside tank)
x = 260 mid-tank zone labels (centered)
x = 320..360 outlet arrow
x = 330 right-side label column ("overflowLevel", "Outlet", )
x = 365 outlet sub-text column
x = 425..495 right input column (foreignObject inputs, width=70)
x = 500 right unit column ("m", "m³")
<!-- Zone labels (mid-tank italic, positioned dynamically at midpoint between adjacent thresholds) -->
<text id="ps-zone-spare" x="260" text-anchor="middle" fill="#B78200" font-size="10" font-style="italic" visibility="hidden">Spare volume before spilling</text>
<text id="ps-zone-sewage" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text>
<text id="ps-zone-buffer1" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<text id="ps-zone-buffer2" x="260" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<text id="ps-zone-dead" x="260" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text>
Y-COORDINATES:
y = 40 tank rim (basinHeight line)
y = 380 tank floor / datum
y = 410 ordering warning ribbon
y = 19,44 "basin volume" / "basinHeight" labels (static)
Threshold rows (overflowLevel, highVolumeSafetyLevel, inflowLevelGuide,
dryRunLevel, outflowLevel, basinHeight tick) get y assigned
DYNAMICALLY by the redraw() function around line 250-340 below.
Their input row may be NUDGED off ideal-y to avoid overlap; a leader
line (ps-leader-*) is then drawn between threshold y and input y.
Zone-label rows (ps-zone-*) get y assigned dynamically to the midpoint
between adjacent thresholds; they hide if the gap is too small.
HOW TO NUDGE OVERLAPPING LABELS:
- For STATIC y values (hardcoded below): edit the inline y attribute.
- For DYNAMIC y values: search redraw() for the element id and adjust
the layout math (e.g. NUDGE_PX or the threshold-stack ordering).
- For x: every label column above can be shifted by editing the inline
x attribute on the relevant <text>/<line>/<foreignObject>.
<!-- basinHeight always at tank rim (y=40 in viewBox coords) -->
<line id="ps-line-basinHeight" x1="195" y1="40" x2="325" y2="40" stroke="#333" stroke-width="1.5" />
<text id="ps-label-basinHeight" x="330" y="44" fill="#333">basinHeight</text>
<foreignObject id="ps-fo-basinHeight" x="425" y="29" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-basinHeight" min="0" step="0.1" />
</foreignObject>
<text id="ps-unit-basinHeight" x="500" y="44" fill="#555">m</text>
Note: dynamic line/label positioning lives in oneditprepare redraw()
further up in this file. Changing only the inline y here will be
overridden on first redraw for any element whose id appears in redraw().
============================================================
-->
<div class="ps-diag" id="ps-basin-wrap">
<!-- LEFT: stacked colour-coded inputs. Hover a row its paired SVG
line (data-couples-line) is highlighted in the diagram. -->
<div class="ps-diag-side">
<div class="ps-row" data-stroke="#333" style="cursor:default;">
<div><label>basinVolume</label><div class="ps-sub">total empty volume (no marker)</div></div>
<input type="number" id="node-input-basinVolume" min="0" step="0.1" />
<span class="ps-unit"></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 -->
<line id="ps-line-overflowLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-overflowLevel" x="330" fill="#C0392B">overflowLevel</text>
<foreignObject id="ps-fo-overflowLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-overflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-overflowLevel" x="500" fill="#555">m</text>
<!-- Mid-tank zone labels — centred at x=180. -->
<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-zone-sewage" x="180" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Sewage + tank buffer</text>
<text id="ps-zone-buffer1" x="180" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<text id="ps-zone-buffer2" x="180" text-anchor="middle" fill="#1F4E79" font-size="10" font-style="italic" visibility="hidden">Tank buffer</text>
<text id="ps-zone-dead" x="180" text-anchor="middle" fill="#444" font-size="10" font-style="italic" visibility="hidden">Dead volume</text>
<!-- maxLevel -->
<line id="ps-line-maxLevel" x1="195" x2="325" stroke="#D68910" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-maxLevel" x="330" fill="#D68910">maxLevel</text>
<foreignObject id="ps-fo-maxLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-maxLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-maxLevel" x="500" fill="#555">m</text>
<!-- basinHeight tick at tank rim (y=40, static). -->
<line id="ps-line-basinHeight" x1="115" y1="40" x2="245" y2="40" stroke="#333" stroke-width="1.5" />
<text id="ps-label-basinHeight" x="250" y="44" fill="#333">basinHeight</text>
<!-- startLevel -->
<line id="ps-line-startLevel" x1="195" x2="325" stroke="#1E8449" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-startLevel" x="330" fill="#1E8449">startLevel</text>
<foreignObject id="ps-fo-startLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-startLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-startLevel" x="500" fill="#555">m</text>
<line id="ps-line-overflowLevel" x1="115" x2="245" stroke="#C0392B" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-overflowLevel" x="250" fill="#C0392B">overflowLevel</text>
<!-- Inlet arrow + input on the left -->
<line id="ps-line-inflowLevel" x1="140" x2="200" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-inflowLevel" x="135" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</text>
<text id="ps-sub-inflowLevel" x="135" text-anchor="end" fill="#777" font-size="9">bottom of pipe</text>
<foreignObject id="ps-fo-inflowLevel" x="5" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-inflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-inflowLevel" x="80" fill="#555">m</text>
<line id="ps-line-highVolumeSafetyLevel" x1="115" x2="245" stroke="#D68910" stroke-dasharray="1 2" stroke-width="1" opacity="0.7" />
<text id="ps-label-highVolumeSafetyLevel" x="250" fill="#D68910" font-size="10" font-style="italic">highVolumeSafety</text>
<!-- minLevel -->
<line id="ps-line-minLevel" x1="195" x2="325" stroke="#6C3483" stroke-dasharray="4 2" stroke-width="1.5" />
<text id="ps-label-minLevel" x="330" fill="#6C3483">minLevel</text>
<foreignObject id="ps-fo-minLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-minLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-minLevel" x="500" fill="#555">m</text>
<line id="ps-line-inflowLevelGuide" x1="120" x2="240" stroke="#1F4E79" stroke-dasharray="2 3" stroke-width="1" opacity="0.55" />
<text id="ps-label-inflowLevelGuide" x="250" fill="#1F4E79" font-size="10" font-style="italic">inlet invert</text>
<!-- dryRunLevel (derived, read-only) -->
<line id="ps-line-dryRunLevel" x1="195" x2="325" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
<text id="ps-label-dryRunLevel" x="330" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel m (safety from %)</text>
<line id="ps-line-inflowLevel" x1="60" x2="120" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-inflowLevel" x="55" text-anchor="end" fill="#1F4E79" font-weight="bold">Inlet</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-outflowLevel" x1="320" x2="360" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-outflowLevel" x="365" fill="#1F4E79" font-weight="bold">Outlet</text>
<text id="ps-sub-outflowLevel" x="365" fill="#777" font-size="9">top of pipe</text>
<foreignObject id="ps-fo-outflowLevel" x="425" width="70" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" type="number" id="node-input-outflowLevel" min="0" step="0.01" />
</foreignObject>
<text id="ps-unit-outflowLevel" x="500" fill="#555">m</text>
<line id="ps-line-dryRunLevel" x1="115" x2="245" stroke="#C0392B" stroke-dasharray="1 2" stroke-width="1" opacity="0.6" />
<text id="ps-label-dryRunLevel" x="250" fill="#C0392B" font-size="10" font-style="italic">dryRunLevel</text>
<!-- Floor / datum -->
<line x1="195" y1="380" x2="325" y2="380" stroke="#000" stroke-width="2" />
<text x="330" y="384" fill="#000">0 m (datum)</text>
<line id="ps-line-outflowLevel" x1="240" x2="280" stroke="#1F4E79" stroke-width="2" marker-end="url(#ps-arrow)" />
<text id="ps-label-outflowLevel" x="285" fill="#1F4E79" font-weight="bold">Outlet</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 -->
<line id="ps-leader-basinHeight" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-overflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-maxLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-startLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-minLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-dryRunLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<line id="ps-leader-outflowLevel" x1="0" y1="0" x2="0" y2="0" stroke="#bbb" stroke-width="0.6" stroke-dasharray="2 2" visibility="hidden" />
<!-- Floor / datum -->
<line x1="115" y1="380" x2="245" y2="380" stroke="#000" stroke-width="2" />
<text x="250" y="384" fill="#000">0 m (datum)</text>
<!-- Ordering-warning ribbon -->
<text id="ps-warning" x="180" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
</svg>
</div>
<!-- Ordering-warning ribbon -->
<text id="ps-warning" x="260" y="410" text-anchor="middle" fill="#C0392B" font-size="10" font-style="italic" visibility="hidden"></text>
</svg>
<hr>
@@ -519,39 +326,176 @@
<div class="form-row">
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
<select id="node-input-controlMode">
<option value="none">None / Manual</option>
<option value="levelbased">Level-based</option>
<option value="flowbased">Flow-based</option>
<option value="manual">Manual</option>
</select>
</div>
<div id="ps-mode-levelbased" class="ps-mode-section">
<p style="font-size:12px;color:#777;margin:0;">Level-based uses <code>minLevel</code> / <code>startLevel</code> / <code>maxLevel</code> from the diagram above.</p>
<div class="form-row">
<label for="node-input-levelCurveType">Curve</label>
<select id="node-input-levelCurveType" style="width:60%;">
<option value="linear">Linear</option>
<option value="log">Log - fast early response</option>
</select>
</div>
<div class="form-row" id="ps-log-factor-row" style="display:none;">
<label for="node-input-logCurveFactor">Log shape factor</label>
<input type="number" id="node-input-logCurveFactor" min="0.001" step="0.1" style="width:100px;" />
</div>
<div class="form-row">
<label for="node-input-enableShiftedRamp" style="width:auto;">
<input type="checkbox" id="node-input-enableShiftedRamp" style="width:auto;vertical-align:middle;margin-right:6px;" />
Enable shifted ramp (hysteresis)
</label>
</div>
<div id="ps-mode-validation" style="display:none;color:#C0392B;font-size:11px;margin:4px 0 8px 0;border:1px solid #C0392B;border-radius:3px;padding:6px 8px;background:#FDECEA;"></div>
<!--
============================================================
LEVEL-BASED MODE PREVIEW (ps-levelbased-mode-diagram)
============================================================
Coordinate system: SVG viewBox is 430 (wide) × 185 (tall).
Origin (0,0) top-left. +x right. +y DOWN (so y=24 is HIGH on screen,
y=158 is at the baseline).
X-AXIS (level, in viewBox px) — controlled by redrawModeDiagram() in
the oneditprepare script above. The function maps the user's
startLevel/inflowLevel/maxLevel/shiftLevel onto the px window
x0=52 (left axis) x1=390 (right end of plot).
DO NOT hardcode x for ps-mode-line-* / ps-mode-label-*; they're
rewritten on every input change.
Y-AXIS (process demand %):
y=24 100% (top of plot)
y=140 0% (baseline / x-axis)
y=160 OFF baseline (pink dashed)
y=180 axis labels under the plot ("dry run","start","inlet","max","overflow","shift")
y=205 legend captions (one row, BELOW axis labels moved here to stop
colliding with the title row at y=14)
y=14 curve-type title only ("linear curve" / "log curve"), centered.
WHAT IS STATIC vs DYNAMIC:
STATIC (edit inline below): viewBox bounds, axis lines, "0%"/"100%"
tick labels, in-plot caption x/y, axis-label y=176.
DYNAMIC (edit in JS): every ps-mode-line-*, ps-mode-label-* x;
ps-mode-curve-up/down points; visibility of shift elements.
HOW TO NUDGE OVERLAPPING TEXT:
- Move the curve-type caption: edit the x="220" y="18" on
#ps-mode-curve-label.
- Move axis labels (start/inlet/max/shift) UP or DOWN: edit y="176".
(To move them left/right relative to the line, edit redrawModeDiagram
in the script the x is set there.)
- Move the legend captions: edit x="280" y="54" / y="72" on
#ps-mode-curve-up-label / #ps-mode-curve-down-label.
- To resize the plot box, change viewBox + the x0/x1/y0/y1 constants
in redrawModeDiagram() to match.
============================================================
-->
<div class="ps-diag" id="ps-mode-wrap">
<!-- LEFT side-panel: only the level-based mode's editable inputs +
read-only displays for derived/related levels (so user has all
level context in one column). Hover-coupled to the SVG markers. -->
<div class="ps-diag-side">
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-dryRunLevel">
<div><label>dryRunLevel</label><div class="ps-sub">derived</div></div>
<span id="ps-mode-readout-dryRun" class="ps-readonly-val"> m</span>
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#1E8449" data-couples-line="ps-mode-line-startLevel">
<div><label>startLevel</label><div class="ps-sub">pump-on threshold</div></div>
<input type="number" id="node-input-startLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#1F4E79" data-couples-line="ps-mode-line-inflowLevel">
<div><label>inflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-inflow" class="ps-readonly-val"> m</span>
<span class="ps-unit">m</span>
</div>
<div class="ps-row" data-stroke="#D68910" data-couples-line="ps-mode-line-maxLevel">
<div><label>maxLevel</label><div class="ps-sub">100% saturation</div></div>
<input type="number" id="node-input-maxLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" id="ps-shiftLevel-row" data-stroke="#D68910" data-couples-line="ps-mode-line-shiftLevel" style="display:none;">
<div><label>shiftLevel</label><div class="ps-sub">held output drops here</div></div>
<input type="number" id="node-input-shiftLevel" min="0" step="0.01" />
<span class="ps-unit">m</span>
</div>
<div class="ps-row" id="ps-shiftArmPercent-row" data-stroke="#D68910" data-couples-line="ps-mode-line-armPercent" style="display:none;">
<div><label>shiftArmPercent</label><div class="ps-sub">arms when output % crosses this</div></div>
<input type="number" id="node-input-shiftArmPercent" min="0" max="100" step="1" />
<span class="ps-unit">%</span>
</div>
<div class="ps-row ps-readonly" data-stroke="#C0392B" data-couples-line="ps-mode-line-overflowLevel">
<div><label>overflowLevel</label><div class="ps-sub">from basin above</div></div>
<span id="ps-mode-readout-overflow" class="ps-readonly-val"> m</span>
<span class="ps-unit">m</span>
</div>
</div>
<svg id="ps-levelbased-mode-diagram" class="ps-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 215"
style="display:block;width:100%;max-width:540px;background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
font-family="Arial,sans-serif" font-size="11">
<!-- ZONE BANDS drawn FIRST so they sit behind axes and curves.
x is set DYNAMICALLY by redrawModeDiagram(); y/height span the full plot (24..160).
Order from leftmost to rightmost: dryRun (red) | safetyLow (orange) | safe (green) |
safetyHigh (orange) | overflow (red).
-->
<rect id="ps-zone-dryRun" y="24" height="136" fill="#fdecea" />
<rect id="ps-zone-safetyLow" y="24" height="136" fill="#fef5e7" />
<rect id="ps-zone-safe" y="24" height="136" fill="#eafaf1" />
<rect id="ps-zone-safetyHigh" y="24" height="136" fill="#fef5e7" />
<rect id="ps-zone-overflow" y="24" height="136" fill="#fdecea" />
<!-- X-axis (0% baseline) at y=140; y axis at x=52 (top y=24). Plot range: y=24..140. -->
<line x1="52" y1="140" x2="402" y2="140" stroke="#333" />
<line x1="52" y1="140" x2="52" y2="24" stroke="#333" />
<!-- OFF tier baseline at y=160 (20px below 0% baseline). pink line drawn dynamically by curve. -->
<line x1="52" y1="160" x2="402" y2="160" stroke="#E08080" stroke-dasharray="2 3" />
<!-- Y-axis tick labels (x=4, right-aligned via text-anchor="end" at x=50 for tighter alignment). -->
<text x="50" y="27" text-anchor="end" fill="#333">100%</text>
<text x="50" y="143" text-anchor="end" fill="#333">0%</text>
<text x="50" y="163" text-anchor="end" fill="#E08080">OFF</text>
<!-- Plot title above 100% line. -->
<text id="ps-mode-curve-label" x="220" y="14" text-anchor="middle" fill="#555">linear curve</text>
<!-- Curves drawn dynamically. Up curve foot=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;" />
<!-- Horizontal arming-% line y is set DYNAMICALLY by the JS to the
shiftArmPercent value (in plot-y space). Spans full plot width. -->
<line id="ps-mode-line-armPercent" x1="52" x2="402" stroke="#D68910" stroke-dasharray="4 3" stroke-width="1" opacity="0.7" style="display:none;" />
<text id="ps-mode-label-armPercent" x="404" text-anchor="start" fill="#D68910" font-size="9" style="display:none;">arm%</text>
<!-- 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 (held @100% then ramp shiftstart)</text>
</svg>
</div>
</div>
<div id="ps-mode-flowbased" class="ps-mode-section" style="display:none">
<div class="form-row">
<label for="node-input-flowSetpoint">Flow setpoint</label>
<input type="number" id="node-input-flowSetpoint" placeholder="m3/h" />
</div>
<div class="form-row">
<label for="node-input-flowDeadband">Deadband</label>
<input type="number" id="node-input-flowDeadband" placeholder="m3/h" />
</div>
<div id="ps-mode-manual" class="ps-mode-section" style="display:none">
<p style="font-size:12px;color:#777;margin:0;">Manual mode accepts external <code>Qd</code> demand commands and does not compute demand from basin level.</p>
</div>
<hr>
<h4>Reference</h4>
<!-- Reference data -->
<div class="form-row">
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
<select id="node-input-minHeightBasedOn" style="width:60%;">
<option value="inlet">Inlet Elevation</option>
<option value="outlet">Outlet Elevation</option>
</select>
</div>
<!-- Reference data basinBottomRef moved into basin side-panel above. -->
<div class="form-row">
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
<select id="node-input-refHeight" style="width:60%;">
@@ -559,21 +503,11 @@
</select>
</div>
<div class="form-row">
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin floor above datum (m)</label>
<input type="number" id="node-input-basinBottomRef" step="0.01" />
</div>
<hr>
<h4>Safety</h4>
<!-- Safety settings -->
<div class="form-row">
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
<input type="number" id="node-input-timeleftToFullOrEmptyThresholdSeconds" min="0" step="1" />
</div>
<div class="form-row">
<label for="node-input-enableDryRunProtection">
<i class="fa fa-shield"></i> Dry-run Protection
@@ -588,16 +522,16 @@
</div>
<div class="form-row">
<label for="node-input-enableOverfillProtection">
<i class="fa fa-exclamation-triangle"></i> Overfill Protection
<label for="node-input-enableHighVolumeSafety">
<i class="fa fa-exclamation-triangle"></i> High-volume Safety
</label>
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" />
<span>Stop filling when approaching overflow</span>
<input type="checkbox" id="node-input-enableHighVolumeSafety" style="width:20px;vertical-align:baseline;" />
<span>Act before physical overflow</span>
</div>
<div class="form-row">
<label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label>
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
<span id="derived-overfillLevel" style="margin-left:8px;color:#777;font-size:12px;"> overfillLevel m</span>
<label for="node-input-highVolumeSafetyThresholdPercent" style="padding-left:20px;">High-volume Safety (%)</label>
<input type="number" id="node-input-highVolumeSafetyThresholdPercent" min="0" max="100" step="0.1" style="width:80px;" />
<span id="derived-highVolumeSafetyLevel" style="margin-left:8px;color:#777;font-size:12px;"> highVolumeSafetyLevel m</span>
</div>
<hr>

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 path = require('path');
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions');
@@ -37,4 +38,16 @@ module.exports = function(RED) {
}
});
// Editor JS modules — loaded by pumpingStation.html via <script src=...> tags.
// Files live in src/editor/. Filename is restricted to a safe charset to
// prevent path-traversal.
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
if (err && !res.headersSent) res.status(404).send('// editor module not found');
});
});
};

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

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

@@ -0,0 +1,288 @@
// 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 armRaw = fNum('shiftArmPercent');
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
// Plot window is FIXED relative to basin geometry so that moving any
// single level slides only that line, not all the others. Lower bound
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
// if overflow isn't set) plus a small margin.
const upperRefs = [max, overflow].filter(Number.isFinite);
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
const pad = Math.max(upperBase * 0.05, 0.1);
const levelMin = 0;
const levelMax = upperBase + pad;
// Plot rectangle (viewBox px).
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
const yOffPx = 160;
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
const scale = (x) => {
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
return clamped;
};
// Path with three flat regions and a ramp:
// [levelMin..startX] OFF (pump off; below startLevel)
// [startX..footX] 0 % (system armed but not yet ramping)
// [footX..topX] ramp (linear or log scaled 0..100 %)
// [topX..levelMax] 100 % (saturated)
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
const buildPath = (startX, footX, topX) => {
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
const pts = [];
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(0)}`);
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const level = footX + t * (topX - footX);
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
}
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
// Up curve: same as before.
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));
// Shifted-DOWN curve (only when shift enabled): represents the
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
// then linear/log ramp from (shiftLevel, 100 %) down to
// (startLevel, 0 %), then OFF below startLevel.
// Real runtime hold value depends on where direction flips, so the
// preview shows the maximum extent.
const buildShiftedDown = () => {
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
const pts = [];
// OFF baseline far-left to startLevel
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
// Jump 0 % at startLevel
pts.push(`${xFor(start)},${yForPct(0)}`);
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const lvl = start + t * (shift - start);
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
}
// Held at 100 % from shift → far-right
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
if (down) {
if (shiftEnabled) {
down.setAttribute('points', buildShiftedDown());
down.style.display = '';
if (downLabel) downLabel.style.display = '';
} else {
down.setAttribute('points', '');
down.style.display = 'none';
if (downLabel) downLabel.style.display = 'none';
}
}
// Horizontal arming-% line — only meaningful when shift enabled.
const armLine = document.getElementById('ps-mode-line-armPercent');
const armLabel = document.getElementById('ps-mode-label-armPercent');
if (armLine && armLabel) {
if (shiftEnabled) {
const yArm = yForPct(armPct);
armLine.setAttribute('y1', yArm);
armLine.setAttribute('y2', yArm);
armLabel.setAttribute('y', yArm - 2);
armLabel.textContent = `arm ${Math.round(armPct)}%`;
armLine.style.display = '';
armLabel.style.display = '';
} else {
armLine.style.display = 'none';
armLabel.style.display = 'none';
}
}
// Vertical level markers + 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 armRow = document.getElementById('ps-shiftArmPercent-row');
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
const logRow = document.getElementById('ps-log-factor-row');
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
// Auto-default shiftLevel when shift is enabled and current value
// is missing/out-of-range. Visible default avoids a hidden ramp.
const shiftInput = document.getElementById('node-input-shiftLevel');
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
const cur = parseFloat(shiftInput.value);
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
shiftInput.value = (max * 0.9).toFixed(2);
}
}
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
// current value is missing / out of [0, 100].
const armInput = document.getElementById('node-input-shiftArmPercent');
if (shiftEnabled && armInput) {
const cur = parseFloat(armInput.value);
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
armInput.value = 95;
}
}
// Validation: 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 armVal = Number(armInput?.value);
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
issues.push('shiftArmPercent must be in (0, 100]');
}
const warnBox = document.getElementById('ps-mode-validation');
if (warnBox) {
if (issues.length) {
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
warnBox.style.display = '';
} else {
warnBox.style.display = 'none';
}
}
window._psModeValidationIssues = issues;
// Read-only readouts in the side panel — number only; the row's
// .ps-unit span already shows "m".
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
const setText = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = fmt(val);
};
setText('ps-mode-readout-dryRun', dryRun);
setText('ps-mode-readout-inflow', inlet);
setText('ps-mode-readout-overflow', overflow);
},
};
})();

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

@@ -0,0 +1,103 @@
// PumpingStation editor — oneditprepare entry. Wires up form-field
// initialization, control-mode toggle, safety toggles, and binds
// redraws for the basin diagram + level-based mode preview.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
ns.oneditprepare = function () {
const node = this;
// Wait for menu data (asset/logger/position dropdowns) before init.
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
window.EVOLV.nodes.pumpingStation.initEditor(node);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
const refHeightEl = document.getElementById('node-input-refHeight');
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
// Safety toggle pairs — each toggle enables/disables its threshold input.
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
const toggleInput = (toggleEl, inputEl) => {
if (!toggleEl || !inputEl) return;
inputEl.disabled = !toggleEl.checked;
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
};
if (dryRunToggle && dryRunPercent) {
dryRunToggle.checked = !!node.enableDryRunProtection;
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
toggleInput(dryRunToggle, dryRunPercent);
}
if (highVolumeToggle && highVolumePercent) {
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
? !!node.enableHighVolumeSafety
: !!node.enableOverfillProtection;
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
toggleInput(highVolumeToggle, highVolumePercent);
}
// Control-mode section toggle (levelbased / manual).
const toggleModeSections = (val) => {
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
const active = document.getElementById(`ps-mode-${val}`);
if (active) active.style.display = '';
};
const modeSelect = document.getElementById('node-input-controlMode');
if (modeSelect) {
modeSelect.value = node.controlMode === 'manual' ? 'manual' : 'levelbased';
toggleModeSections(modeSelect.value);
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
}
// Numeric field defaults.
ns.setNumberField('node-input-startLevel', node.startLevel);
ns.setNumberField('node-input-maxLevel', node.maxLevel);
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
const curveSelect = document.getElementById('node-input-levelCurveType');
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
// Bind redraws to the inputs each diagram cares about.
ns.bindRedraw(
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
ns.basinDiagram.redraw
);
ns.bindRedraw(
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
// so the mode preview must redraw when either of those change.
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
'dryRunThresholdPercent',
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
'shiftArmPercent'],
ns.modePreview.redraw
);
// Initial render + hover-couple wiring once the DOM is settled.
setTimeout(() => {
ns.basinDiagram.redraw();
ns.modePreview.redraw();
ns.hoverCouple?.init();
}, 60);
};
})();

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

@@ -0,0 +1,65 @@
// 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 armPctVal = parseNum('node-input-shiftArmPercent');
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
const flowSetpoint = parseNum('node-input-flowSetpoint');
const flowDeadband = parseNum('node-input-flowDeadband');
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
};
})();

View File

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

View File

@@ -105,6 +105,20 @@ class PumpingStation {
// levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0;
// --- Level-armed hysteresis state (see _controlLevelBased) ---
// _shiftArmed: true once up-curve output % crosses shiftArmPercent on
// the way up. Cleared when level drops to startLevel.
// _shiftHoldValue: captured on every filling→draining transition while
// armed. The output stays at this value while level drops from the
// flip point to shiftLevel; below shiftLevel it ramps to 0 % at
// startLevel (linear or log shape).
// _lastDirection: tracks the previous tick's direction so we can
// detect filling→draining transitions. We don't update it on
// 'steady' ticks so transitions through the dead-band are preserved.
this._shiftArmed = false;
this._shiftHoldValue = null;
this._lastDirection = null;
// --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise.
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
@@ -271,6 +285,11 @@ class PumpingStation {
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
}
setManualOutflow(value, timestamp = Date.now(), unit) {
const num = Number(value);
this.measurements.type('flow').variant('predicted').position('out').child('manual-qout').value(num, timestamp, unit);
}
/* --------------------------- Tick / Control --------------------------- */
tick() {
@@ -314,7 +333,7 @@ class PumpingStation {
_controlLogic(direction) {
switch (this.mode) {
case 'levelbased':
this._controlLevelBased();
this._controlLevelBased(direction);
break;
case 'flowbased':
this._controlFlowBased?.();
@@ -326,8 +345,9 @@ class PumpingStation {
}
}
async _controlLevelBased() {
const { startLevel, minLevel } = this.config.control.levelbased;
async _controlLevelBased(direction) {
const cfg = this.config.control.levelbased;
const { startLevel, minLevel } = cfg;
const levelUnit = this.measurements.getUnit('level');
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
@@ -336,37 +356,121 @@ class PumpingStation {
return;
}
// Level-based pump control via MGC — three zones:
// Level-based pump control via MGC. See wiki/modes/levelbased.md.
//
// Always:
// level < minLevel → STOP (unconditional MGC shutdown)
// minLevel ≤ level < startLevel → DEAD ZONE (hysteresis; keep last cmd)
// level ≥ startLevel → RUN (linear [startLevel..maxLevel] → [0..100 %])
// See wiki/modes/levelbased.md for the full transfer-function diagram.
// level < inflowLevel → 0 % (HOLD zone, pumps idle)
// level in [inflow..max] → up curve 0..100 % (linear or log)
// level > maxLevel → 100 % (MGC clamps internally)
//
// With enableShiftedRamp (hysteresis):
// When up-curve % rises past shiftArmPercent → ARMED.
// On the next filling→draining transition while armed → capture
// hold = current up-curve %.
// While armed AND draining:
// level >= shiftLevel → output = hold (held)
// level in [start..shift] → output ramps hold→0 % over the range
// level < startLevel → output = 0 %
// While armed AND filling/steady → output = up curve (resets hold).
// Disarms only when level <= startLevel.
// STOP — below minLevel, always shut down regardless of direction.
if (level < minLevel) {
this.percControl = 0;
this._shiftHoldValue = null;
this._shiftArmed = false;
this._lastDirection = direction;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// DEAD ZONE — between minLevel and startLevel, do nothing.
// Pumps that are running keep their last command; pumps that
// are off stay off. This prevents rapid on/off cycling.
if (level < startLevel) {
return;
// Up-curve value (always defined; foot=inflowLevel, top=maxLevel).
const upPct = this._scaleLevelToFlowPercent(level, this.basin?.inflowLevel ?? startLevel, cfg.maxLevel);
// Update arming flag.
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
if (!this._shiftArmed && upPct >= armPct) {
this._shiftArmed = true;
this.logger.debug(`Shift armed: upPct=${upPct} >= ${armPct}`);
}
} else {
this._shiftArmed = false;
}
if (level <= startLevel) {
this._shiftArmed = false;
this._shiftHoldValue = null;
}
// Capture hold on filling→draining transition while armed.
if (cfg.enableShiftedRamp && this._shiftArmed) {
if (this._lastDirection !== 'draining' && direction === 'draining') {
this._shiftHoldValue = upPct;
this.logger.debug(`Shift hold captured: ${upPct} % at level=${level}`);
} else if (direction === 'filling') {
// Returning to filling clears any captured hold; the next drain
// transition will recapture from the up curve.
this._shiftHoldValue = null;
}
}
if (direction === 'filling' || direction === 'draining') {
this._lastDirection = direction;
}
// Compute output.
let percControl;
const inDrainingHold = cfg.enableShiftedRamp && this._shiftArmed
&& direction === 'draining' && this._shiftHoldValue != null;
if (!inDrainingHold) {
// Up curve: 0 % below inflow, scaled inflow..max → 0..100, saturates above max.
if (level < (this.basin?.inflowLevel ?? startLevel)) {
percControl = 0;
} else {
percControl = Math.max(0, upPct);
}
} else {
const hold = this._shiftHoldValue;
const shift = cfg.shiftLevel;
if (!Number.isFinite(shift) || shift <= startLevel) {
// Bad config — fall back to up curve.
percControl = Math.max(0, upPct);
} else if (level >= shift) {
percControl = hold;
} else if (level > startLevel) {
// Ramp from (shiftLevel, hold) down to (startLevel, 0).
// Use the same curve shape (linear/log) as the up curve, scaled to
// peak at hold% at level=shiftLevel.
const x = (level - startLevel) / (shift - startLevel);
const shaped = this._curveShape(x);
percControl = Math.max(0, hold * shaped);
} else {
percControl = 0;
}
}
// RUN — above startLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [startLevel..maxLevel] → [0..100].
// Above maxLevel the MGC clamps internally.
const rawPercControl = this._scaleLevelToFlowPercent(level);
const percControl = Math.max(0, rawPercControl);
this.percControl = percControl;
this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`);
this.logger.debug(
`Level-based: level=${level} dir=${direction} armed=${this._shiftArmed} hold=${this._shiftHoldValue} pct=${percControl}`
);
await this._applyMachineGroupLevelControl(percControl);
}
// Apply the configured curve shape to a normalized x in [0,1].
// Returns shaped value in [0,1]. Linear by default; log when curveType
// is 'log' (with logCurveFactor).
_curveShape(x) {
const { curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor) : 9;
return Math.log1p(factor * clamped) / Math.log1p(factor);
}
return clamped;
}
_controlFlowBased() {
// placeholder for flow-based logic
}
@@ -503,10 +607,26 @@ class PumpingStation {
return null;
}
_scaleLevelToFlowPercent(level) {
const { startLevel, maxLevel } = this.config.control.levelbased;
this.logger.debug(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
return this.interpolate.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
// (legacy _levelBasedRampStart/_levelBasedRampTop/_updateShiftArmed
// helpers were removed in favour of the inline state machine in
// _controlLevelBased — see that method's doc block.)
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const start = Number.isFinite(rampStartLevel) ? rampStartLevel : this.config.control.levelbased.startLevel;
const top = Number.isFinite(rampTopLevel) ? rampTopLevel : maxLevel;
if (!Number.isFinite(level) || !Number.isFinite(start) || !Number.isFinite(top)) return 0;
if (top <= start) return level >= top ? 100 : 0;
const x = Math.max(0, Math.min(1, (level - start) / (top - start)));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor)
: 9;
return 100 * (Math.log1p(factor * x) / Math.log1p(factor));
}
return x * 100;
}
_levelRate(variant) {
@@ -634,7 +754,7 @@ class PumpingStation {
* Only a manual override or emergency can restart them.
* safetyControllerActive = true → blocks _controlLogic.
*
* 2. ABOVE overflow level (overfill): pumps CANNOT stop.
* 2. ABOVE high-volume safety level: pumps CANNOT stop.
* Shuts down UPSTREAM equipment only (stop more water coming in).
* Does NOT shut down downstream pumps or machine groups — they
* must keep draining. Does NOT set safetyControllerActive — the
@@ -642,7 +762,7 @@ class PumpingStation {
* dictated by the current level (which will be >100% near overflow,
* meaning all pumps at maximum via the normal demand curve).
* Only a manual override or emergency stop can shut pumps during
* an overfill event.
* a high-volume or overflowing event.
*/
_safetyController(remainingTime, direction) {
this.safetyControllerActive = false;
@@ -660,21 +780,34 @@ class PumpingStation {
enableDryRunProtection,
dryRunThresholdPercent,
enableOverfillProtection,
overfillThresholdPercent,
enableHighVolumeSafety,
timeleftToFullOrEmptyThresholdSeconds
} = this.config.safety || {};
const dryRunEnabled = Boolean(enableDryRunProtection);
const overfillEnabled = Boolean(enableOverfillProtection);
const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
const triggerHighVol = this.basin.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100));
const safety = this._computeSafetyPoints();
const triggerHighVol = safety.highVolumeSafetyVol;
const triggerLowVol = safety.dryRunSafetyVol;
const currentLevel = this._pickVariant('level', this.levelVariants, 'atequipment', 'm');
this.safetyState = {
dryRunActive: false,
highVolumeActive: false,
isOverflowing: Number.isFinite(currentLevel) && currentLevel >= this.basin.overflowLevel,
dryRunLevel: safety.dryRunLevel,
highVolumeSafetyLevel: safety.highVolumeSafetyLevel,
dryRunSafetyVol: safety.dryRunSafetyVol,
highVolumeSafetyVol: safety.highVolumeSafetyVol
};
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
if (direction === 'draining') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
if (timeTriggered || dryRunTriggered) {
this.safetyState.dryRunActive = true;
// Shut down all downstream equipment — pumps must stop.
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
@@ -700,8 +833,9 @@ class PumpingStation {
// running to maintain pump demand.
if (direction === 'filling') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const overfillTriggered = overfillEnabled && vol > triggerHighVol;
if (timeTriggered || overfillTriggered) {
const highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
if (timeTriggered || highVolumeTriggered) {
this.safetyState.highVolumeActive = true;
// Shut down UPSTREAM only — stop more water coming in.
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
@@ -713,7 +847,7 @@ class PumpingStation {
// NOTE: machine groups (downstream pumps) are NOT shut down.
// They must keep draining to prevent overflow from worsening.
this.logger.warn(
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
`High-volume safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
);
// NOTE: safetyControllerActive is NOT set — level control
// keeps commanding pumps at maximum demand.
@@ -721,6 +855,25 @@ class PumpingStation {
}
}
_computeSafetyPoints() {
const safety = this.config.safety || {};
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const highPct = Number(
safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent ?? 98
) || 0;
const dryRunSafetyVol = this.basin.minVol * (1 + (dryRunPct / 100));
const dryRunLevel = this._calcLevelFromVolume(dryRunSafetyVol);
const highVolumeSafetyVol = this.basin.maxVolAtOverflow * (highPct / 100);
const highVolumeSafetyLevel = this._calcLevelFromVolume(highVolumeSafetyVol);
return {
dryRunSafetyVol,
dryRunLevel,
highVolumeSafetyVol,
highVolumeSafetyLevel
};
}
/* --------------------------- Basin --------------------------- */
/**
@@ -740,9 +893,11 @@ class PumpingStation {
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity
const heightBasin = this.config.basin.height; // m — floor to rim
const inflowLevel = this.config.basin.inflowLevel; // m — sewer feed pipe centre
const outflowLevel = this.config.basin.outflowLevel; // m — pump suction pipe centre
const inflowLevel = this.config.basin.inflowLevel; // m — inlet pipe bottom/invert
const outflowLevel = this.config.basin.outflowLevel; // m — outlet/pump suction pipe top
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest
const inletPipeDiameter = this.config.basin.inletPipeDiameter;
const outletPipeDiameter = this.config.basin.outletPipeDiameter;
// Constant cross-section assumption: volume = level × area
const surfaceArea = volEmptyBasin / heightBasin;
@@ -762,6 +917,8 @@ class PumpingStation {
inflowLevel,
outflowLevel,
overflowLevel,
inletPipeDiameter,
outletPipeDiameter,
surfaceArea,
maxVol,
maxVolAtOverflow,
@@ -786,26 +943,21 @@ class PumpingStation {
*
* Strict invariants (bottom → top):
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
* dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
*
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
* dryRunLevel and highVolumeSafetyLevel are DERIVED — computed
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
* overfillThresholdPercent/100 in the safety layer. Validating those
* highVolumeSafetyThresholdPercent/100 in the safety layer. Validating those
* catches config that would let minLevel sit below where safety has
* already force-stopped the pumps (no-op control band).
*/
_validateThresholdOrdering() {
const basin = this.basin;
const lvl = this.config.control?.levelbased || {};
const safety = this.config.safety || {};
// Derived safety trigger levels (level-space equivalents of what
// _safetyController does in volume-space).
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
const safetyPoints = this._computeSafetyPoints();
const dryRunLevel = safetyPoints.dryRunLevel;
const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
@@ -813,8 +965,10 @@ class PumpingStation {
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
];
const issues = [];
@@ -844,21 +998,38 @@ class PumpingStation {
getOutput() {
const output = this.measurements.getFlattenedOutput();
const safety = this._computeSafetyPoints();
output.direction = this.state.direction;
output.flowSource = this.state.flowSource;
output.timeleft = this.state.seconds;
output.volEmptyBasin = this.basin.volEmptyBasin;
output.inflowLevel = this.basin.inflowLevel;
output.outflowLevel = this.basin.outflowLevel;
output.overflowLevel = this.basin.overflowLevel;
output.inletPipeDiameter = this.basin.inletPipeDiameter;
output.outletPipeDiameter = this.basin.outletPipeDiameter;
output.maxVol = this.basin.maxVol;
output.minVol = this.basin.minVol;
output.maxVolAtOverflow = this.basin.maxVolAtOverflow;
output.minVolAtOutflow = this.basin.minVolAtOutflow;
output.minVolAtInflow = this.basin.minVolAtInflow;
output.minHeightBasedOn = this.basin.minHeightBasedOn;
output.dryRunLevel = safety.dryRunLevel;
output.dryRunSafetyVol = safety.dryRunSafetyVol;
output.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
output.highVolumeSafetyVol = safety.highVolumeSafetyVol;
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
output.safetyState = this._deriveSafetyState();
output.percControl = this.percControl;
return output;
}
_deriveSafetyState() {
if (this.safetyState?.isOverflowing) return 'overflowing';
if (this.safetyState?.highVolumeActive) return 'highVolume';
if (this.safetyState?.dryRunActive) return 'dryRun';
return 'normal';
}
}
module.exports = PumpingStation;
@@ -887,15 +1058,19 @@ if (require.main === module) {
height: 10,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 3.2
overflowLevel: 3.2,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0
basinBottomRef: 0,
minHeightBasedOn: 'outlet'
},
safety: {
enableDryRunProtection:false,
enableOverfillProtection:false
enableHighVolumeSafety:false,
highVolumeSafetyThresholdPercent: 98
}
};
}
@@ -1036,4 +1211,4 @@ if (require.main === module) {
console.error('Demo failed:', err);
});
}
//*/
//*/

View File

@@ -27,6 +27,8 @@ function makeConfig(overrides = {}) {
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 4.5,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3,
},
hydraulics: {
refHeight: 'NAP',
@@ -36,12 +38,13 @@ function makeConfig(overrides = {}) {
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 2,
highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
@@ -80,6 +83,10 @@ test('Basin geometry — derived values', async (t) => {
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
assert.equal(ps2.basin.minVol, 30);
});
await t.test('pipe diameters are part of basin contract', () => {
assert.equal(ps.basin.inletPipeDiameter, 0.4);
assert.equal(ps.basin.outletPipeDiameter, 0.3);
});
});
test('Level ↔ volume roundtrip', async (t) => {
@@ -131,6 +138,17 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
});
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
},
}));
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
});
await t.test('outflowLevel >= inflowLevel flagged', () => {
const ps = new PumpingStation(makeConfig({
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
@@ -223,20 +241,22 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
assert.equal(turnOffCalls, 1);
});
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => { throw new Error('should not be called in dead zone'); },
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 42); // unchanged
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
@@ -244,14 +264,144 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
await ps._controlLevelBased();
// lerp(3, [2,4], [0,100]) = 50
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0);
assert.equal(demands[0], 0);
});
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
await ps._controlLevelBased('filling');
// lerp(3.5, [3,4], [0,100]) = 50
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
assert.equal(demands.length, 1);
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
});
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased();
assert.ok(ps.percControl > 0);
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
await ps._controlLevelBased();
// Without shift the foot is inflowLevel → 0% in the hold zone.
assert.equal(ps.percControl, 0);
});
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
ps.calibratePredictedLevel(3.5);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, false);
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
ps.calibratePredictedLevel(3.6);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
ps.calibratePredictedLevel(2.75);
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
// Below startLevel ⇒ output 0 % AND disarm.
ps.calibratePredictedLevel(1.9);
await ps._controlLevelBased('draining');
assert.equal(ps.percControl, 0);
assert.equal(ps._shiftArmed, false);
assert.equal(ps._shiftHoldValue, null);
});
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
ps.calibratePredictedLevel(3.85);
await ps._controlLevelBased('filling');
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
// Direction back to filling ⇒ up curve, hold cleared, still armed.
ps.calibratePredictedLevel(3.9);
await ps._controlLevelBased('filling');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true);
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
await ps._controlLevelBased('draining');
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
});
await t.test('log curve has fast early response', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
},
}));
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async () => {},
};
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
await ps._controlLevelBased('filling');
assert.ok(ps.percControl > 50);
assert.ok(ps.percControl < 100);
});
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
const ps = new PumpingStation(makeConfig());
ps.machineGroups['mgc1'] = {
@@ -275,6 +425,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
assert.equal(out.maxVolAtOverflow, 45);
assert.equal(out.minVolAtInflow, 30);
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
assert.equal(out.inletPipeDiameter, 0.4);
assert.equal(out.outletPipeDiameter, 0.3);
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 1e-9);
});
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
const out = ps.getOutput();

View File

@@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function loadDashboardFlow() {
const flowPath = path.join(__dirname, '../../examples/basic-dashboard.flow.json');
return JSON.parse(fs.readFileSync(flowPath, 'utf8'));
}
function makeContextStub() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
},
};
}
test('basic dashboard flow contains the pumpingStation node and trend widgets', () => {
const flow = loadDashboardFlow();
const ps = flow.find((n) => n.id === 'ps_node_basic');
const parser = flow.find((n) => n.id === 'ps_parse_output');
const levelChart = flow.find((n) => n.id === 'ps_chart_level');
const demandChart = flow.find((n) => n.id === 'ps_chart_demand');
assert.ok(ps, 'ps_node_basic should exist');
assert.equal(ps.type, 'pumpingStation');
assert.equal(ps.controlMode, 'levelbased');
assert.equal(ps.levelCurveType, 'linear');
assert.equal(ps.inletPipeDiameter, 0.4);
assert.equal(ps.outletPipeDiameter, 0.3);
assert.ok(parser, 'ps_parse_output should exist');
assert.equal(parser.outputs, 6);
assert.equal(levelChart.type, 'ui-chart');
assert.equal(demandChart.type, 'ui-chart');
});
test('basic dashboard parser routes process fields to charts and state text', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
assert.ok(parser, 'ps_parse_output should exist');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
// Flatten format is `${type}.${variant}.${position}.${childId}`. When the
// runtime writes without an explicit .child(), childId='default'. Mirror
// the real shape here. (See generalFunctions/src/measurements/
// MeasurementContainer.js getFlattenedOutput.)
const out = func({
payload: {
'level.predicted.atequipment.default': 3.25,
'volume.predicted.atequipment.default': 32.5,
'netFlowRate.predicted.atequipment.default': 0.003,
percControl: 25,
direction: 'filling',
safetyState: 'normal',
isOverflowing: false,
timeleft: 400,
},
}, context, node);
assert.ok(Array.isArray(out));
assert.equal(out.length, 6);
assert.equal(out[0].topic, 'level');
assert.equal(out[0].payload, 3.25);
assert.equal(out[1].topic, 'volume');
assert.equal(out[1].payload, 32.5);
assert.equal(out[2].topic, 'demand');
assert.equal(out[2].payload, 25);
assert.equal(out[3].topic, 'net_flow');
assert.equal(out[3].payload, 0.003);
assert.match(out[4].payload, /normal/);
assert.match(out[5].payload, /level=3.25 m/);
});
test('basic dashboard parser keeps previous values when process output sends only changed fields', () => {
const flow = loadDashboardFlow();
const parser = flow.find((n) => n.id === 'ps_parse_output');
const func = new Function('msg', 'context', 'node', parser.func);
const context = makeContextStub();
const node = { send() {} };
func({ payload: { 'level.predicted.atequipment.default': 3.1, percControl: 10 } }, context, node);
const out = func({ payload: { percControl: 20 } }, context, node);
assert.equal(out[0].payload, 3.1);
assert.equal(out[2].payload, 20);
});

View File

@@ -0,0 +1,198 @@
// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
// Drives a full fill→arm→drain cycle through the same code path the
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
// hold-then-ramp output behaviour.
//
// Run with: node --test test/integration/shifted-ramp-end-to-end.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const PumpingStation = require('../../src/specificClass');
const SURFACE_AREA = 10; // basin volume / height = 50/5
const TICK_MS = 1000; // simulate 1 s per tick
function makeConfig() {
return {
general: {
name: 'TestPS',
id: 'ps-e2e',
unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' },
flowThreshold: 1e-4,
},
functionality: {
softwareType: 'pumpingStation',
role: 'stationcontroller',
positionVsParent: 'atEquipment',
},
basin: {
volume: 50, height: 5,
inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
},
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: {
minLevel: 1, startLevel: 2, maxLevel: 4,
curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
safety: {
enableDryRunProtection: false, enableOverfillProtection: false,
dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
},
};
}
// Build a PS with a fake MGC that captures every demand sent to it,
// and a clock we control so _updatePredictedVolume integrates over a
// known dt regardless of wall-clock.
function buildHarness() {
const ps = new PumpingStation(makeConfig());
const demands = [];
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {},
handleInput: async (_src, d) => { demands.push(d); },
};
// Seed level at startLevel so the run begins idle.
ps.calibratePredictedLevel(2.0);
// Override Date.now via a controllable clock that advances `step()`.
let now = ps._predictedFlowState.lastTimestamp || 0;
ps._fakeNow = () => now;
ps._fakeAdvance = (ms) => { now += ms; };
// Patch global Date.now JUST inside the scope of these tests.
const realNow = Date.now;
Date.now = ps._fakeNow;
// Restore on completion.
ps._restore = () => { Date.now = realNow; };
return { ps, demands };
}
async function step(ps, qIn, qOut) {
// Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
// topic handlers in nodeClass.js), advance time, then tick once.
if (Number.isFinite(qIn)) ps.setManualInflow(qIn, Date.now(), 'm3/s');
if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
ps._fakeAdvance(TICK_MS);
ps.tick();
}
function levelOf(ps) {
return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
}
test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
const { ps } = buildHarness();
try {
// ─── PHASE A: fill from start (2.0) up past the arm point ──────────
// Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
// 0.05/SURFACE_AREA = 0.005 m per second.
let armedAt = null;
for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
await step(ps, 0.05, 0);
if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
}
assert.ok(armedAt, 'shift should arm during fill');
// Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
// jitter for time-discretization.
assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
`expected arm near level=3.8, got ${armedAt.level}`);
assert.ok(armedAt.pct >= 80 - 1e-6,
`at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);
// While still filling and armed, output should track the up curve
// (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
const fillingPct = ps.percControl;
assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
`filling-armed output should still be on up curve, got ${fillingPct}`);
// No hold captured yet (still filling).
assert.equal(ps._shiftHoldValue, null);
// ─── PHASE B: flip to draining ─────────────────────────────────────
// First drain tick captures the hold. We need direction='draining' as
// determined by _selectBestNetFlow → so q_in - q_out must be negative
// by more than the dead-band (1e-4).
await step(ps, 0, 0.05); // net = -0.05
assert.equal(ps.state.direction, 'draining');
// Hold captured = up curve at the level when direction flipped. The
// captured value is recorded BEFORE this drain tick lowered the level
// further, so it should match the last filling tick's output (within
// the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
`hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
const hold = ps._shiftHoldValue;
// ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
// Drain until level just above shiftLevel=3.5. Output stays = hold.
let held = true;
for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
await step(ps, 0, 0.05);
if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
}
assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
`still expected hold=${hold}, got ${ps.percControl}`);
// ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
// Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
const justBelow = ps.percControl;
assert.ok(justBelow < hold,
`output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
// Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
const mid = ps.percControl;
assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
`at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);
// ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps.percControl, 0);
} finally {
ps._restore();
}
});
test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
const { ps } = buildHarness();
try {
// Fill to arm + some headroom.
while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
assert.equal(ps._shiftArmed, true);
// First drain transition → hold #1.
await step(ps, 0, 0.05);
const hold1 = ps._shiftHoldValue;
assert.ok(hold1 >= 80 - 1e-6);
// Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);
// Flip back to filling at higher rate; up curve resumes; hold cleared.
await step(ps, 0.05, 0);
assert.equal(ps._shiftHoldValue, null);
assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');
// Fill higher than before (output goes higher).
while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
const fillingPct = ps.percControl;
assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);
// Drain again → fresh hold #2 = current up curve %.
await step(ps, 0, 0.05);
const hold2 = ps._shiftHoldValue;
assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
} finally {
ps._restore();
}
});