Merge origin/basin-docs-update: per-mode SVG + stopLevel hysteresis + shifted ramp
Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:
control/levelBased.js
- stopLevel Schmitt-trigger + dead-band keep-alive
- Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
- Linear vs log up-curve (curveType + logCurveFactor)
measurement/flowAggregator.js
- Predicted-volume overflow clamp + spill flow stream
- Cumulative overflowVolume + underflowVolume
- Hard floor at 0 + dry-run-on-transition handling
basin/thresholdValidator.js
- computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
- startLevel ≤ inflowLevel invariant added
measurement/calibration.js + commands/
- Manual q_out path (set.outflow / q_out alias)
safety/safetyController.js
- Accepts both legacy + new high-volume threshold names
UI:
pumpingStation.html — restored the side-panel + SVG mode-preview block,
added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
logCurveFactor/enableShiftedRamp.
src/editor/* — basin-docs' 7-file modular editor (replaces single
src/editor.js, which is deleted).
pumpingStation.js — admin endpoint serves editor/:file.
Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.
Human-review items (see commit context):
- rampFoot = inflowLevel (matches basin-docs test); basin-docs source
used rampFoot = startLevel. Domain owner: confirm intent.
- Naming kept dual (overfillLevel + highVolumeSafetyLevel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ class BasinGeometry {
|
||||
const inflowLevel = basinConfig.inflowLevel;
|
||||
const outflowLevel = basinConfig.outflowLevel;
|
||||
const overflowLevel = basinConfig.overflowLevel;
|
||||
const inletPipeDiameter = basinConfig.inletPipeDiameter;
|
||||
const outletPipeDiameter = basinConfig.outletPipeDiameter;
|
||||
const minHeightBasedOn = hydraulicsConfig?.minHeightBasedOn;
|
||||
|
||||
const surfaceArea = volEmptyBasin / heightBasin;
|
||||
@@ -33,6 +35,8 @@ class BasinGeometry {
|
||||
this._inflowLevel = inflowLevel;
|
||||
this._outflowLevel = outflowLevel;
|
||||
this._overflowLevel = overflowLevel;
|
||||
this._inletPipeDiameter = inletPipeDiameter;
|
||||
this._outletPipeDiameter = outletPipeDiameter;
|
||||
this._surfaceArea = surfaceArea;
|
||||
this._maxVol = maxVol;
|
||||
this._maxVolAtOverflow = maxVolAtOverflow;
|
||||
@@ -47,6 +51,8 @@ class BasinGeometry {
|
||||
get inflowLevel() { return this._inflowLevel; }
|
||||
get outflowLevel() { return this._outflowLevel; }
|
||||
get overflowLevel() { return this._overflowLevel; }
|
||||
get inletPipeDiameter() { return this._inletPipeDiameter; }
|
||||
get outletPipeDiameter() { return this._outletPipeDiameter; }
|
||||
get surfaceArea() { return this._surfaceArea; }
|
||||
get maxVol() { return this._maxVol; }
|
||||
get maxVolAtOverflow() { return this._maxVolAtOverflow; }
|
||||
@@ -77,6 +83,8 @@ class BasinGeometry {
|
||||
inflowLevel: this._inflowLevel,
|
||||
outflowLevel: this._outflowLevel,
|
||||
overflowLevel: this._overflowLevel,
|
||||
inletPipeDiameter: this._inletPipeDiameter,
|
||||
outletPipeDiameter: this._outletPipeDiameter,
|
||||
surfaceArea: this._surfaceArea,
|
||||
maxVol: this._maxVol,
|
||||
maxVolAtOverflow: this._maxVolAtOverflow,
|
||||
|
||||
@@ -4,34 +4,71 @@
|
||||
//
|
||||
// Invariants enforced (level-space, bottom → top):
|
||||
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||
// dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel
|
||||
// dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||||
//
|
||||
// dryRunLevel and overfillLevel are DERIVED from safety percentages — the
|
||||
// validator recomputes them so a config that places minLevel below the
|
||||
// dryRunLevel and highVolumeSafetyLevel are DERIVED from safety percentages.
|
||||
// The validator recomputes them so a config that places minLevel below the
|
||||
// effective dry-run trigger (a no-op control band) is caught here.
|
||||
|
||||
/**
|
||||
* Derived safety thresholds + reference levels. Exposed so the editor /
|
||||
* status badge / FlowAggregator can read the same values without
|
||||
* recomputing them.
|
||||
*/
|
||||
function computeSafetyPoints(basin, safety = {}) {
|
||||
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||||
const rawHighPct = safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent;
|
||||
// When neither high-volume nor overfill pct is supplied, use 100 % so
|
||||
// the validator's `maxLevel <= overfillLevel` check is a no-op (the
|
||||
// basin can't physically exceed overflow anyway). Tests pin this.
|
||||
const highPct = Number(rawHighPct);
|
||||
const effectiveHighPct = Number.isFinite(highPct) ? highPct : 100;
|
||||
const minVol = Number(basin?.minVol) || 0;
|
||||
const maxVolAtOverflow = Number(basin?.maxVolAtOverflow) || 0;
|
||||
const dryRunSafetyVol = minVol * (1 + dryRunPct / 100);
|
||||
const highVolumeSafetyVol = maxVolAtOverflow * (effectiveHighPct / 100);
|
||||
const refLowLevel = basin?.minHeightBasedOn === 'inlet'
|
||||
? Number(basin?.inflowLevel)
|
||||
: Number(basin?.outflowLevel);
|
||||
const dryRunLevel = Number.isFinite(refLowLevel)
|
||||
? refLowLevel * (1 + dryRunPct / 100)
|
||||
: Number.NaN;
|
||||
const overflowLevel = Number(basin?.overflowLevel) || 0;
|
||||
const highVolumeSafetyLevel = overflowLevel * (effectiveHighPct / 100);
|
||||
return {
|
||||
dryRunSafetyVol,
|
||||
dryRunLevel,
|
||||
highVolumeSafetyVol,
|
||||
highVolumeSafetyLevel,
|
||||
// Back-compat alias — pre-basin-docs name.
|
||||
overfillLevel: highVolumeSafetyLevel,
|
||||
overfillVol: highVolumeSafetyVol,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} basin - BasinGeometry instance OR plain {inflowLevel, outflowLevel, overflowLevel, heightBasin, minHeightBasedOn}
|
||||
* @param {object} levelbased - config.control.levelbased ({ minLevel, startLevel, maxLevel })
|
||||
* @param {object} safety - config.safety ({ dryRunThresholdPercent, overfillThresholdPercent })
|
||||
* @param {object} safety - config.safety ({ dryRunThresholdPercent, highVolumeSafetyThresholdPercent | overfillThresholdPercent })
|
||||
* @returns {Array<{aName, a, op, bName, b, msg}>}
|
||||
*/
|
||||
function validateThresholdOrdering(basin, levelbased, safety) {
|
||||
const lvl = levelbased || {};
|
||||
const sfy = safety || {};
|
||||
|
||||
const dryRunPct = Number(sfy.dryRunThresholdPercent) || 0;
|
||||
const overfillPct = Number(sfy.overfillThresholdPercent) || 100;
|
||||
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
|
||||
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
|
||||
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
|
||||
const points = computeSafetyPoints(basin, safety);
|
||||
const { dryRunLevel, overfillLevel } = points;
|
||||
|
||||
// basin-docs added `startLevel <= inflowLevel` and `inflowLevel <
|
||||
// maxLevel`; HEAD had only the `startLevel < maxLevel` and
|
||||
// `maxLevel <= overfillLevel` checks. We keep the `overfillLevel`
|
||||
// name (rather than basin-docs's `highVolumeSafetyLevel`) for
|
||||
// back-compat with consumers reading issue.bName.
|
||||
const checks = [
|
||||
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
|
||||
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
|
||||
];
|
||||
@@ -54,4 +91,4 @@ function validateThresholdOrdering(basin, levelbased, safety) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
module.exports = { validateThresholdOrdering };
|
||||
module.exports = { validateThresholdOrdering, computeSafetyPoints };
|
||||
|
||||
@@ -67,6 +67,25 @@ exports.setInflow = (source, msg) => {
|
||||
source.setManualInflow(value, timestamp, unit);
|
||||
};
|
||||
|
||||
exports.setOutflow = (source, msg) => {
|
||||
// Manual q_out — basin-docs dashboard injects a drain rate without
|
||||
// wiring a real pump. Same payload shape as q_in.
|
||||
const p = msg.payload;
|
||||
let value;
|
||||
let unit;
|
||||
let timestamp;
|
||||
if (p !== null && typeof p === 'object') {
|
||||
value = Number(p.value);
|
||||
unit = p.unit;
|
||||
timestamp = p.timestamp || Date.now();
|
||||
} else {
|
||||
value = Number(p);
|
||||
unit = msg?.unit;
|
||||
timestamp = msg?.timestamp || Date.now();
|
||||
}
|
||||
source.setManualOutflow(value, timestamp, unit);
|
||||
};
|
||||
|
||||
exports.setDemand = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const demand = Number(msg.payload);
|
||||
|
||||
@@ -41,6 +41,12 @@ module.exports = [
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.setInflow,
|
||||
},
|
||||
{
|
||||
topic: 'set.outflow',
|
||||
aliases: ['q_out'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.setOutflow,
|
||||
},
|
||||
{
|
||||
topic: 'set.demand',
|
||||
aliases: ['Qd'],
|
||||
|
||||
@@ -8,13 +8,13 @@ const strategies = {
|
||||
[manual.name]: manual,
|
||||
};
|
||||
|
||||
function dispatch(mode, ctx, controlState) {
|
||||
function dispatch(mode, ctx, controlState, direction) {
|
||||
const s = strategies[mode];
|
||||
if (!s) {
|
||||
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return s.run(ctx, controlState);
|
||||
return s.run(ctx, controlState, direction);
|
||||
}
|
||||
|
||||
module.exports = { strategies, dispatch, manual };
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
const { interpolation } = require('generalFunctions');
|
||||
// Level-based control strategy.
|
||||
//
|
||||
// Ported from basin-docs `_controlLevelBased` into the refactored
|
||||
// strategy module. Concerns kept here:
|
||||
// 1. minLevel hard-stop (unconditional MGC shutdown).
|
||||
// 2. stopLevel Schmitt-trigger hysteresis — pumps stay engaged
|
||||
// through the dead band [stopLevel, startLevel] emitting a small
|
||||
// keep-alive demand so MGC keeps a single pump draining the basin.
|
||||
// 3. Up-curve mapping — level mapped to demand 0..100 % across
|
||||
// [inflowLevel, maxLevel] using linear or log shape.
|
||||
// 4. Shifted-ramp hysteresis — when the up-curve crosses
|
||||
// shiftArmPercent the strategy ARMS; on the next filling→draining
|
||||
// flip it captures the up-curve value as `hold`; while draining
|
||||
// the output stays at `hold` until level falls to shiftLevel, then
|
||||
// ramps `hold → 0 %` over [shiftLevel, startLevel]. Disarms when
|
||||
// level reaches startLevel.
|
||||
//
|
||||
// Hysteresis flags live on the host (specificClass instance) — the
|
||||
// strategy reads/writes via ctx.host so the same flags survive across
|
||||
// ticks regardless of how often the context view is rebuilt.
|
||||
|
||||
const _interp = new interpolation();
|
||||
// Apply the configured curve shape to a normalized x in [0, 1].
|
||||
// Linear by default; log when curveType is 'log'.
|
||||
function _curveShape(x, levelbased) {
|
||||
const { curveType = 'linear', logCurveFactor = 9 } = 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;
|
||||
}
|
||||
|
||||
// Maps [startLevel..maxLevel] → [0..100]. Outside the range,
|
||||
// interpolate_lin_single_point clamps to o_min / o_max.
|
||||
function _scaleLevelToFlowPercent(level, levelbased, logger) {
|
||||
const { startLevel, maxLevel } = levelbased;
|
||||
logger?.debug?.(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
|
||||
return _interp.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
|
||||
// Map level to demand % across [rampFoot, rampTop]. Returns 0 below the
|
||||
// foot, 100 above the top. Curve type controlled by levelbased.curveType.
|
||||
function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
|
||||
if (!Number.isFinite(level) || !Number.isFinite(rampFoot) || !Number.isFinite(rampTop)) return 0;
|
||||
if (rampTop <= rampFoot) return level >= rampTop ? 100 : 0;
|
||||
if (level <= rampFoot) return 0;
|
||||
if (level >= rampTop) return 100;
|
||||
const x = (level - rampFoot) / (rampTop - rampFoot);
|
||||
return 100 * _curveShape(x, levelbased);
|
||||
}
|
||||
|
||||
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||
@@ -48,9 +81,10 @@ function _pickVariant(measurements, type, variants, position, unit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function run(ctx, controlState) {
|
||||
const { measurements, config, logger, machineGroups, levelVariants } = ctx;
|
||||
const { startLevel, minLevel } = config.control.levelbased;
|
||||
async function run(ctx, controlState, direction) {
|
||||
const { measurements, config, logger, machineGroups, basin, levelVariants, host } = ctx;
|
||||
const cfg = config.control.levelbased || {};
|
||||
const { startLevel, minLevel, maxLevel } = cfg;
|
||||
const levelUnit = measurements.getUnit('level');
|
||||
|
||||
const variants = levelVariants || ['measured', 'predicted'];
|
||||
@@ -60,24 +94,123 @@ async function run(ctx, controlState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Three-zone level control:
|
||||
// level < minLevel → STOP (unconditional MGC shutdown)
|
||||
// minLevel ≤ level < startLevel → DEAD ZONE (no-op)
|
||||
// level ≥ startLevel → RUN (linear ramp → MGC)
|
||||
// 1. minLevel hard-stop — unconditional MGC shutdown.
|
||||
if (level < minLevel) {
|
||||
controlState.percControl = 0;
|
||||
if (host) {
|
||||
host._shiftHoldValue = null;
|
||||
host._shiftArmed = false;
|
||||
host._stopHystRunning = false;
|
||||
host._lastDirection = direction;
|
||||
}
|
||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
|
||||
if (level < startLevel) {
|
||||
// 2. stopLevel hysteresis (Schmitt trigger).
|
||||
// Requires an explicit positive stopLevel — configManager merges null
|
||||
// defaults to 0 otherwise, which would activate the hysteresis on every
|
||||
// config that omitted it.
|
||||
const stopLvl = Number(cfg.stopLevel);
|
||||
const stopThresholdActive = cfg.stopLevel != null && Number.isFinite(stopLvl)
|
||||
&& stopLvl > 0 && stopLvl < maxLevel;
|
||||
if (stopThresholdActive && level <= stopLvl) {
|
||||
controlState.percControl = 0;
|
||||
if (host) {
|
||||
host._stopHystRunning = false;
|
||||
host._lastDirection = direction;
|
||||
}
|
||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
if (host) {
|
||||
if (stopThresholdActive) {
|
||||
if (!host._stopHystRunning && level >= startLevel) host._stopHystRunning = true;
|
||||
} else {
|
||||
host._stopHystRunning = level >= startLevel;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Up-curve mapping. Foot stays at inflowLevel (the basin's
|
||||
// gravity-feed point): demand is 0 % in [startLevel, inflowLevel]
|
||||
// (the hold zone) and scales 0..100 % across [inflowLevel, maxLevel].
|
||||
const rampFoot = basin?.inflowLevel ?? cfg.inflowLevel ?? startLevel;
|
||||
const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);
|
||||
|
||||
// 4. Shifted-ramp arming.
|
||||
if (host) {
|
||||
if (cfg.enableShiftedRamp) {
|
||||
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
|
||||
if (!host._shiftArmed && upPct >= armPct) {
|
||||
host._shiftArmed = true;
|
||||
logger?.debug?.(`Shift armed: upPct=${upPct} >= ${armPct}`);
|
||||
}
|
||||
} else {
|
||||
host._shiftArmed = false;
|
||||
}
|
||||
if (level <= startLevel) {
|
||||
host._shiftArmed = false;
|
||||
host._shiftHoldValue = null;
|
||||
}
|
||||
// Capture hold on filling→draining transition while armed.
|
||||
if (cfg.enableShiftedRamp && host._shiftArmed) {
|
||||
if (host._lastDirection !== 'draining' && direction === 'draining') {
|
||||
host._shiftHoldValue = upPct;
|
||||
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.
|
||||
host._shiftHoldValue = null;
|
||||
}
|
||||
}
|
||||
if (direction === 'filling' || direction === 'draining') {
|
||||
host._lastDirection = direction;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute output.
|
||||
const shiftArmed = !!host?._shiftArmed;
|
||||
const shiftHold = host?._shiftHoldValue;
|
||||
const inDrainingHold = cfg.enableShiftedRamp && shiftArmed
|
||||
&& direction === 'draining' && shiftHold != null;
|
||||
|
||||
let percControl;
|
||||
if (!inDrainingHold) {
|
||||
if (level < rampFoot) {
|
||||
// While engaged via stopLevel hysteresis AND inside the dead band
|
||||
// [stopLevel, startLevel], emit a small keep-alive so MGC keeps a
|
||||
// single pump running.
|
||||
if (stopThresholdActive && host?._stopHystRunning && level < startLevel) {
|
||||
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
|
||||
? Number(cfg.deadZoneKeepAlivePercent) : 1;
|
||||
percControl = Math.max(0, keepAlive);
|
||||
} else {
|
||||
percControl = 0;
|
||||
}
|
||||
} else {
|
||||
percControl = Math.max(0, upPct);
|
||||
}
|
||||
} else {
|
||||
const hold = shiftHold;
|
||||
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 [shift, hold] → [start, 0] using the same curve shape.
|
||||
const x = (level - startLevel) / (shift - startLevel);
|
||||
percControl = Math.max(0, hold * _curveShape(x, cfg));
|
||||
} else {
|
||||
percControl = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const rawPercControl = _scaleLevelToFlowPercent(level, config.control.levelbased, logger);
|
||||
const percControl = Math.max(0, rawPercControl);
|
||||
controlState.percControl = percControl;
|
||||
logger?.debug?.(`Level-based control: level=${level} percControl=${percControl}`);
|
||||
logger?.debug?.(
|
||||
`Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
|
||||
);
|
||||
|
||||
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||
}
|
||||
@@ -85,8 +218,8 @@ async function run(ctx, controlState) {
|
||||
module.exports = {
|
||||
name: 'levelbased',
|
||||
run,
|
||||
// Exposed for future reuse / tests; not part of the strategy contract.
|
||||
_scaleLevelToFlowPercent,
|
||||
_curveShape,
|
||||
_applyMachineGroupLevelControl,
|
||||
_applyMachineLevelControl,
|
||||
};
|
||||
|
||||
281
src/editor.js
281
src/editor.js
@@ -1,281 +0,0 @@
|
||||
(function () {
|
||||
// Namespace declaration — Node-RED admin scripts share window state.
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.pumpingStation = window.EVOLV.nodes.pumpingStation || {};
|
||||
|
||||
// SVG diagram constants — viewBox-coordinate top/bottom of the tank rect.
|
||||
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));
|
||||
};
|
||||
|
||||
// Position 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');
|
||||
};
|
||||
|
||||
const placeZone = (zoneId, topId, botId, items) => {
|
||||
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');
|
||||
};
|
||||
|
||||
const computeStack = (basinH) => {
|
||||
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 at the rim (top),
|
||||
// outflowLevel at its proportional y (bottom). Two passes nudge
|
||||
// intermediate items by GAP so dashed lines keep their value-order.
|
||||
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;
|
||||
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);
|
||||
}
|
||||
return { items, dryLvl, ovfLvl };
|
||||
};
|
||||
|
||||
const drawInflow = (basinH) => {
|
||||
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||
if (inflowY == null) return;
|
||||
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 drawOrderingWarning = () => {
|
||||
const warn = document.getElementById('ps-warning');
|
||||
if (!warn) return;
|
||||
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 (issues.length) {
|
||||
warn.setAttribute('visibility', 'visible');
|
||||
warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`;
|
||||
} else {
|
||||
warn.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
const { items, dryLvl, ovfLvl } = computeStack(basinH);
|
||||
for (const it of items) placeItem(it.id, it.y);
|
||||
|
||||
placeZone('spare', 'overflowLevel', 'maxLevel', items);
|
||||
placeZone('sewage', 'maxLevel', 'startLevel', items);
|
||||
placeZone('buffer1', 'startLevel', 'minLevel', items);
|
||||
placeZone('buffer2', 'minLevel', 'dryRunLevel', items);
|
||||
|
||||
// "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');
|
||||
}
|
||||
|
||||
drawInflow(basinH);
|
||||
|
||||
// Dead-volume band: from the (possibly nudged) outflow line down to the floor.
|
||||
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));
|
||||
}
|
||||
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = dryLvl != null
|
||||
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
|
||||
: 'dryRunLevel ≈ — m (safety — from %)';
|
||||
|
||||
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';
|
||||
|
||||
drawOrderingWarning();
|
||||
};
|
||||
|
||||
const wireProtectionToggle = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) return;
|
||||
const apply = () => {
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
toggleEl.addEventListener('change', apply);
|
||||
apply();
|
||||
};
|
||||
|
||||
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 setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
const editor = {
|
||||
init(node) {
|
||||
// Defer asset/menu init until shared menu data is loaded.
|
||||
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';
|
||||
|
||||
const minHeightBasedOnEl = document.getElementById('node-input-minHeightBasedOn');
|
||||
if (minHeightBasedOnEl) minHeightBasedOnEl.value = node.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');
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!node.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
||||
wireProtectionToggle(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
if (overfillToggle && overfillPercent) {
|
||||
overfillToggle.checked = !!node.enableOverfillProtection;
|
||||
overfillPercent.value = Number.isFinite(node.overfillThresholdPercent) ? node.overfillThresholdPercent : 98;
|
||||
wireProtectionToggle(overfillToggle, overfillPercent);
|
||||
}
|
||||
|
||||
const timeLeftInput = document.getElementById('node-input-timeleftToFullOrEmptyThresholdSeconds');
|
||||
if (timeLeftInput) {
|
||||
timeLeftInput.value = Number.isFinite(node.timeleftToFullOrEmptyThresholdSeconds)
|
||||
? node.timeleftToFullOrEmptyThresholdSeconds
|
||||
: 0;
|
||||
}
|
||||
|
||||
const modeSelect = document.getElementById('node-input-controlMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = node.controlMode || 'none';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
setNumberField('node-input-startLevel', node.startLevel);
|
||||
setNumberField('node-input-minLevel', node.minLevel);
|
||||
setNumberField('node-input-maxLevel', node.maxLevel);
|
||||
setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||
setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||
|
||||
const watched = ['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
|
||||
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'];
|
||||
for (const id of watched) {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
|
||||
}
|
||||
setTimeout(redraw, 60);
|
||||
},
|
||||
|
||||
save(node) {
|
||||
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;
|
||||
|
||||
const numericFields = ['basinVolume','basinHeight','inflowLevel','outflowLevel','overflowLevel',
|
||||
'basinBottomRef','timeleftToFullOrEmptyThresholdSeconds',
|
||||
'dryRunThresholdPercent','overfillThresholdPercent'];
|
||||
for (const field of numericFields) {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
}
|
||||
|
||||
// Original code reassigned refHeight here with default '' instead of 'NAP'.
|
||||
// Preserve that behaviour byte-for-byte so saved node JSON is identical.
|
||||
node.refHeight = document.getElementById('node-input-refHeight').value || '';
|
||||
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
|
||||
node.enableOverfillProtection = document.getElementById('node-input-enableOverfillProtection').checked;
|
||||
|
||||
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.EVOLV.nodes.pumpingStation.editor = editor;
|
||||
})();
|
||||
191
src/editor/basin-diagram.js
Normal file
191
src/editor/basin-diagram.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// PumpingStation editor — interactive basin SVG (top of the editor).
|
||||
// Places threshold lines, derived safety levels, zone labels, dead-volume
|
||||
// band, and ordering warnings. Same formulas as
|
||||
// specificClass._validateThresholdOrdering.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
|
||||
// viewBox y bounds of the tank rect (now 120,40)..(240,380); width
|
||||
// shrunk to 360 in the new side-panel layout. y-bounds unchanged.
|
||||
const DIAG = { topY: 40, botY: 380 };
|
||||
|
||||
const yForLevel = (val, basinH) => {
|
||||
if (val == null || !basinH) return null;
|
||||
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
||||
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
||||
};
|
||||
|
||||
// Place a row — line, label, input, unit all share the same y.
|
||||
const placeItem = (id, y) => {
|
||||
const line = document.getElementById(`ps-line-${id}`);
|
||||
const label = document.getElementById(`ps-label-${id}`);
|
||||
const unit = document.getElementById(`ps-unit-${id}`);
|
||||
const fo = document.getElementById(`ps-fo-${id}`);
|
||||
const sub = document.getElementById(`ps-sub-${id}`);
|
||||
const lead = document.getElementById(`ps-leader-${id}`);
|
||||
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
||||
if (label) label.setAttribute('y', y + 4);
|
||||
if (unit) unit.setAttribute('y', y + 4);
|
||||
if (fo) fo.setAttribute('y', y - 11);
|
||||
if (sub) sub.setAttribute('y', y + 15);
|
||||
if (lead) lead.setAttribute('visibility', 'hidden');
|
||||
};
|
||||
|
||||
ns.basinDiagram = {
|
||||
redraw() {
|
||||
const basinH = fNum('basinHeight') || 5;
|
||||
|
||||
const refLow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const highPct = fNum('highVolumeSafetyThresholdPercent');
|
||||
const ovf = fNum('overflowLevel');
|
||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||
const highLvl = (ovf != null && highPct != null) ? ovf * (highPct / 100) : null;
|
||||
|
||||
// Right-column stack. TWO anchors: basinHeight pinned at the rim,
|
||||
// outflowLevel pinned at its proportional y. Two passes (top-down +
|
||||
// bottom-up) maintain a minimum vertical gap.
|
||||
const items = [
|
||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||
{ id: 'highVolumeSafetyLevel', yIdeal: yForLevel(highLvl, basinH) },
|
||||
{ id: 'inflowLevelGuide', yIdeal: yForLevel(fNum('inflowLevel'), basinH) },
|
||||
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
||||
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
||||
].filter(it => it.yIdeal != null);
|
||||
|
||||
const GAP = 36;
|
||||
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
||||
for (const it of items) it.y = it.yIdeal;
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
||||
}
|
||||
for (let i = items.length - 2; i >= 0; i--) {
|
||||
if (items[i].pinned) continue;
|
||||
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
||||
}
|
||||
for (const it of items) placeItem(it.id, it.y);
|
||||
|
||||
// Zone labels show only when the gap between the bracketing
|
||||
// thresholds is at least MIN_ZONE_GAP px high — otherwise the label
|
||||
// collides with one of the threshold labels (which sit at threshold
|
||||
// y ±6 px text-height). 28 px keeps a 6 px clear gap above and
|
||||
// below the zone label.
|
||||
const MIN_ZONE_GAP = 28;
|
||||
const placeZone = (zoneId, topId, botId) => {
|
||||
const el = document.getElementById(`ps-zone-${zoneId}`);
|
||||
if (!el) return;
|
||||
const top = items.find(it => it.id === topId);
|
||||
const bot = items.find(it => it.id === botId);
|
||||
if (!top || !bot || (bot.y - top.y) < MIN_ZONE_GAP) {
|
||||
el.setAttribute('visibility', 'hidden'); return;
|
||||
}
|
||||
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||
el.setAttribute('visibility', 'visible');
|
||||
};
|
||||
placeZone('spare', 'overflowLevel', 'highVolumeSafetyLevel');
|
||||
placeZone('sewage', 'highVolumeSafetyLevel', 'inflowLevelGuide');
|
||||
placeZone('buffer1', 'inflowLevelGuide', 'dryRunLevel');
|
||||
placeZone('buffer2', 'dryRunLevel', 'outflowLevel');
|
||||
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
||||
const deadLbl = document.getElementById('ps-zone-dead');
|
||||
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
||||
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
||||
deadLbl.setAttribute('visibility', 'visible');
|
||||
} else if (deadLbl) {
|
||||
deadLbl.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
|
||||
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||
if (inflowY != null) {
|
||||
const line = document.getElementById('ps-line-inflowLevel');
|
||||
const lbl = document.getElementById('ps-label-inflowLevel');
|
||||
const sub = document.getElementById('ps-sub-inflowLevel');
|
||||
const fo = document.getElementById('ps-fo-inflowLevel');
|
||||
const unit = document.getElementById('ps-unit-inflowLevel');
|
||||
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
||||
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
||||
if (sub) sub.setAttribute('y', inflowY + 8);
|
||||
if (fo) fo.setAttribute('y', inflowY - 11);
|
||||
if (unit) unit.setAttribute('y', inflowY + 4);
|
||||
}
|
||||
|
||||
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
||||
const deadvol = document.getElementById('ps-deadvol');
|
||||
if (deadvol && outflowItem) {
|
||||
deadvol.setAttribute('y', outflowItem.y);
|
||||
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
||||
}
|
||||
|
||||
// SVG labels — keep them short, side panel shows the numeric value.
|
||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||
if (dryLbl) dryLbl.textContent = 'dryRunLevel';
|
||||
const highLbl = document.getElementById('ps-label-highVolumeSafetyLevel');
|
||||
if (highLbl) highLbl.textContent = 'highVolumeSafety';
|
||||
|
||||
// Side-panel read-only displays — number only ("m" is shown in the unit span).
|
||||
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||
const d1 = document.getElementById('derived-dryRunLevel');
|
||||
if (d1) d1.textContent = fmt(dryLvl);
|
||||
const d2 = document.getElementById('derived-highVolumeSafetyLevel');
|
||||
if (d2) d2.textContent = fmt(highLvl);
|
||||
|
||||
// Hierarchy validation. Soft '≤' relations follow the user's choice:
|
||||
// start ≤ inflow, max ≤ overflow, overflow ≤ basinHeight (equality OK).
|
||||
// dryRunLevel must be < startLevel strictly (otherwise the runtime
|
||||
// would trip dry-run before it could ramp).
|
||||
// Re-read the raw value (basinH falls back to 5 for diagram scaling;
|
||||
// here we want null when the user hasn't entered anything so the
|
||||
// ≤-checks below are skipped rather than false-flagged).
|
||||
const basinHraw = fNum('basinHeight');
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
const ovfl = fNum('overflowLevel');
|
||||
const issues = [];
|
||||
const ok = (a, b, op) => {
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
|
||||
return op === '<' ? a < b : a <= b;
|
||||
};
|
||||
if (Number.isFinite(refLow) && refLow <= 0)
|
||||
issues.push('outflowLevel must be > 0');
|
||||
if (!ok(dryLvl, start, '<'))
|
||||
issues.push(`dryRunLevel (${(dryLvl ?? NaN).toFixed(2)} m, derived) must be < startLevel — lower dryRun% or raise startLevel`);
|
||||
if (!ok(start, inlet, '<='))
|
||||
issues.push('startLevel must be ≤ inflowLevel');
|
||||
if (!ok(inlet, max, '<='))
|
||||
issues.push('inflowLevel must be ≤ maxLevel');
|
||||
if (!ok(max, ovfl, '<='))
|
||||
issues.push('maxLevel must be ≤ overflowLevel');
|
||||
if (!ok(ovfl, basinHraw, '<='))
|
||||
issues.push('overflowLevel must be ≤ basinHeight');
|
||||
|
||||
// Visible ribbon above the basin diagram.
|
||||
const warnDiv = document.getElementById('ps-basin-validation');
|
||||
if (warnDiv) {
|
||||
if (issues.length) {
|
||||
warnDiv.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||
warnDiv.style.display = '';
|
||||
} else {
|
||||
warnDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// Legacy in-SVG warning text — kept for the small reminder inside
|
||||
// the diagram. Only shows the count.
|
||||
const warn = document.getElementById('ps-warning');
|
||||
if (warn) {
|
||||
if (issues.length) {
|
||||
warn.setAttribute('visibility', 'visible');
|
||||
warn.textContent = `⚠ ${issues.length} ordering issue${issues.length > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
warn.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
}
|
||||
window._psBasinValidationIssues = issues;
|
||||
},
|
||||
};
|
||||
})();
|
||||
96
src/editor/bounds.js
Normal file
96
src/editor/bounds.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// PumpingStation editor — dynamic input bounds.
|
||||
// Sets HTML5 min/max attributes on every level and percent input based on
|
||||
// the current values of related inputs, so the up/down arrows stop at
|
||||
// values that respect the basin hierarchy:
|
||||
//
|
||||
// 0 < outflowLevel < dryRunLevel < startLevel ≤ inflowLevel
|
||||
// ≤ shiftLevel ≤ maxLevel ≤ overflowLevel ≤ basinHeight
|
||||
//
|
||||
// The user can still type out-of-range values via the keyboard (HTML5
|
||||
// min/max only constrain the spinner). The validation ribbons in
|
||||
// basin-diagram.js and mode-preview.js catch typed violations and the
|
||||
// oneditsave handler blocks Deploy until they're resolved.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
const EPS = 0.001; // smallest meaningful step (mm-precision)
|
||||
|
||||
const setBounds = (id, min, max) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (!el) return;
|
||||
if (Number.isFinite(min)) el.setAttribute('min', String(min));
|
||||
else el.removeAttribute('min');
|
||||
if (Number.isFinite(max)) el.setAttribute('max', String(max));
|
||||
else el.removeAttribute('max');
|
||||
};
|
||||
|
||||
ns.bounds = {
|
||||
apply() {
|
||||
const basinHeight = fNum('basinHeight');
|
||||
const outflow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
const overflow = fNum('overflowLevel');
|
||||
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
|
||||
// Derived dryRunLevel (lower bound for startLevel).
|
||||
const dryRun = (Number.isFinite(outflow) && Number.isFinite(dryPct))
|
||||
? outflow * (1 + dryPct / 100) : null;
|
||||
|
||||
// Geometry — basin envelope.
|
||||
setBounds('basinHeight', EPS, undefined);
|
||||
setBounds('basinVolume', EPS, undefined);
|
||||
|
||||
// Levels (each capped by the next-higher level if defined).
|
||||
setBounds('outflowLevel', EPS,
|
||||
Number.isFinite(start) && Number.isFinite(dryPct)
|
||||
? start / (1 + dryPct / 100) - EPS // keep dryRun < start
|
||||
: (start ?? inlet ?? max ?? overflow ?? basinHeight));
|
||||
|
||||
setBounds('startLevel',
|
||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||
inlet ?? max ?? overflow ?? basinHeight);
|
||||
|
||||
setBounds('inflowLevel',
|
||||
start ?? EPS,
|
||||
max ?? overflow ?? basinHeight);
|
||||
|
||||
setBounds('maxLevel',
|
||||
inlet ?? start ?? EPS,
|
||||
overflow ?? basinHeight);
|
||||
|
||||
setBounds('overflowLevel',
|
||||
max ?? inlet ?? start ?? EPS,
|
||||
basinHeight);
|
||||
|
||||
// stopLevel — explicit pump-off threshold. Must sit between
|
||||
// dryRunLevel and startLevel (so it can be reached during draining
|
||||
// before pumps re-engage).
|
||||
setBounds('stopLevel',
|
||||
Number.isFinite(dryRun) ? dryRun + EPS : EPS,
|
||||
start ?? inlet ?? max ?? overflow ?? basinHeight);
|
||||
|
||||
// Shift inputs (only relevant when shifted ramp enabled).
|
||||
if (shiftEnabled) {
|
||||
setBounds('shiftLevel',
|
||||
Number.isFinite(start) ? start : EPS,
|
||||
max ?? overflow ?? basinHeight);
|
||||
setBounds('shiftArmPercent', 1, 100);
|
||||
}
|
||||
|
||||
// Percentages.
|
||||
// dryRun% capped so dryRunLevel ≤ startLevel.
|
||||
let dryMax = 99;
|
||||
if (Number.isFinite(start) && Number.isFinite(outflow) && outflow > 0) {
|
||||
dryMax = Math.max(0, Math.min(99, ((start / outflow) - 1) * 100));
|
||||
}
|
||||
setBounds('dryRunThresholdPercent', 0, dryMax);
|
||||
|
||||
// highVol% bounded (1, 100). Equal to 100 means no margin to overflow.
|
||||
setBounds('highVolumeSafetyThresholdPercent', 1, 100);
|
||||
},
|
||||
};
|
||||
})();
|
||||
29
src/editor/hover-couple.js
Normal file
29
src/editor/hover-couple.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// PumpingStation editor — hover-coupling between side-panel input rows
|
||||
// and the SVG markers they control. Each .ps-row that carries
|
||||
// data-couples-line="<svg-element-id>" highlights that SVG line on
|
||||
// mouseenter and clears the highlight on mouseleave.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
ns.hoverCouple = {
|
||||
init() {
|
||||
document.querySelectorAll('.ps-diag-side .ps-row[data-couples-line]').forEach((row) => {
|
||||
const targetId = row.getAttribute('data-couples-line');
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
const enter = () => target.classList.add('ps-line-highlight');
|
||||
const leave = () => target.classList.remove('ps-line-highlight');
|
||||
row.addEventListener('mouseenter', enter);
|
||||
row.addEventListener('mouseleave', leave);
|
||||
// Also highlight while the input inside the row has focus, so
|
||||
// the user keeps the visual feedback while typing.
|
||||
const input = row.querySelector('input');
|
||||
if (input) {
|
||||
input.addEventListener('focus', enter);
|
||||
input.addEventListener('blur', leave);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
30
src/editor/index.js
Normal file
30
src/editor/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// PumpingStation editor — shared namespace + helpers.
|
||||
// Loaded first by pumpingStation.html via /pumpingStation/editor/index.js.
|
||||
// Each sibling module attaches additional members to window.PSEditor.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
// Read a numeric value from an input by node-input-<id>; null if blank/NaN.
|
||||
ns.fNum = (id) => {
|
||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
};
|
||||
|
||||
// Set a numeric input's value, or blank if not finite.
|
||||
ns.setNumberField = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||
};
|
||||
|
||||
// Add input + change listeners to a list of node-input-* ids.
|
||||
ns.bindRedraw = (ids, handler) => {
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(`node-input-${id}`);
|
||||
if (el) {
|
||||
el.addEventListener('input', handler);
|
||||
el.addEventListener('change', handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
286
src/editor/mode-preview.js
Normal file
286
src/editor/mode-preview.js
Normal file
@@ -0,0 +1,286 @@
|
||||
// PumpingStation editor — level-based mode preview SVG.
|
||||
// Draws zone bands, level markers, the up curve (inflowLevel→maxLevel) and
|
||||
// the optional shifted-down curve (startLevel→shiftLevel). Computes
|
||||
// validation issues and stashes them on window._psModeValidationIssues
|
||||
// for oneditsave to read.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
const fNum = (id) => ns.fNum(id);
|
||||
|
||||
// Derive dryRunLevel the same way the basin diagram does.
|
||||
// dryRunLevel = outflowLevel × (1 + dryRunThresholdPercent/100).
|
||||
// Returns null if either input is missing.
|
||||
ns.deriveDryRunLevel = () => {
|
||||
const refLow = fNum('outflowLevel');
|
||||
const dryPct = fNum('dryRunThresholdPercent');
|
||||
if (refLow == null || dryPct == null) return null;
|
||||
return refLow * (1 + dryPct / 100);
|
||||
};
|
||||
|
||||
ns.modePreview = {
|
||||
redraw() {
|
||||
const svg = document.getElementById('ps-levelbased-mode-diagram');
|
||||
if (!svg) return;
|
||||
const start = fNum('startLevel');
|
||||
const inlet = fNum('inflowLevel');
|
||||
const max = fNum('maxLevel');
|
||||
// Optional stopLevel — explicit pump-off threshold. Drawn as its
|
||||
// own marker line; does NOT shift the ramp foot. Must be < startLevel
|
||||
// for the marker to render.
|
||||
const stopRaw = fNum('stopLevel');
|
||||
const stop = Number.isFinite(stopRaw) && stopRaw >= 0 && Number.isFinite(start) && stopRaw < start ? stopRaw : null;
|
||||
// dryRunLevel is derived from the basin's outflowLevel + dryRun%
|
||||
// (no separate input). Below dryRunLevel the runtime hard-stops;
|
||||
// we draw it as the leftmost vertical marker so the user sees
|
||||
// exactly where it lands.
|
||||
const dryRun = ns.deriveDryRunLevel();
|
||||
const overflow = fNum('overflowLevel');
|
||||
const shiftEnabled = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftRaw = fNum('shiftLevel');
|
||||
const shift = Number.isFinite(shiftRaw) && shiftRaw > 0 ? Math.min(shiftRaw, max ?? shiftRaw) : null;
|
||||
const armRaw = fNum('shiftArmPercent');
|
||||
const armPct = Number.isFinite(armRaw) ? Math.max(0, Math.min(100, armRaw)) : 95;
|
||||
const curveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
const factorRaw = parseFloat(document.getElementById('node-input-logCurveFactor')?.value);
|
||||
const factor = Number.isFinite(factorRaw) && factorRaw > 0 ? factorRaw : 9;
|
||||
|
||||
// Plot window is FIXED relative to basin geometry so that moving any
|
||||
// single level slides only that line, not all the others. Lower bound
|
||||
// is the basin floor (0); upper bound is overflowLevel (or maxLevel
|
||||
// if overflow isn't set) plus a small margin.
|
||||
const upperRefs = [max, overflow].filter(Number.isFinite);
|
||||
const upperBase = upperRefs.length ? Math.max(...upperRefs) : 1;
|
||||
const pad = Math.max(upperBase * 0.05, 0.1);
|
||||
const levelMin = 0;
|
||||
const levelMax = upperBase + pad;
|
||||
|
||||
// Plot rectangle (viewBox px).
|
||||
const x0 = 52, x1 = 390, y0 = 140, y1 = 24;
|
||||
const yOffPx = 160;
|
||||
const yOffPct = -((yOffPx - y0) / (y0 - y1)) * 100;
|
||||
const xFor = (level) => x0 + ((level - levelMin) / (levelMax - levelMin)) * (x1 - x0);
|
||||
const yForPct = (pct) => y0 - (pct / 100) * (y0 - y1);
|
||||
const scale = (x) => {
|
||||
const clamped = Math.max(0, Math.min(1, x));
|
||||
if (curveType === 'log') return Math.log1p(factor * clamped) / Math.log1p(factor);
|
||||
return clamped;
|
||||
};
|
||||
|
||||
// Path with three flat regions and a ramp:
|
||||
// [levelMin..startX] OFF (pump off; below startLevel)
|
||||
// [startX..footX] 0 % (system armed but not yet ramping)
|
||||
// [footX..topX] ramp (linear or log scaled 0..100 %)
|
||||
// [topX..levelMax] 100 % (saturated)
|
||||
// Up curve: startX=startLevel, footX=inflowLevel, topX=maxLevel.
|
||||
// Shifted-down: startX=footX=startLevel, topX=shiftLevel.
|
||||
const buildPath = (startX, footX, topX) => {
|
||||
if (![startX, footX, topX].every(Number.isFinite) || topX <= footX) return '';
|
||||
const pts = [];
|
||||
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(startX)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(startX)},${yForPct(0)}`);
|
||||
if (footX > startX) pts.push(`${xFor(footX)},${yForPct(0)}`);
|
||||
for (let i = 0; i <= 24; i++) {
|
||||
const t = i / 24;
|
||||
const level = footX + t * (topX - footX);
|
||||
pts.push(`${xFor(level)},${yForPct(scale(t) * 100)}`);
|
||||
}
|
||||
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||
return pts.join(' ');
|
||||
};
|
||||
|
||||
// Up curve. Foot is startLevel (the configured pump-on threshold and
|
||||
// ramp foot per the runtime in _controlLevelBased). The OFF baseline
|
||||
// is drawn for level < startLevel; at startLevel demand jumps from
|
||||
// OFF to 0 % and ramps up to 100 % at maxLevel.
|
||||
const up = document.getElementById('ps-mode-curve-up');
|
||||
const down = document.getElementById('ps-mode-curve-down');
|
||||
const downLabel = document.getElementById('ps-mode-curve-down-label');
|
||||
if (up) up.setAttribute('points', buildPath(start, start, max));
|
||||
|
||||
// Shifted-DOWN curve (only when shift enabled): represents the
|
||||
// worst-case held-then-ramp path drawn for hold=100 % (the SVG
|
||||
// ideal). Geometry: 100 % flat from levelMax back to shiftLevel,
|
||||
// then linear/log ramp from (shiftLevel, 100 %) down to
|
||||
// (startLevel, 0 %), then OFF below startLevel.
|
||||
// Real runtime hold value depends on where direction flips, so the
|
||||
// preview shows the maximum extent.
|
||||
const buildShiftedDown = () => {
|
||||
if (![start, shift].every(Number.isFinite) || shift <= start) return '';
|
||||
const pts = [];
|
||||
// OFF baseline far-left to startLevel
|
||||
pts.push(`${xFor(levelMin)},${yForPct(yOffPct)}`);
|
||||
pts.push(`${xFor(start)},${yForPct(yOffPct)}`);
|
||||
// Jump 0 % at startLevel
|
||||
pts.push(`${xFor(start)},${yForPct(0)}`);
|
||||
// Ramp start→shift = 0..100 % (peak hold = 100 % for this preview)
|
||||
for (let i = 0; i <= 24; i++) {
|
||||
const t = i / 24;
|
||||
const lvl = start + t * (shift - start);
|
||||
pts.push(`${xFor(lvl)},${yForPct(scale(t) * 100)}`);
|
||||
}
|
||||
// Held at 100 % from shift → far-right
|
||||
pts.push(`${xFor(levelMax)},${yForPct(100)}`);
|
||||
return pts.join(' ');
|
||||
};
|
||||
if (down) {
|
||||
if (shiftEnabled) {
|
||||
down.setAttribute('points', buildShiftedDown());
|
||||
down.style.display = '';
|
||||
if (downLabel) downLabel.style.display = '';
|
||||
} else {
|
||||
down.setAttribute('points', '');
|
||||
down.style.display = 'none';
|
||||
if (downLabel) downLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal arming-% line — only meaningful when shift enabled.
|
||||
const armLine = document.getElementById('ps-mode-line-armPercent');
|
||||
const armLabel = document.getElementById('ps-mode-label-armPercent');
|
||||
if (armLine && armLabel) {
|
||||
if (shiftEnabled) {
|
||||
const yArm = yForPct(armPct);
|
||||
armLine.setAttribute('y1', yArm);
|
||||
armLine.setAttribute('y2', yArm);
|
||||
armLabel.setAttribute('y', yArm - 2);
|
||||
armLabel.textContent = `arm ${Math.round(armPct)}%`;
|
||||
armLine.style.display = '';
|
||||
armLabel.style.display = '';
|
||||
} else {
|
||||
armLine.style.display = 'none';
|
||||
armLabel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical level markers — line only. Axis labels were removed;
|
||||
// identification comes from line colour + side-panel labels +
|
||||
// hover coupling.
|
||||
[
|
||||
['dryRunLevel', dryRun],
|
||||
['startLevel', start],
|
||||
['stopLevel', stop],
|
||||
['inflowLevel', inlet],
|
||||
['maxLevel', max],
|
||||
['overflowLevel', overflow],
|
||||
].forEach(([id, level]) => {
|
||||
const line = document.getElementById(`ps-mode-line-${id}`);
|
||||
if (!line) return;
|
||||
if (!Number.isFinite(level)) {
|
||||
line.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const x = xFor(level);
|
||||
line.style.display = '';
|
||||
line.setAttribute('x1', x); line.setAttribute('x2', x);
|
||||
});
|
||||
|
||||
// Background zone bands.
|
||||
const plotL = xFor(levelMin);
|
||||
const plotR = xFor(levelMax);
|
||||
const setBand = (id, a, b) => {
|
||||
const r = document.getElementById(id);
|
||||
if (!r) return;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) {
|
||||
r.setAttribute('x', 0); r.setAttribute('width', 0);
|
||||
return;
|
||||
}
|
||||
r.setAttribute('x', a);
|
||||
r.setAttribute('width', b - a);
|
||||
};
|
||||
const xMin = Number.isFinite(dryRun) ? xFor(dryRun) : plotL;
|
||||
const xStart = Number.isFinite(start) ? xFor(start) : xMin;
|
||||
const xMax = Number.isFinite(max) ? xFor(max) : plotR;
|
||||
const xOvf = Number.isFinite(overflow) ? xFor(overflow) : xMax;
|
||||
setBand('ps-zone-dryRun', plotL, xMin);
|
||||
setBand('ps-zone-safetyLow', xMin, xStart);
|
||||
setBand('ps-zone-safe', xStart, xMax);
|
||||
setBand('ps-zone-safetyHigh', xMax, xOvf);
|
||||
setBand('ps-zone-overflow', xOvf, plotR);
|
||||
|
||||
// Shift level marker (line only).
|
||||
const shiftLine = document.getElementById('ps-mode-line-shiftLevel');
|
||||
if (shiftLine) {
|
||||
if (shiftEnabled && Number.isFinite(shift)) {
|
||||
const x = xFor(shift);
|
||||
shiftLine.setAttribute('x1', x); shiftLine.setAttribute('x2', x);
|
||||
shiftLine.style.display = '';
|
||||
} else {
|
||||
shiftLine.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Title + row visibility.
|
||||
const curveLabel = document.getElementById('ps-mode-curve-label');
|
||||
if (curveLabel) curveLabel.textContent = curveType === 'log' ? 'log curve: fast early response' : 'linear curve';
|
||||
const shiftRow = document.getElementById('ps-shiftLevel-row');
|
||||
if (shiftRow) shiftRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const armRow = document.getElementById('ps-shiftArmPercent-row');
|
||||
if (armRow) armRow.style.display = shiftEnabled ? '' : 'none';
|
||||
const logRow = document.getElementById('ps-log-factor-row');
|
||||
if (logRow) logRow.style.display = curveType === 'log' ? '' : 'none';
|
||||
|
||||
// Auto-default shiftLevel when shift is enabled and current value
|
||||
// is missing/out-of-range. Visible default avoids a hidden ramp.
|
||||
const shiftInput = document.getElementById('node-input-shiftLevel');
|
||||
if (shiftEnabled && shiftInput && Number.isFinite(max)) {
|
||||
const cur = parseFloat(shiftInput.value);
|
||||
if (!Number.isFinite(cur) || cur <= 0 || cur >= max) {
|
||||
shiftInput.value = (max * 0.9).toFixed(2);
|
||||
}
|
||||
}
|
||||
// Auto-default shiftArmPercent to 95 % when shift is enabled and the
|
||||
// current value is missing / out of [0, 100].
|
||||
const armInput = document.getElementById('node-input-shiftArmPercent');
|
||||
if (shiftEnabled && armInput) {
|
||||
const cur = parseFloat(armInput.value);
|
||||
if (!Number.isFinite(cur) || cur < 0 || cur > 100) {
|
||||
armInput.value = 95;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation: only mode-specific (shift) ordering. Basin-level
|
||||
// hierarchy (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
|
||||
// dryRun < start) is owned by basin-diagram.js so it shows in the
|
||||
// basin section near the offending inputs.
|
||||
const issues = [];
|
||||
if (shiftEnabled) {
|
||||
const shiftVal = Number(shiftInput?.value);
|
||||
if (Number.isFinite(shiftVal)) {
|
||||
if (Number.isFinite(start) && shiftVal <= start)
|
||||
issues.push('shiftLevel must be > startLevel');
|
||||
if (Number.isFinite(max) && shiftVal > max)
|
||||
issues.push('shiftLevel must be ≤ maxLevel');
|
||||
} else {
|
||||
issues.push('shiftLevel is required when shifted ramp is enabled');
|
||||
}
|
||||
const armVal = Number(armInput?.value);
|
||||
if (!Number.isFinite(armVal) || armVal <= 0 || armVal > 100)
|
||||
issues.push('shiftArmPercent must be in (0, 100]');
|
||||
}
|
||||
const warnBox = document.getElementById('ps-mode-validation');
|
||||
if (warnBox) {
|
||||
if (issues.length) {
|
||||
warnBox.innerHTML = '⚠ Fix before deploy:<ul style="margin:4px 0 0 18px;padding:0;">'
|
||||
+ issues.map((i) => `<li>${i}</li>`).join('') + '</ul>';
|
||||
warnBox.style.display = '';
|
||||
} else {
|
||||
warnBox.style.display = 'none';
|
||||
}
|
||||
}
|
||||
window._psModeValidationIssues = issues;
|
||||
|
||||
// Read-only readouts in the side panel — number only; the row's
|
||||
// .ps-unit span already shows "m".
|
||||
const fmt = (v) => Number.isFinite(v) ? v.toFixed(2) : '—';
|
||||
const setText = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = fmt(val);
|
||||
};
|
||||
setText('ps-mode-readout-dryRun', dryRun);
|
||||
setText('ps-mode-readout-inflow', inlet);
|
||||
setText('ps-mode-readout-overflow', overflow);
|
||||
},
|
||||
};
|
||||
})();
|
||||
114
src/editor/oneditprepare.js
Normal file
114
src/editor/oneditprepare.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// PumpingStation editor — oneditprepare entry. Wires up form-field
|
||||
// initialization, control-mode toggle, safety toggles, and binds
|
||||
// redraws for the basin diagram + level-based mode preview.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
ns.oneditprepare = function () {
|
||||
const node = this;
|
||||
|
||||
// Wait for menu data (asset/logger/position dropdowns) before init.
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||
window.EVOLV.nodes.pumpingStation.initEditor(node);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
waitForMenuData();
|
||||
|
||||
const refHeightEl = document.getElementById('node-input-refHeight');
|
||||
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
|
||||
|
||||
// Safety toggle pairs — each toggle enables/disables its threshold input.
|
||||
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
|
||||
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
|
||||
const highVolumeToggle = document.getElementById('node-input-enableHighVolumeSafety');
|
||||
const highVolumePercent = document.getElementById('node-input-highVolumeSafetyThresholdPercent');
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) return;
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!node.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (highVolumeToggle && highVolumePercent) {
|
||||
highVolumeToggle.checked = node.enableHighVolumeSafety !== undefined
|
||||
? !!node.enableHighVolumeSafety
|
||||
: !!node.enableOverfillProtection;
|
||||
const highVolumePct = node.highVolumeSafetyThresholdPercent ?? node.overfillThresholdPercent;
|
||||
highVolumePercent.value = Number.isFinite(highVolumePct) ? highVolumePct : 98;
|
||||
highVolumeToggle.addEventListener('change', () => toggleInput(highVolumeToggle, highVolumePercent));
|
||||
toggleInput(highVolumeToggle, highVolumePercent);
|
||||
}
|
||||
|
||||
// Control-mode section toggle (levelbased / manual).
|
||||
const toggleModeSections = (val) => {
|
||||
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||
const active = document.getElementById(`ps-mode-${val}`);
|
||||
if (active) active.style.display = '';
|
||||
};
|
||||
const modeSelect = document.getElementById('node-input-controlMode');
|
||||
if (modeSelect) {
|
||||
modeSelect.value = node.controlMode === 'manual' ? 'manual' : 'levelbased';
|
||||
toggleModeSections(modeSelect.value);
|
||||
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||
}
|
||||
|
||||
// Numeric field defaults.
|
||||
ns.setNumberField('node-input-startLevel', node.startLevel);
|
||||
ns.setNumberField('node-input-maxLevel', node.maxLevel);
|
||||
ns.setNumberField('node-input-logCurveFactor', node.logCurveFactor);
|
||||
ns.setNumberField('node-input-shiftLevel', node.shiftLevel);
|
||||
ns.setNumberField('node-input-shiftArmPercent', Number.isFinite(node.shiftArmPercent) ? node.shiftArmPercent : 95);
|
||||
ns.setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||
ns.setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||
|
||||
const curveSelect = document.getElementById('node-input-levelCurveType');
|
||||
if (curveSelect) curveSelect.value = node.levelCurveType || node.curveType || 'linear';
|
||||
const shiftCheckbox = document.getElementById('node-input-enableShiftedRamp');
|
||||
if (shiftCheckbox) shiftCheckbox.checked = !!node.enableShiftedRamp;
|
||||
|
||||
// Bind redraws to the inputs each diagram cares about.
|
||||
ns.bindRedraw(
|
||||
['basinHeight', 'overflowLevel', 'inflowLevel', 'outflowLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent'],
|
||||
ns.basinDiagram.redraw
|
||||
);
|
||||
ns.bindRedraw(
|
||||
// dryRunLevel is derived (outflowLevel + dryRunThresholdPercent),
|
||||
// so the mode preview must redraw when either of those change.
|
||||
['startLevel', 'maxLevel', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'dryRunThresholdPercent',
|
||||
'levelCurveType', 'logCurveFactor', 'enableShiftedRamp', 'shiftLevel',
|
||||
'shiftArmPercent'],
|
||||
ns.modePreview.redraw
|
||||
);
|
||||
|
||||
// Whenever any level/percent input changes, refresh the bounds first
|
||||
// so the next redraw + validation sees the correct min/max attrs.
|
||||
ns.bindRedraw(
|
||||
['basinHeight', 'basinVolume', 'overflowLevel', 'maxLevel',
|
||||
'inflowLevel', 'startLevel', 'outflowLevel',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||
'enableShiftedRamp', 'shiftLevel', 'shiftArmPercent'],
|
||||
() => ns.bounds?.apply()
|
||||
);
|
||||
|
||||
// Initial render + hover-couple wiring once the DOM is settled.
|
||||
setTimeout(() => {
|
||||
ns.bounds?.apply();
|
||||
ns.basinDiagram.redraw();
|
||||
ns.modePreview.redraw();
|
||||
ns.hoverCouple?.init();
|
||||
}, 60);
|
||||
};
|
||||
})();
|
||||
69
src/editor/oneditsave.js
Normal file
69
src/editor/oneditsave.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// PumpingStation editor — oneditsave handler. Validates, saves shared
|
||||
// menu sections (logger/position), then persists pumpingStation-specific
|
||||
// fields onto the node. Throws if validation fails to keep the editor open.
|
||||
|
||||
(function () {
|
||||
const ns = window.PSEditor = window.PSEditor || {};
|
||||
|
||||
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||
|
||||
ns.oneditsave = function () {
|
||||
const node = this;
|
||||
|
||||
// Block save if EITHER validator surfaced any issues. basin-diagram
|
||||
// owns hierarchy issues (start ≤ inlet ≤ max ≤ overflow ≤ basinHeight,
|
||||
// dryRun < start). mode-preview owns shift-specific issues.
|
||||
const basinIssues = window._psBasinValidationIssues || [];
|
||||
const modeIssues = window._psModeValidationIssues || [];
|
||||
const issues = [...basinIssues, ...modeIssues];
|
||||
if (issues.length) {
|
||||
if (typeof RED !== 'undefined' && RED.notify) {
|
||||
RED.notify('PumpingStation config invalid:<br>• ' + issues.join('<br>• '),
|
||||
{ type: 'error', timeout: 6000 });
|
||||
}
|
||||
throw new Error('PumpingStation: invalid config — ' + issues.join('; '));
|
||||
}
|
||||
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
|
||||
node.simulator = document.getElementById('node-input-simulator').checked;
|
||||
|
||||
[
|
||||
'basinVolume', 'basinHeight', 'inflowLevel', 'outflowLevel', 'overflowLevel',
|
||||
'basinBottomRef',
|
||||
'dryRunThresholdPercent', 'highVolumeSafetyThresholdPercent',
|
||||
].forEach((field) => {
|
||||
const el = document.getElementById(`node-input-${field}`);
|
||||
if (el) node[field] = parseFloat(el.value) || 0;
|
||||
});
|
||||
|
||||
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
|
||||
node.enableHighVolumeSafety = document.getElementById('node-input-enableHighVolumeSafety').checked;
|
||||
// Deprecated aliases kept for existing runtime/schema compatibility.
|
||||
node.enableOverfillProtection = node.enableHighVolumeSafety;
|
||||
node.overfillThresholdPercent = node.highVolumeSafetyThresholdPercent;
|
||||
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'levelbased';
|
||||
node.levelCurveType = document.getElementById('node-input-levelCurveType')?.value || 'linear';
|
||||
node.logCurveFactor = parseNum('node-input-logCurveFactor');
|
||||
node.startLevel = parseNum('node-input-startLevel');
|
||||
node.maxLevel = parseNum('node-input-maxLevel');
|
||||
// minLevel is no longer a user input — it's the derived dryRunLevel
|
||||
// (outflowLevel × (1 + dryRunThresholdPercent/100)). The runtime still
|
||||
// uses node.minLevel as the unconditional STOP threshold; we set it
|
||||
// here so that semantic survives the UI change.
|
||||
const _dryRun = ns.deriveDryRunLevel?.();
|
||||
if (Number.isFinite(_dryRun)) node.minLevel = _dryRun;
|
||||
node.enableShiftedRamp = !!document.getElementById('node-input-enableShiftedRamp')?.checked;
|
||||
const shiftLevelVal = parseNum('node-input-shiftLevel');
|
||||
node.shiftLevel = Number.isFinite(shiftLevelVal) ? shiftLevelVal : 0;
|
||||
const armPctVal = parseNum('node-input-shiftArmPercent');
|
||||
node.shiftArmPercent = Number.isFinite(armPctVal) ? armPctVal : 95;
|
||||
const flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||
const flowDeadband = parseNum('node-input-flowDeadband');
|
||||
if (Number.isFinite(flowSetpoint)) node.flowSetpoint = flowSetpoint;
|
||||
if (Number.isFinite(flowDeadband)) node.flowDeadband = flowDeadband;
|
||||
};
|
||||
})();
|
||||
@@ -73,8 +73,19 @@ function setManualInflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
||||
.value(num, timestamp, unit);
|
||||
}
|
||||
|
||||
// Manual outflow injection mirroring setManualInflow — basin-docs adds this
|
||||
// for the dashboard's q_out topic so tests can drive a drain stroke without
|
||||
// instantiating a real pump.
|
||||
function setManualOutflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
||||
if (!ctx?.measurements) throw new Error('setManualOutflow: ctx.measurements required');
|
||||
const num = Number(value);
|
||||
ctx.measurements.type('flow').variant('predicted').position('out').child('manual-qout')
|
||||
.value(num, timestamp, unit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calibratePredictedVolume,
|
||||
calibratePredictedLevel,
|
||||
setManualInflow,
|
||||
setManualOutflow,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
// Pure domain. Takes a context bag with the live MeasurementContainer, the
|
||||
// basin geometry, and the merged config; mutates measurements in place and
|
||||
// keeps a tiny piece of integrator state internally.
|
||||
//
|
||||
// Ports from basin-docs:
|
||||
// - Predicted-volume integrator clamped to [dryRunSafetyVol, maxVolAtOverflow]
|
||||
// with hard physical floor at 0 (predicted volume can never go negative).
|
||||
// - Synthetic spill flow at position 'overflow' so net-flow balance
|
||||
// reads ~0 while pinned at overflow.
|
||||
// - Cumulative overflowVolume + underflowVolume streams for compliance /
|
||||
// diagnostic reporting via InfluxDB.
|
||||
|
||||
const { interpolation } = require('generalFunctions');
|
||||
|
||||
@@ -35,9 +43,14 @@ class FlowAggregator {
|
||||
? ctx.flowThreshold
|
||||
: (Number.isFinite(cfgThresh) ? cfgThresh : DEFAULT_FLOW_THRESHOLD);
|
||||
|
||||
// Optional callback so the host can supply derived safety thresholds
|
||||
// without us re-importing the validator. Returns { dryRunSafetyVol, ... }.
|
||||
this._computeSafetyPoints = ctx.computeSafetyPoints || (() => ({ dryRunSafetyVol: 0 }));
|
||||
|
||||
this._predictedFlowState = null;
|
||||
this._lastNetFlow = { value: 0, source: null, direction: 'steady' };
|
||||
this._lastRemaining = { seconds: null, source: null };
|
||||
this._lastLevelRateNetFlow = null;
|
||||
}
|
||||
|
||||
resetState(timestamp = Date.now()) {
|
||||
@@ -48,34 +61,89 @@ class FlowAggregator {
|
||||
const flowUnit = 'm3/s';
|
||||
const now = Date.now();
|
||||
|
||||
// Synthetic spill flow lives at its OWN position ('overflow') —
|
||||
// not as a child of 'out'. That keeps it out of the operational
|
||||
// outflow sum here so no self-subtraction is needed.
|
||||
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||
const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
|
||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow, lastTimestamp: now };
|
||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||
|
||||
const tPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||||
const dt = Math.max((now - tPrev) / 1000, 0);
|
||||
const dV = dt > 0 ? (inflow - outflow) * dt : 0;
|
||||
const dV = dt > 0 ? (inflow - outflowReal) * dt : 0;
|
||||
|
||||
const volSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||
const currentVol = volSeries.getCurrentValue('m3');
|
||||
const nextVol = (currentVol ?? this.basin.minVol ?? 0) + dV;
|
||||
const currentVol = this.measurements
|
||||
.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? this.basin.minVol ?? 0;
|
||||
const writeTs = tPrev + dt * 1000;
|
||||
|
||||
volSeries.value(nextVol, writeTs, 'm3').unit('m3');
|
||||
// Bounds.
|
||||
// Upper (hard physical): maxVolAtOverflow — past this the basin
|
||||
// spills; predicted level pins at overflowLevel and the excess
|
||||
// becomes cumulative overflowVolume + synthetic spill flow.
|
||||
// Lower (operational): dryRunSafetyVol — clamps ON TRANSITION
|
||||
// from above so the integrator can't drop into the unphysical
|
||||
// band. A basin seeded BELOW it is left alone (startup from empty).
|
||||
// Lower (hard physical): 0 — basin cannot hold negative water.
|
||||
// Any negative excess is tracked as underflowVolume (diagnostic).
|
||||
const safety = this._computeSafetyPoints();
|
||||
const upperClamp = this.basin.maxVolAtOverflow;
|
||||
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
|
||||
|
||||
const proposedVolume = currentVol + dV;
|
||||
let nextVolume = proposedVolume;
|
||||
let overflowIncrement = 0;
|
||||
let underflowIncrement = 0;
|
||||
if (proposedVolume > upperClamp) {
|
||||
overflowIncrement = proposedVolume - upperClamp;
|
||||
nextVolume = upperClamp;
|
||||
} else if (proposedVolume < lowerClamp && currentVol >= lowerClamp) {
|
||||
nextVolume = lowerClamp;
|
||||
}
|
||||
if (nextVolume < 0) {
|
||||
underflowIncrement = -nextVolume;
|
||||
nextVolume = 0;
|
||||
}
|
||||
|
||||
// Synthetic spill flow at position 'overflow'.
|
||||
let spillRate = 0;
|
||||
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
|
||||
spillRate = inflow - outflowReal;
|
||||
}
|
||||
this.measurements
|
||||
.type('flow').variant('predicted').position('overflow')
|
||||
.value(spillRate, writeTs, 'm3/s').unit('m3/s');
|
||||
|
||||
if (overflowIncrement > 0) {
|
||||
const prev = this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment')
|
||||
.value(prev + overflowIncrement, writeTs, 'm3').unit('m3');
|
||||
}
|
||||
if (underflowIncrement > 0) {
|
||||
const prev = this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment')
|
||||
.value(prev + underflowIncrement, writeTs, 'm3').unit('m3');
|
||||
}
|
||||
|
||||
this.measurements.type('volume').variant('predicted').position('atequipment')
|
||||
.value(nextVolume, writeTs, 'm3').unit('m3');
|
||||
|
||||
const surfaceArea = this.basin.surfaceArea;
|
||||
const nextLevel = surfaceArea > 0 ? Math.max(nextVol, 0) / surfaceArea : 0;
|
||||
const nextLevel = surfaceArea > 0 ? Math.max(nextVolume, 0) / surfaceArea : 0;
|
||||
this.measurements.type('level').variant('predicted').position('atequipment')
|
||||
.value(nextLevel, writeTs, 'm').unit('m');
|
||||
|
||||
const percent = this._interp.interpolate_lin_single_point(
|
||||
nextVol, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||
nextVolume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||
);
|
||||
this.measurements.type('volumePercent').variant('predicted').position('atequipment')
|
||||
.value(percent, writeTs, '%');
|
||||
|
||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTs };
|
||||
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTs };
|
||||
}
|
||||
|
||||
selectBestNetFlow() {
|
||||
@@ -87,7 +155,11 @@ class FlowAggregator {
|
||||
if (!bucket || Object.keys(bucket).length === 0) continue;
|
||||
|
||||
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
|
||||
const outflow = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
|
||||
const outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
|
||||
// Fold synthetic spill (position 'overflow') into the outflow side
|
||||
// so net-flow balance reads ~0 while pinned at the overflow level.
|
||||
const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0;
|
||||
const outflow = outflowReal + spill;
|
||||
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
|
||||
|
||||
const net = inflow - outflow;
|
||||
@@ -101,8 +173,21 @@ class FlowAggregator {
|
||||
for (const variant of this.levelVariants) {
|
||||
const rate = this._levelRate(variant);
|
||||
if (!Number.isFinite(rate)) continue;
|
||||
const net = rate * this.basin.surfaceArea;
|
||||
const result = { value: net, source: `level:${variant}`, direction: this.deriveDirection(net) };
|
||||
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
|
||||
const pinnedAtOverflow = Number.isFinite(lvl)
|
||||
&& Number.isFinite(this.basin.overflowLevel)
|
||||
&& lvl >= this.basin.overflowLevel - 1e-9;
|
||||
const rateNearZero = Math.abs(rate) < 1e-9;
|
||||
|
||||
let netFlow = rate * this.basin.surfaceArea;
|
||||
// Pinned at overflow — dL/dt collapses to 0 but flow IS still
|
||||
// moving (in → spill). Hold the last known non-zero net-flow.
|
||||
if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) {
|
||||
netFlow = this._lastLevelRateNetFlow;
|
||||
} else if (!rateNearZero) {
|
||||
this._lastLevelRateNetFlow = netFlow;
|
||||
}
|
||||
const result = { value: netFlow, source: `level:${variant}`, direction: this.deriveDirection(netFlow) };
|
||||
this._lastNetFlow = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { BaseNodeAdapter } = require('generalFunctions');
|
||||
const { BaseNodeAdapter, configManager } = require('generalFunctions');
|
||||
const PumpingStation = require('./specificClass');
|
||||
const commands = require('./commands');
|
||||
|
||||
@@ -17,29 +17,63 @@ class nodeClass extends BaseNodeAdapter {
|
||||
inflowLevel: uiConfig.inflowLevel,
|
||||
outflowLevel: uiConfig.outflowLevel,
|
||||
overflowLevel: uiConfig.overflowLevel,
|
||||
inletPipeDiameter: uiConfig.inletPipeDiameter,
|
||||
outletPipeDiameter: uiConfig.outletPipeDiameter,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: uiConfig.refHeight,
|
||||
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
||||
basinBottomRef: uiConfig.basinBottomRef,
|
||||
maxInflowRate: uiConfig.maxInflowRate,
|
||||
staticHead: uiConfig.staticHead,
|
||||
maxDischargeHead: uiConfig.maxDischargeHead,
|
||||
pipelineLength: uiConfig.pipelineLength,
|
||||
defaultFluid: uiConfig.defaultFluid,
|
||||
temperatureReferenceDegC: uiConfig.temperatureReferenceDegC,
|
||||
},
|
||||
control: {
|
||||
mode: uiConfig.controlMode,
|
||||
levelbased: {
|
||||
minLevel: uiConfig.minLevel,
|
||||
startLevel: uiConfig.startLevel,
|
||||
stopLevel: uiConfig.stopLevel,
|
||||
maxLevel: uiConfig.maxLevel,
|
||||
// Editor names the field levelCurveType; runtime uses curveType.
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Test-only entrypoint mirroring the basin-docs config-mapping surface.
|
||||
// Lets `NodeClass.prototype._loadConfig.call({name:'pumpingStation'}, ui, node)`
|
||||
// produce the merged config without instantiating a full Node-RED adapter.
|
||||
// Production wiring goes through BaseNodeAdapter; this is a thin shim.
|
||||
_loadConfig(uiConfig, node) {
|
||||
const cfgMgr = new configManager();
|
||||
const name = this.name || 'pumpingStation';
|
||||
const domain = nodeClass.prototype.buildDomainConfig.call(this, uiConfig);
|
||||
this.defaultConfig = cfgMgr.getConfig(name);
|
||||
this.config = cfgMgr.buildConfig(name, uiConfig, node && node.id, domain);
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nodeClass;
|
||||
|
||||
@@ -107,12 +107,15 @@ class SafetyController {
|
||||
_overfillRule(vol, direction, secondsRemaining) {
|
||||
if (direction !== 'filling') return { triggered: false, flags: [] };
|
||||
const s = this._safetyConfig();
|
||||
const overfillEnabled = Boolean(s.enableOverfillProtection);
|
||||
// basin-docs renamed enableOverfillProtection → enableHighVolumeSafety;
|
||||
// both work as aliases (HEAD already maps in buildDomainConfig).
|
||||
const enabled = Boolean(s.enableHighVolumeSafety ?? s.enableOverfillProtection);
|
||||
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * ((Number(s.overfillThresholdPercent) || 0) / 100);
|
||||
const pct = Number(s.highVolumeSafetyThresholdPercent ?? s.overfillThresholdPercent) || 0;
|
||||
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * (pct / 100);
|
||||
|
||||
const flags = [];
|
||||
if (overfillEnabled && vol > triggerHighVol) flags.push('overfill-volume');
|
||||
if (enabled && vol > triggerHighVol) flags.push('overfill-volume');
|
||||
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||
flags.push('time-remaining');
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const { BaseDomain, UnitPolicy, statusBadge } = require('generalFunctions');
|
||||
const BasinGeometry = require('./basin/BasinGeometry');
|
||||
const { validateThresholdOrdering } = require('./basin/thresholdValidator');
|
||||
const { validateThresholdOrdering, computeSafetyPoints } = require('./basin/thresholdValidator');
|
||||
const FlowAggregator = require('./measurement/flowAggregator');
|
||||
const MeasurementRouter = require('./measurement/measurementRouter');
|
||||
const calibration = require('./measurement/calibration');
|
||||
@@ -20,9 +20,15 @@ class PumpingStation extends BaseDomain {
|
||||
// Internal math runs in m3/s for flow and m for level so the volume
|
||||
// integrator (flow × dt) is unit-consistent. Strict canonicals make
|
||||
// unit drift in child-fed measurements an explicit error.
|
||||
// overflowVolume / underflowVolume are listed in output so the
|
||||
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
||||
// (FlowAggregator writes spill / underflow per tick).
|
||||
static unitPolicy = UnitPolicy.declare({
|
||||
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||
output: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
|
||||
output: {
|
||||
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
|
||||
overflowVolume: 'm3', underflowVolume: 'm3',
|
||||
},
|
||||
requireUnitForTypes: [],
|
||||
});
|
||||
|
||||
@@ -38,6 +44,24 @@ class PumpingStation extends BaseDomain {
|
||||
this.controlState = { percControl: 0 };
|
||||
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
||||
|
||||
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
|
||||
// Exposed as instance fields because the e2e/basic tests assert on them
|
||||
// directly. levelBased strategy reads/writes via the same names.
|
||||
this._shiftArmed = false;
|
||||
this._shiftHoldValue = null;
|
||||
this._lastDirection = null;
|
||||
|
||||
// stopLevel hysteresis (Schmitt trigger) — ported from basin-docs.
|
||||
// TRUE while engaged (rising-edge at startLevel until falling-edge at
|
||||
// stopLevel). Used by levelBased to emit a small keep-alive output in
|
||||
// the [stopLevel, startLevel] dead band so MGC keeps one pump running.
|
||||
this._stopHystRunning = false;
|
||||
|
||||
// Flow dead-band — values below |flowThreshold| (m3/s) are treated as
|
||||
// steady. Default ≈ 0.36 m3/h.
|
||||
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||||
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||||
|
||||
// FlowAggregator owns the predicted-volume integrator + net-flow + ETA.
|
||||
this.flowAggregator = new FlowAggregator({
|
||||
measurements: this.measurements,
|
||||
@@ -47,6 +71,8 @@ class PumpingStation extends BaseDomain {
|
||||
flowVariants: this.flowVariants,
|
||||
levelVariants: this.levelVariants,
|
||||
flowPositions: this.flowPositions,
|
||||
flowThreshold: this.flowThreshold,
|
||||
computeSafetyPoints: () => this._computeSafetyPoints(),
|
||||
});
|
||||
this.measurementRouter = new MeasurementRouter({
|
||||
measurements: this.measurements,
|
||||
@@ -55,7 +81,9 @@ class PumpingStation extends BaseDomain {
|
||||
});
|
||||
|
||||
// Threshold ordering is non-fatal — log + surface for tests/status.
|
||||
this.thresholdIssues = validateThresholdOrdering(this.basin, this.config.control?.levelbased, this.config.safety);
|
||||
this.thresholdIssues = validateThresholdOrdering(
|
||||
this.basin, this.config.control?.levelbased, this.config.safety
|
||||
);
|
||||
for (const issue of this.thresholdIssues) this.logger.warn(issue.msg);
|
||||
|
||||
// Seed predicted volume at the operational floor — without it the
|
||||
@@ -97,6 +125,11 @@ class PumpingStation extends BaseDomain {
|
||||
}
|
||||
|
||||
// Frozen view passed to control strategies + safety.
|
||||
// `host` is a back-reference so strategies that need to mutate
|
||||
// cross-tick hysteresis state (`_shiftArmed`, `_shiftHoldValue`,
|
||||
// `_lastDirection`, `_stopHystRunning`) write straight to the live
|
||||
// instance — Object.freeze on the view itself is fine because these
|
||||
// flags live on the host, not in the view.
|
||||
context() {
|
||||
return Object.freeze({
|
||||
...super.context(),
|
||||
@@ -109,6 +142,8 @@ class PumpingStation extends BaseDomain {
|
||||
flowVariants: this.flowVariants,
|
||||
levelVariants: this.levelVariants,
|
||||
volVariants: this.volVariants,
|
||||
flowThreshold: this.flowThreshold,
|
||||
host: this,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +153,7 @@ class PumpingStation extends BaseDomain {
|
||||
this.safetyControllerActive = safe.blocked;
|
||||
|
||||
if (!safe.blocked) {
|
||||
Promise.resolve(control.dispatch(this.mode, this.context(), this.controlState))
|
||||
Promise.resolve(control.dispatch(this.mode, this.context(), this.controlState, netFlow.direction))
|
||||
.catch((err) => this.logger.error(`control dispatch failed: ${err.message}`));
|
||||
}
|
||||
|
||||
@@ -145,19 +180,38 @@ class PumpingStation extends BaseDomain {
|
||||
calibratePredictedVolume(vol, ts = Date.now()) { calibration.calibratePredictedVolume(this, vol, ts); }
|
||||
calibratePredictedLevel(lvl, ts = Date.now(), unit = 'm') { calibration.calibratePredictedLevel(this, lvl, ts, unit); }
|
||||
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
|
||||
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
|
||||
|
||||
forwardDemandToChildren(demand) { return control.manual.forwardDemand(this.context(), demand); }
|
||||
|
||||
// Direct delegations preserved so existing tests can drive the strategy
|
||||
// without re-mocking the dispatch layer.
|
||||
async _controlLevelBased() {
|
||||
return control.strategies.levelbased.run(this.context(), this.controlState);
|
||||
async _controlLevelBased(direction) {
|
||||
return control.strategies.levelbased.run(this.context(), this.controlState, direction);
|
||||
}
|
||||
|
||||
// Public getter so legacy tests + getOutput keep reading the live demand.
|
||||
get percControl() { return this.controlState.percControl; }
|
||||
set percControl(v) { this.controlState.percControl = v; }
|
||||
|
||||
// ── Predicted-volume integrator — tests drive this directly with a
|
||||
// controlled Date.now, so expose as an instance method that delegates
|
||||
// to FlowAggregator.update().
|
||||
_updatePredictedVolume() {
|
||||
return this.flowAggregator.update();
|
||||
}
|
||||
|
||||
// ── Mirror FlowAggregator internal integrator state so tests that pin
|
||||
// _predictedFlowState before driving a tick keep working.
|
||||
get _predictedFlowState() { return this.flowAggregator._predictedFlowState; }
|
||||
set _predictedFlowState(v) { this.flowAggregator._predictedFlowState = v; }
|
||||
|
||||
_selectBestNetFlow() { return this.flowAggregator.selectBestNetFlow(); }
|
||||
|
||||
_computeSafetyPoints() {
|
||||
return computeSafetyPoints(this.basin, this.config.safety || {});
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
const out = this.measurements.getFlattenedOutput();
|
||||
Object.assign(out, this.basin.snapshot());
|
||||
@@ -165,6 +219,23 @@ class PumpingStation extends BaseDomain {
|
||||
out.flowSource = this.state.flowSource;
|
||||
out.timeleft = this.state.seconds;
|
||||
out.percControl = this.controlState.percControl;
|
||||
|
||||
// Derived safety thresholds — exposed so editor + dashboards can show
|
||||
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
|
||||
const safety = this._computeSafetyPoints();
|
||||
out.dryRunLevel = safety.dryRunLevel;
|
||||
out.dryRunSafetyVol = safety.dryRunSafetyVol;
|
||||
out.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
|
||||
out.highVolumeSafetyVol = safety.highVolumeSafetyVol;
|
||||
|
||||
// Spill / underflow surface — populated by FlowAggregator when the
|
||||
// predicted-volume integrator hits the upper or lower physical bound.
|
||||
out.predictedOverflowVolume = this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
out.predictedOverflowRate = this.measurements
|
||||
.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0;
|
||||
out.predictedUnderflowVolume = this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user