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

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

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

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

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

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

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

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

@@ -0,0 +1,147 @@
// PumpingStation editor — interactive basin SVG (top of the editor).
// Places threshold lines, derived safety levels, zone labels, dead-volume
// band, and ordering warnings. Same formulas as
// specificClass._validateThresholdOrdering.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
const DIAG = { topY: 40, botY: 380 };
const yForLevel = (val, basinH) => {
if (val == null || !basinH) return null;
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
};
// Place a row — line, label, input, unit all share the same y.
const placeItem = (id, y) => {
const line = document.getElementById(`ps-line-${id}`);
const label = document.getElementById(`ps-label-${id}`);
const unit = document.getElementById(`ps-unit-${id}`);
const fo = document.getElementById(`ps-fo-${id}`);
const sub = document.getElementById(`ps-sub-${id}`);
const lead = document.getElementById(`ps-leader-${id}`);
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
if (label) label.setAttribute('y', y + 4);
if (unit) unit.setAttribute('y', y + 4);
if (fo) fo.setAttribute('y', y - 11);
if (sub) sub.setAttribute('y', y + 15);
if (lead) lead.setAttribute('visibility', 'hidden');
};
ns.basinDiagram = {
redraw() {
const basinH = fNum('basinHeight') || 5;
const refLow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
const highPct = fNum('highVolumeSafetyThresholdPercent');
const ovf = fNum('overflowLevel');
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
// outflowLevel pinned at its proportional y. Two passes (top-down +
// bottom-up) maintain a minimum vertical gap.
const items = [
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), basinH) },
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
].filter(it => it.yIdeal != null);
const GAP = 36;
items.sort((a, b) => a.yIdeal - b.yIdeal);
for (const it of items) it.y = it.yIdeal;
for (let i = 1; i < items.length; i++) {
if (items[i].pinned) continue;
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
}
for (let i = items.length - 2; i >= 0; i--) {
if (items[i].pinned) continue;
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
}
for (const it of items) placeItem(it.id, it.y);
const placeZone = (zoneId, topId, botId) => {
const el = document.getElementById(`ps-zone-${zoneId}`);
if (!el) return;
const top = items.find(it => it.id === topId);
const bot = items.find(it => it.id === botId);
if (!top || !bot || (bot.y - top.y) < 14) {
el.setAttribute('visibility', 'hidden'); return;
}
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
el.setAttribute('visibility', 'visible');
};
placeZone('spare', 'overflowLevel', 'highVolumeSafetyLevel');
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
const outflowPinned = items.find(it => it.id === 'outflowLevel');
const deadLbl = document.getElementById('ps-zone-dead');
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
deadLbl.setAttribute('visibility', 'visible');
} else if (deadLbl) {
deadLbl.setAttribute('visibility', 'hidden');
}
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
if (inflowY != null) {
const line = document.getElementById('ps-line-inflowLevel');
const lbl = document.getElementById('ps-label-inflowLevel');
const sub = document.getElementById('ps-sub-inflowLevel');
const fo = document.getElementById('ps-fo-inflowLevel');
const unit = document.getElementById('ps-unit-inflowLevel');
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
if (lbl) lbl.setAttribute('y', inflowY - 4);
if (sub) sub.setAttribute('y', inflowY + 8);
if (fo) fo.setAttribute('y', inflowY - 11);
if (unit) unit.setAttribute('y', inflowY + 4);
}
const outflowItem = items.find(it => it.id === 'outflowLevel');
const deadvol = document.getElementById('ps-deadvol');
if (deadvol && outflowItem) {
deadvol.setAttribute('y', outflowItem.y);
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
}
// SVG labels — keep them short, side panel shows the numeric value.
const dryLbl = document.getElementById('ps-label-dryRunLevel');
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
if (highLbl) highLbl.textContent = 'highVolumeSafety';
// Side-panel read-only displays — number only ("m" is shown in the unit span).
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
const d1 = document.getElementById('derived-dryRunLevel');
if (d1) d1.textContent = fmt(dryLvl);
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
if (d2) d2.textContent = fmt(highLvl);
const warn = document.getElementById('ps-warning');
const issues = [];
const pairs = [
['outflowLevel', 'inflowLevel', '<'],
['inflowLevel', 'overflowLevel', '<'],
];
for (const [a, b, op] of pairs) {
const av = fNum(a), bv = fNum(b);
if (av == null || bv == null) continue;
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
}
if (warn) {
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
else { warn.setAttribute('visibility', 'hidden'); }
}
},
};
})();

View File

@@ -0,0 +1,29 @@
// PumpingStation editor — hover-coupling between side-panel input rows
// and the SVG markers they control. Each .ps-row that carries
// data-couples-line="<svg-element-id>" highlights that SVG line on
// mouseenter and clears the highlight on mouseleave.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
ns.hoverCouple = {
init() {
document.querySelectorAll('.ps-diag-side .ps-row[data-couples-line]').forEach((row) => {
const targetId = row.getAttribute('data-couples-line');
const target = document.getElementById(targetId);
if (!target) return;
const enter = () => target.classList.add('ps-line-highlight');
const leave = () => target.classList.remove('ps-line-highlight');
row.addEventListener('mouseenter', enter);
row.addEventListener('mouseleave', leave);
// Also highlight while the input inside the row has focus, so
// the user keeps the visual feedback while typing.
const input = row.querySelector('input');
if (input) {
input.addEventListener('focus', enter);
input.addEventListener('blur', leave);
}
});
},
};
})();

30
src/editor/index.js Normal file
View File

@@ -0,0 +1,30 @@
// PumpingStation editor — shared namespace + helpers.
// Loaded first by pumpingStation.html via /pumpingStation/editor/index.js.
// Each sibling module attaches additional members to window.PSEditor.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
// Read a numeric value from an input by node-input-<id>; null if blank/NaN.
ns.fNum = (id) => {
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
return Number.isFinite(v) ? v : null;
};
// Set a numeric input's value, or blank if not finite.
ns.setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
};
// Add input + change listeners to a list of node-input-* ids.
ns.bindRedraw = (ids, handler) => {
ids.forEach((id) => {
const el = document.getElementById(`node-input-${id}`);
if (el) {
el.addEventListener('input', handler);
el.addEventListener('change', handler);
}
});
};
})();

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

@@ -0,0 +1,228 @@
// PumpingStation editor — level-based mode preview SVG.
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
// the optional shifted-down curve (startLevel→shiftLevel). Computes
// validation issues and stashes them on window._psModeValidationIssues
// for oneditsave to read.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const fNum = (id) => ns.fNum(id);
// Derive dryRunLevel the same way the basin diagram does.
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
// Returns null if either input is missing.
ns.deriveDryRunLevel = () => {
const refLow = fNum('outflowLevel');
const dryPct = fNum('dryRunThresholdPercent');
if (refLow == null || dryPct == null) return null;
return refLow * (1 + dryPct / 100);
};
ns.modePreview = {
redraw() {
const svg = document.getElementById('ps-levelbased-mode-diagram');
if (!svg) return;
const start = fNum('startLevel');
const inlet = fNum('inflowLevel');
const max = fNum('maxLevel');
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
// (no separate input). Below dryRunLevel the runtime hard-stops;
// we draw it as the leftmost vertical marker so the user sees
// exactly where it lands.
const dryRun = ns.deriveDryRunLevel();
const overflow = fNum('overflowLevel');
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftRaw = fNum('shiftLevel');
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
// Plot window is FIXED relative to basin geometry so that moving any
// single level slides only that line, not all the others. Lower bound
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
// if overflow isn't set) plus a small margin.
const upperRefs = [max, overflow].filter(Number.isFinite);
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
const pad = Math.max(upperBase * 0.05, 0.1);
const levelMin = 0;
const levelMax = upperBase + pad;
// Plot rectangle (viewBox px).
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
const yOffPx = 160;
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
const scale = (x) => {
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
return clamped;
};
// Path with three flat regions and a ramp:
// [levelMin..startX] OFF (pump off; below startLevel)
// [startX..footX] 0 % (system armed but not yet ramping)
// [footX..topX] ramp (linear or log scaled 0..100 %)
// [topX..levelMax] 100 % (saturated)
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
const buildPath = (startX, footX, topX) => {
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
const pts = [];
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
pts.push(`${xFor(startX)},${yForPct(0)}`);
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
for (let i = 0; i <= 24; i++) {
const t = i / 24;
const level = footX + t * (topX - footX);
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
}
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
return pts.join(' ');
};
const up = document.getElementById('ps-mode-curve-up');
const down = document.getElementById('ps-mode-curve-down');
const downLabel = document.getElementById('ps-mode-curve-down-label');
if (up) up.setAttribute('points', buildPath(start, inlet, max));
if (down) {
if (shiftEnabled) {
const shiftedTop = Number.isFinite(shift) && shift > start ? shift : max;
down.setAttribute('points', buildPath(start, start, shiftedTop));
down.style.display = '';
if (downLabel) downLabel.style.display = '';
} else {
down.setAttribute('points', '');
down.style.display = 'none';
if (downLabel) downLabel.style.display = 'none';
}
}
// Vertical level markers + axis labels.
[
['dryRunLevel', dryRun],
['startLevel', start],
['inflowLevel', inlet],
['maxLevel', max],
['overflowLevel', overflow],
].forEach(([id, level]) => {
const line = document.getElementById(`ps-mode-line-${id}`);
const label = document.getElementById(`ps-mode-label-${id}`);
if (!line || !label) return;
if (!Number.isFinite(level)) {
line.style.display = 'none';
label.style.display = 'none';
return;
}
const x = xFor(level);
line.style.display = '';
label.style.display = '';
line.setAttribute('x1', x); line.setAttribute('x2', x);
label.setAttribute('x', x);
});
// Background zone bands.
const plotL = xFor(levelMin);
const plotR = xFor(levelMax);
const setBand = (id, a, b) => {
const r = document.getElementById(id);
if (!r) return;
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
r.setAttribute('x', 0); r.setAttribute('width', 0);
return;
}
r.setAttribute('x', a);
r.setAttribute('width', b - a);
};
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
setBand('ps-zone-dryRun', plotL, xMin);
setBand('ps-zone-safetyLow', xMin, xStart);
setBand('ps-zone-safe', xStart, xMax);
setBand('ps-zone-safetyHigh', xMax, xOvf);
setBand('ps-zone-overflow', xOvf, plotR);
// Shift level marker.
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
const shiftLabel = document.getElementById('ps-mode-label-shiftLevel');
if (shiftLine && shiftLabel) {
if (shiftEnabled && Number.isFinite(shift)) {
const x = xFor(shift);
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
shiftLabel.setAttribute('x', x);
shiftLine.style.display = '';
shiftLabel.style.display = '';
} else {
shiftLine.style.display = 'none';
shiftLabel.style.display = 'none';
}
}
// Title + row visibility.
const curveLabel = document.getElementById('ps-mode-curve-label');
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
const shiftRow = document.getElementById('ps-shiftLevel-row');
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
const logRow = document.getElementById('ps-log-factor-row');
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
// Auto-default shiftLevel when shift is enabled and current value
// is missing/out-of-range. Visible default avoids a hidden ramp.
const shiftInput = document.getElementById('node-input-shiftLevel');
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
const cur = parseFloat(shiftInput.value);
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
shiftInput.value = (max * 0.9).toFixed(2);
}
}
// Validation: ordering constraints.
const issues = [];
if (Number.isFinite(dryRun) && Number.isFinite(start) && dryRun >= start)
issues.push('dryRunLevel (derived) must be < startLevel — increase startLevel or lower dryRun%');
if (Number.isFinite(start) && Number.isFinite(inlet) && start >= inlet)
issues.push('startLevel must be < inflowLevel (set in basin above)');
if (Number.isFinite(inlet) && Number.isFinite(max) && inlet >= max)
issues.push('inflowLevel must be < maxLevel');
if (Number.isFinite(max) && Number.isFinite(overflow) && max > overflow)
issues.push('maxLevel must be ≤ overflowLevel');
if (shiftEnabled) {
const shiftVal = Number(shiftInput?.value);
if (Number.isFinite(shiftVal)) {
if (Number.isFinite(start) && shiftVal <= start)
issues.push('shiftLevel must be > startLevel');
if (Number.isFinite(max) && shiftVal > max)
issues.push('shiftLevel must be ≤ maxLevel');
} else {
issues.push('shiftLevel is required when shifted ramp is enabled');
}
}
const warnBox = document.getElementById('ps-mode-validation');
if (warnBox) {
if (issues.length) {
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
warnBox.style.display = '';
} else {
warnBox.style.display = 'none';
}
}
window._psModeValidationIssues = issues;
// Read-only readouts in the side panel — number only; the row's
// .ps-unit span already shows "m".
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
const setText = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = fmt(val);
};
setText('ps-mode-readout-dryRun', dryRun);
setText('ps-mode-readout-inflow', inlet);
setText('ps-mode-readout-overflow', overflow);
},
};
})();

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

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

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

@@ -0,0 +1,63 @@
// PumpingStation editor — oneditsave handler. Validates, saves shared
// menu sections (logger/position), then persists pumpingStation-specific
// fields onto the node. Throws if validation fails to keep the editor open.
(function () {
const ns = window.PSEditor = window.PSEditor || {};
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
ns.oneditsave = function () {
const node = this;
// Block save if the inline validator surfaced any issues.
const issues = window._psModeValidationIssues || [];
if (issues.length) {
if (typeof RED !== 'undefined' && RED.notify) {
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),
{ type: 'error', timeout: 6000 });
}
throw new Error('PumpingStation: invalid config — ' + issues.join('; '));
}
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
node.simulator = document.getElementById('node-input-simulator').checked;
[
'basinVolume', 'basinHeight', 'inflowLevel', 'outflowLevel', 'overflowLevel',
'basinBottomRef',
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
].forEach((field) => {
const el = document.getElementById(`node-input-${field}`);
if (el) node[field] = parseFloat(el.value) || 0;
});
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
node.enableHighVolumeSafety = document.getElementById('node-input-enableHighVolumeSafety').checked;
// Deprecated aliases kept for existing runtime/schema compatibility.
node.enableOverfillProtection = node.enableHighVolumeSafety;
node.overfillThresholdPercent = node.highVolumeSafetyThresholdPercent;
node.controlMode = document.getElementById('node-input-controlMode').value || 'levelbased';
node.levelCurveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
node.logCurveFactor = parseNum('node-input-logCurveFactor');
node.startLevel = parseNum('node-input-startLevel');
node.maxLevel = parseNum('node-input-maxLevel');
// minLevel is no longer a user input — it's the derived dryRunLevel
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
// uses node.minLevel as the unconditional STOP threshold; we set it
// here so that semantic survives the UI change.
const _dryRun = ns.deriveDryRunLevel?.();
if (Number.isFinite(_dryRun)) node.minLevel = _dryRun;
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
const shiftLevelVal = parseNum('node-input-shiftLevel');
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
const flowSetpoint = parseNum('node-input-flowSetpoint');
const flowDeadband = parseNum('node-input-flowDeadband');
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
};
})();

View File

@@ -47,26 +47,44 @@ class nodeClass {
inflowLevel: uiConfig.inflowLevel,
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
}
},
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 +238,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,14 @@ class PumpingStation {
// levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0;
// --- Level-armed hysteresis state ---
// _shiftArmed flips true when level rises past shiftLevel (with
// enableShiftedRamp). While armed, the demand ramp's lower foot
// is startLevel instead of inflowLevel — so on the way down the
// pumps stay aggressive until level falls below startLevel, at
// which point the arm clears.
this._shiftArmed = false;
// --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise.
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
@@ -271,6 +279,11 @@ class PumpingStation {
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
}
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 +327,7 @@ class PumpingStation {
_controlLogic(direction) {
switch (this.mode) {
case 'levelbased':
this._controlLevelBased();
this._controlLevelBased(direction);
break;
case 'flowbased':
this._controlFlowBased?.();
@@ -326,7 +339,7 @@ class PumpingStation {
}
}
async _controlLevelBased() {
async _controlLevelBased(_direction) {
const { startLevel, minLevel } = this.config.control.levelbased;
const levelUnit = this.measurements.getUnit('level');
@@ -336,33 +349,45 @@ class PumpingStation {
return;
}
// Level-based pump control via MGC — three zones:
// Level-based pump control via MGC (see wiki/modes/levelbased.md).
//
// level < minLevel → STOP (unconditional MGC shutdown)
// 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 < startLevel → 0 % (pumps held off)
// level in [startLevel..rampStart] → 0 % (HOLD zone)
// level in [rampStart..maxLevel] → 0..100 % (linear or log curve)
// level > maxLevel → ≥100 % (MGC clamps internally)
//
// With enableShiftedRamp:
// rampStart = inflowLevel by default
// when level rises past shiftLevel → arm → rampStart = startLevel
// when level drops below startLevel → disarm → rampStart = inflowLevel
// Without enableShiftedRamp: rampStart = inflowLevel always.
// STOP — below minLevel, always shut down regardless of direction.
if (level < minLevel) {
this.percControl = 0;
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) {
this._updateShiftArmed(level);
const rampStartLevel = this._levelBasedRampStart();
const rampTopLevel = this._levelBasedRampTop();
// HOLD/MINIMUM DEMAND — below the active ramp start, command 0 %
// without latching dry-run. Dry-run remains the safety layer's job.
if (level < rampStartLevel) {
this.percControl = 0;
await this._applyMachineGroupLevelControl(0);
return;
}
// 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);
// RUN — above rampStartLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [rampStartLevel..rampTopLevel] → [0..100].
// Above rampTopLevel demand saturates at 100 %.
const rawPercControl = this._scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel);
const percControl = Math.max(0, rawPercControl);
this.percControl = percControl;
this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`);
this.logger.debug(`Level-based control: level=${level} armed=${this._shiftArmed} foot=${rampStartLevel} top=${rampTopLevel} percControl=${percControl}`);
await this._applyMachineGroupLevelControl(percControl);
}
@@ -503,10 +528,60 @@ 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);
_levelBasedRampStart() {
const { startLevel, enableShiftedRamp } = this.config.control.levelbased;
const inflowLevel = this.basin?.inflowLevel;
if (enableShiftedRamp && this._shiftArmed) return startLevel;
if (Number.isFinite(inflowLevel)) return inflowLevel;
return startLevel;
}
_levelBasedRampTop() {
// Returns the upper level at which demand saturates at 100 %.
// While the shift is armed, top moves left from maxLevel to shiftLevel
// so output reaches 100 % earlier and stays at 100 % until level
// falls back through shiftLevel on the way down.
const { maxLevel, enableShiftedRamp, shiftLevel } = this.config.control.levelbased;
if (enableShiftedRamp && this._shiftArmed
&& Number.isFinite(shiftLevel) && shiftLevel > 0
&& shiftLevel <= maxLevel) {
return shiftLevel;
}
return maxLevel;
}
_updateShiftArmed(level) {
const { enableShiftedRamp, shiftLevel, startLevel } = this.config.control.levelbased;
if (!enableShiftedRamp) {
this._shiftArmed = false;
return;
}
const trigger = Number.isFinite(shiftLevel) && shiftLevel > 0 ? shiftLevel : null;
if (!this._shiftArmed && trigger != null && level >= trigger) {
this._shiftArmed = true;
this.logger.debug(`Shift armed at level=${level} (shiftLevel=${trigger})`);
} else if (this._shiftArmed && Number.isFinite(startLevel) && level < startLevel) {
this._shiftArmed = false;
this.logger.debug(`Shift disarmed at level=${level} (startLevel=${startLevel})`);
}
}
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const start = Number.isFinite(rampStartLevel) ? rampStartLevel : this.config.control.levelbased.startLevel;
const top = Number.isFinite(rampTopLevel) ? rampTopLevel : maxLevel;
if (!Number.isFinite(level) || !Number.isFinite(start) || !Number.isFinite(top)) return 0;
if (top <= start) return level >= top ? 100 : 0;
const x = Math.max(0, Math.min(1, (level - start) / (top - start)));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor)
: 9;
return 100 * (Math.log1p(factor * x) / Math.log1p(factor));
}
return x * 100;
}
_levelRate(variant) {
@@ -634,7 +709,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 +717,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 +735,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 +788,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 +802,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 +810,25 @@ class PumpingStation {
}
}
_computeSafetyPoints() {
const safety = this.config.safety || {};
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const highPct = Number(
safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent ?? 98
) || 0;
const dryRunSafetyVol = this.basin.minVol * (1 + (dryRunPct / 100));
const dryRunLevel = this._calcLevelFromVolume(dryRunSafetyVol);
const highVolumeSafetyVol = this.basin.maxVolAtOverflow * (highPct / 100);
const highVolumeSafetyLevel = this._calcLevelFromVolume(highVolumeSafetyVol);
return {
dryRunSafetyVol,
dryRunLevel,
highVolumeSafetyVol,
highVolumeSafetyLevel
};
}
/* --------------------------- Basin --------------------------- */
/**
@@ -740,9 +848,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 +872,8 @@ class PumpingStation {
inflowLevel,
outflowLevel,
overflowLevel,
inletPipeDiameter,
outletPipeDiameter,
surfaceArea,
maxVol,
maxVolAtOverflow,
@@ -786,26 +898,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 +920,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 +953,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 +1013,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 +1166,4 @@ if (require.main === module) {
console.error('Demo failed:', err);
});
}
//*/
//*/