P2 wave 1: extract concerns from pumpingStation specificClass
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.
src/basin/ BasinGeometry + thresholdValidator (pure)
src/measurement/ flowAggregator + measurementRouter + calibration
src/control/ levelBased + flowBased(stub) + manual + index dispatcher
src/safety/ safetyController split into dryRun + overfill rules
src/commands/ registry array + handlers (canonical names from start)
src/editor.js 260 lines of SVG basin-diagram redraw, was inline in .html
examples/standalone-demo.js was if(require.main===module) at bottom of specificClass.js
CONTRACT.md canonical inputs + outputs + emitted events
Modified:
src/specificClass.js removed the 170-line standalone demo block
pumpingStation.html oneditprepare/oneditsave delegate to editor.{init,save}
pumpingStation.js added admin endpoint serving src/editor.js
102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
src/basin/BasinGeometry.js
Normal file
91
src/basin/BasinGeometry.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// Basin geometry for a wet-well pumping station.
|
||||
//
|
||||
// Models the basin as a rectangular prism (constant cross-section), so
|
||||
// volume = level × surfaceArea. Owns the level↔volume conversions and the
|
||||
// derived threshold volumes used by control + safety. Pure domain — no
|
||||
// Node-RED, no logger, no side effects beyond construction.
|
||||
|
||||
class BasinGeometry {
|
||||
/**
|
||||
* @param {object} basinConfig - { volume, height, inflowLevel, outflowLevel, overflowLevel }
|
||||
* @param {object} hydraulicsConfig - { minHeightBasedOn: 'inlet' | 'outlet' }
|
||||
*/
|
||||
constructor(basinConfig, hydraulicsConfig) {
|
||||
const volEmptyBasin = basinConfig.volume;
|
||||
const heightBasin = basinConfig.height;
|
||||
const inflowLevel = basinConfig.inflowLevel;
|
||||
const outflowLevel = basinConfig.outflowLevel;
|
||||
const overflowLevel = basinConfig.overflowLevel;
|
||||
const minHeightBasedOn = hydraulicsConfig?.minHeightBasedOn;
|
||||
|
||||
const surfaceArea = volEmptyBasin / heightBasin;
|
||||
|
||||
// maxVol ≡ volEmptyBasin under the constant cross-section assumption;
|
||||
// kept as a separate field for naming symmetry with the trigger volumes.
|
||||
const maxVol = heightBasin * surfaceArea;
|
||||
const maxVolAtOverflow = overflowLevel * surfaceArea;
|
||||
const minVolAtOutflow = outflowLevel * surfaceArea;
|
||||
const minVolAtInflow = inflowLevel * surfaceArea;
|
||||
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
|
||||
|
||||
this._volEmptyBasin = volEmptyBasin;
|
||||
this._heightBasin = heightBasin;
|
||||
this._inflowLevel = inflowLevel;
|
||||
this._outflowLevel = outflowLevel;
|
||||
this._overflowLevel = overflowLevel;
|
||||
this._surfaceArea = surfaceArea;
|
||||
this._maxVol = maxVol;
|
||||
this._maxVolAtOverflow = maxVolAtOverflow;
|
||||
this._minVolAtInflow = minVolAtInflow;
|
||||
this._minVolAtOutflow = minVolAtOutflow;
|
||||
this._minVol = minVol;
|
||||
this._minHeightBasedOn = minHeightBasedOn;
|
||||
}
|
||||
|
||||
get volEmptyBasin() { return this._volEmptyBasin; }
|
||||
get heightBasin() { return this._heightBasin; }
|
||||
get inflowLevel() { return this._inflowLevel; }
|
||||
get outflowLevel() { return this._outflowLevel; }
|
||||
get overflowLevel() { return this._overflowLevel; }
|
||||
get surfaceArea() { return this._surfaceArea; }
|
||||
get maxVol() { return this._maxVol; }
|
||||
get maxVolAtOverflow() { return this._maxVolAtOverflow; }
|
||||
get minVolAtInflow() { return this._minVolAtInflow; }
|
||||
get minVolAtOutflow() { return this._minVolAtOutflow; }
|
||||
get minVol() { return this._minVol; }
|
||||
get minHeightBasedOn() { return this._minHeightBasedOn; }
|
||||
|
||||
/** Convert level (m from floor) → volume (m3). Negative levels clamp to 0. */
|
||||
volumeFromLevel(level) {
|
||||
return Math.max(level, 0) * this._surfaceArea;
|
||||
}
|
||||
|
||||
/** Convert volume (m3) → level (m from floor). Negative volumes clamp to 0. */
|
||||
levelFromVolume(volume) {
|
||||
return Math.max(volume, 0) / this._surfaceArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-object snapshot mirroring the legacy `this.basin` shape so
|
||||
* getOutput / status code can keep using the same field names without
|
||||
* caring whether it's holding a class instance or a plain object.
|
||||
*/
|
||||
snapshot() {
|
||||
return {
|
||||
volEmptyBasin: this._volEmptyBasin,
|
||||
heightBasin: this._heightBasin,
|
||||
inflowLevel: this._inflowLevel,
|
||||
outflowLevel: this._outflowLevel,
|
||||
overflowLevel: this._overflowLevel,
|
||||
surfaceArea: this._surfaceArea,
|
||||
maxVol: this._maxVol,
|
||||
maxVolAtOverflow: this._maxVolAtOverflow,
|
||||
minVolAtInflow: this._minVolAtInflow,
|
||||
minVolAtOutflow: this._minVolAtOutflow,
|
||||
minVol: this._minVol,
|
||||
minHeightBasedOn: this._minHeightBasedOn,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BasinGeometry;
|
||||
57
src/basin/thresholdValidator.js
Normal file
57
src/basin/thresholdValidator.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Threshold-ordering validator for the pumpingStation basin + control +
|
||||
// safety config. Pure: returns the issues array, never logs or throws.
|
||||
// The caller decides what to do (warn, surface to status badge, fail tests).
|
||||
//
|
||||
// Invariants enforced (level-space, bottom → top):
|
||||
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||
// dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel
|
||||
//
|
||||
// dryRunLevel and overfillLevel 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.
|
||||
|
||||
/**
|
||||
* @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 })
|
||||
* @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 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, '<', 'maxLevel', lvl.maxLevel],
|
||||
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
|
||||
];
|
||||
|
||||
const issues = [];
|
||||
for (const [aName, a, op, bName, b] of checks) {
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
|
||||
const ok = op === '<' ? a < b : a <= b;
|
||||
if (!ok) {
|
||||
issues.push({
|
||||
aName,
|
||||
a,
|
||||
op,
|
||||
bName,
|
||||
b,
|
||||
msg: `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
module.exports = { validateThresholdOrdering };
|
||||
87
src/commands/handlers.js
Normal file
87
src/commands/handlers.js
Normal file
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
// Handler functions for pumpingStation commands. Each handler receives:
|
||||
// source: the domain (specificClass) instance — has the public methods
|
||||
// (changeMode, calibratePredicted*, setManualInflow, ...).
|
||||
// msg: the Node-RED input message.
|
||||
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||
//
|
||||
// Handlers are pure functions: they don't keep state. Validation that goes
|
||||
// beyond the registry's typeof-check ladder lives here.
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
|
||||
exports.setMode = (source, msg) => {
|
||||
source.changeMode(msg.payload);
|
||||
};
|
||||
|
||||
exports.registerChild = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const childId = msg.payload;
|
||||
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
|
||||
return;
|
||||
}
|
||||
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||
};
|
||||
|
||||
exports.calibrateVolume = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const v = parseFloat(msg.payload);
|
||||
if (!Number.isFinite(v)) {
|
||||
log?.warn?.(`cmd.calibrate.volume: non-numeric payload '${msg.payload}'`);
|
||||
return;
|
||||
}
|
||||
source.calibratePredictedVolume(v);
|
||||
};
|
||||
|
||||
exports.calibrateLevel = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const v = parseFloat(msg.payload);
|
||||
if (!Number.isFinite(v)) {
|
||||
log?.warn?.(`cmd.calibrate.level: non-numeric payload '${msg.payload}'`);
|
||||
return;
|
||||
}
|
||||
source.calibratePredictedLevel(v);
|
||||
};
|
||||
|
||||
exports.setInflow = (source, msg) => {
|
||||
// Payload is either a number (legacy q_in shape) or
|
||||
// { value, unit, timestamp } (richer object form).
|
||||
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.setManualInflow(value, timestamp, unit);
|
||||
};
|
||||
|
||||
exports.setDemand = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const demand = Number(msg.payload);
|
||||
if (!Number.isFinite(demand)) {
|
||||
log?.warn?.(`set.demand: invalid Qd value '${msg.payload}'`);
|
||||
return;
|
||||
}
|
||||
if (source.mode !== 'manual') {
|
||||
log?.debug?.(
|
||||
`set.demand ignored in '${source.mode}' mode; switch to manual to use the demand slider`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// forwardDemandToChildren returns a promise — surface failures via logger.
|
||||
Promise.resolve(source.forwardDemandToChildren(demand)).catch((err) => {
|
||||
log?.error?.(`set.demand: failed to forward demand: ${err && err.message}`);
|
||||
});
|
||||
};
|
||||
50
src/commands/index.js
Normal file
50
src/commands/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
// pumpingStation command registry. Consumed by BaseNodeAdapter via
|
||||
// `static commands = require('./commands')`. Each descriptor maps a
|
||||
// canonical msg.topic to its handler; legacy names are listed under
|
||||
// `aliases` and emit a one-time deprecation warning at runtime.
|
||||
|
||||
const handlers = require('./handlers');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
topic: 'set.mode',
|
||||
aliases: ['changemode'],
|
||||
payloadSchema: { type: 'string' },
|
||||
handler: handlers.setMode,
|
||||
},
|
||||
{
|
||||
topic: 'child.register',
|
||||
aliases: ['registerChild'],
|
||||
// payload is the Node-RED id (string) of the child node.
|
||||
payloadSchema: { type: 'string' },
|
||||
handler: handlers.registerChild,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.calibrate.volume',
|
||||
aliases: ['calibratePredictedVolume'],
|
||||
// any: payload may be a number or numeric string.
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.calibrateVolume,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.calibrate.level',
|
||||
aliases: ['calibratePredictedLevel'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.calibrateLevel,
|
||||
},
|
||||
{
|
||||
topic: 'set.inflow',
|
||||
aliases: ['q_in'],
|
||||
// any: number, numeric string, or { value, unit, timestamp } object.
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.setInflow,
|
||||
},
|
||||
{
|
||||
topic: 'set.demand',
|
||||
aliases: ['Qd'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.setDemand,
|
||||
},
|
||||
];
|
||||
11
src/control/flowBased.js
Normal file
11
src/control/flowBased.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// Placeholder — flow-based control mode is not yet implemented.
|
||||
// The dispatcher routes here when config.control.mode === 'flowbased',
|
||||
// at which point a real implementation should land in this file.
|
||||
async function run(ctx) {
|
||||
ctx?.logger?.debug?.('flow-based mode not yet implemented');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'flowbased',
|
||||
run,
|
||||
};
|
||||
20
src/control/index.js
Normal file
20
src/control/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const levelBased = require('./levelBased');
|
||||
const flowBased = require('./flowBased');
|
||||
const manual = require('./manual');
|
||||
|
||||
const strategies = {
|
||||
[levelBased.name]: levelBased,
|
||||
[flowBased.name]: flowBased,
|
||||
[manual.name]: manual,
|
||||
};
|
||||
|
||||
function dispatch(mode, ctx, controlState) {
|
||||
const s = strategies[mode];
|
||||
if (!s) {
|
||||
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return s.run(ctx, controlState);
|
||||
}
|
||||
|
||||
module.exports = { strategies, dispatch, manual };
|
||||
92
src/control/levelBased.js
Normal file
92
src/control/levelBased.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const { interpolation } = require('generalFunctions');
|
||||
|
||||
const _interp = new interpolation();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||
await Promise.all(
|
||||
Object.values(machineGroups).map((group) =>
|
||||
group.handleInput('parent', percentControl).catch((err) => {
|
||||
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||
const filtered = Object.values(machines).filter((machine) => {
|
||||
const pos = machine?.config?.functionality?.positionVsParent;
|
||||
return (pos === 'downstream' || pos === 'atequipment');
|
||||
});
|
||||
if (!filtered.length) return;
|
||||
|
||||
const perMachine = percentControl / filtered.length;
|
||||
for (const machine of filtered) {
|
||||
try {
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||
} catch (err) {
|
||||
logger?.error?.(`Failed to start machine "${machine.config?.general?.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _pickVariant(measurements, type, variants, position, unit) {
|
||||
for (const variant of variants) {
|
||||
const val = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||
if (!Number.isFinite(val)) continue;
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function run(ctx, controlState) {
|
||||
const { measurements, config, logger, machineGroups, levelVariants } = ctx;
|
||||
const { startLevel, minLevel } = config.control.levelbased;
|
||||
const levelUnit = measurements.getUnit('level');
|
||||
|
||||
const variants = levelVariants || ['measured', 'predicted'];
|
||||
const level = _pickVariant(measurements, 'level', variants, 'atequipment', levelUnit);
|
||||
if (level == null) {
|
||||
logger?.warn?.('No valid level found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Three-zone level control:
|
||||
// level < minLevel → STOP (unconditional MGC shutdown)
|
||||
// minLevel ≤ level < startLevel → DEAD ZONE (no-op)
|
||||
// level ≥ startLevel → RUN (linear ramp → MGC)
|
||||
if (level < minLevel) {
|
||||
controlState.percControl = 0;
|
||||
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||
return;
|
||||
}
|
||||
|
||||
if (level < startLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'levelbased',
|
||||
run,
|
||||
// Exposed for future reuse / tests; not part of the strategy contract.
|
||||
_scaleLevelToFlowPercent,
|
||||
_applyMachineGroupLevelControl,
|
||||
_applyMachineLevelControl,
|
||||
};
|
||||
36
src/control/manual.js
Normal file
36
src/control/manual.js
Normal file
@@ -0,0 +1,36 @@
|
||||
async function run() {
|
||||
// No-op: manual mode is event-driven via set.demand → forwardDemand,
|
||||
// not tick-driven.
|
||||
}
|
||||
|
||||
async function forwardDemand(ctx, demand) {
|
||||
const { machineGroups, machines, logger } = ctx;
|
||||
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||
|
||||
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||
await Promise.all(
|
||||
Object.values(machineGroups).map((group) =>
|
||||
group.handleInput('parent', demand).catch((err) => {
|
||||
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (machines && Object.keys(machines).length > 0) {
|
||||
const perMachine = demand / Object.keys(machines).length;
|
||||
for (const machine of Object.values(machines)) {
|
||||
try {
|
||||
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||
} catch (err) {
|
||||
logger?.error?.(`Failed to forward demand to machine: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'manual',
|
||||
run,
|
||||
forwardDemand,
|
||||
};
|
||||
281
src/editor.js
Normal file
281
src/editor.js
Normal file
@@ -0,0 +1,281 @@
|
||||
(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;
|
||||
})();
|
||||
80
src/measurement/calibration.js
Normal file
80
src/measurement/calibration.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Calibration helpers for the pumping-station predicted volume / level
|
||||
// streams. Pure functions over a context bag holding the live
|
||||
// MeasurementContainer + basin geometry. After every calibration the
|
||||
// integrator state is reset so the next tick starts from the new anchor.
|
||||
|
||||
function _resetFlowState(ctx, timestamp) {
|
||||
if (ctx.flowAggregator?.resetState) {
|
||||
ctx.flowAggregator.resetState(timestamp);
|
||||
return;
|
||||
}
|
||||
ctx._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||
}
|
||||
|
||||
function _clearSeries(measurements, type) {
|
||||
const series = measurements.type(type).variant('predicted').position('atequipment');
|
||||
if (series.exists()) {
|
||||
const m = series.get();
|
||||
if (m) {
|
||||
m.values = [];
|
||||
m.timestamps = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _levelFromVolume(basin, volume) {
|
||||
const area = basin.surfaceArea;
|
||||
return area > 0 ? Math.max(volume, 0) / area : 0;
|
||||
}
|
||||
|
||||
function _volumeFromLevel(basin, level) {
|
||||
const area = basin.surfaceArea;
|
||||
return area > 0 ? Math.max(level, 0) * area : 0;
|
||||
}
|
||||
|
||||
function calibratePredictedVolume(ctx, calibratedVol, timestamp = Date.now()) {
|
||||
if (!ctx?.measurements || !ctx.basin) {
|
||||
throw new Error('calibratePredictedVolume: ctx.measurements and ctx.basin required');
|
||||
}
|
||||
const { measurements, basin } = ctx;
|
||||
|
||||
_clearSeries(measurements, 'volume');
|
||||
_clearSeries(measurements, 'level');
|
||||
|
||||
measurements.type('volume').variant('predicted').position('atequipment')
|
||||
.value(calibratedVol, timestamp, 'm3').unit('m3');
|
||||
measurements.type('level').variant('predicted').position('atequipment')
|
||||
.value(_levelFromVolume(basin, calibratedVol), timestamp, 'm');
|
||||
|
||||
_resetFlowState(ctx, timestamp);
|
||||
}
|
||||
|
||||
function calibratePredictedLevel(ctx, level, timestamp = Date.now(), unit = 'm') {
|
||||
if (!ctx?.measurements || !ctx.basin) {
|
||||
throw new Error('calibratePredictedLevel: ctx.measurements and ctx.basin required');
|
||||
}
|
||||
const { measurements, basin } = ctx;
|
||||
|
||||
_clearSeries(measurements, 'volume');
|
||||
_clearSeries(measurements, 'level');
|
||||
|
||||
measurements.type('level').variant('predicted').position('atequipment')
|
||||
.value(level, timestamp, unit);
|
||||
measurements.type('volume').variant('predicted').position('atequipment')
|
||||
.value(_volumeFromLevel(basin, level), timestamp, 'm3');
|
||||
|
||||
_resetFlowState(ctx, timestamp);
|
||||
}
|
||||
|
||||
function setManualInflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
||||
if (!ctx?.measurements) throw new Error('setManualInflow: ctx.measurements required');
|
||||
const num = Number(value);
|
||||
ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin')
|
||||
.value(num, timestamp, unit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calibratePredictedVolume,
|
||||
calibratePredictedLevel,
|
||||
setManualInflow,
|
||||
};
|
||||
180
src/measurement/flowAggregator.js
Normal file
180
src/measurement/flowAggregator.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// FlowAggregator — owns the predicted-volume integrator + net-flow selection
|
||||
// + remaining-time projection for the pumping-station basin.
|
||||
//
|
||||
// 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.
|
||||
|
||||
const { interpolation } = require('generalFunctions');
|
||||
|
||||
const DEFAULT_FLOW_THRESHOLD = 1e-4;
|
||||
const DEFAULT_FLOW_VARIANTS = ['measured', 'predicted'];
|
||||
const DEFAULT_LEVEL_VARIANTS = ['measured', 'predicted'];
|
||||
const DEFAULT_FLOW_POSITIONS = {
|
||||
inflow: ['in', 'upstream'],
|
||||
outflow: ['out', 'downstream'],
|
||||
};
|
||||
|
||||
class FlowAggregator {
|
||||
constructor(ctx = {}) {
|
||||
if (!ctx.measurements) throw new Error('FlowAggregator: ctx.measurements is required');
|
||||
if (!ctx.basin) throw new Error('FlowAggregator: ctx.basin is required');
|
||||
|
||||
this.measurements = ctx.measurements;
|
||||
this.basin = ctx.basin;
|
||||
this.config = ctx.config || {};
|
||||
this.logger = ctx.logger || null;
|
||||
this._interp = ctx.interpolation || new interpolation();
|
||||
|
||||
this.flowVariants = ctx.flowVariants || DEFAULT_FLOW_VARIANTS;
|
||||
this.levelVariants = ctx.levelVariants || DEFAULT_LEVEL_VARIANTS;
|
||||
this.flowPositions = ctx.flowPositions || DEFAULT_FLOW_POSITIONS;
|
||||
|
||||
const cfgThresh = Number(this.config?.general?.flowThreshold);
|
||||
this.flowThreshold = Number.isFinite(ctx.flowThreshold)
|
||||
? ctx.flowThreshold
|
||||
: (Number.isFinite(cfgThresh) ? cfgThresh : DEFAULT_FLOW_THRESHOLD);
|
||||
|
||||
this._predictedFlowState = null;
|
||||
this._lastNetFlow = { value: 0, source: null, direction: 'steady' };
|
||||
this._lastRemaining = { seconds: null, source: null };
|
||||
}
|
||||
|
||||
resetState(timestamp = Date.now()) {
|
||||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||
}
|
||||
|
||||
update() {
|
||||
const flowUnit = 'm3/s';
|
||||
const now = Date.now();
|
||||
|
||||
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||
const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
|
||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow, 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 volSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||
const currentVol = volSeries.getCurrentValue('m3');
|
||||
const nextVol = (currentVol ?? this.basin.minVol ?? 0) + dV;
|
||||
const writeTs = tPrev + dt * 1000;
|
||||
|
||||
volSeries.value(nextVol, writeTs, 'm3').unit('m3');
|
||||
|
||||
const surfaceArea = this.basin.surfaceArea;
|
||||
const nextLevel = surfaceArea > 0 ? Math.max(nextVol, 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
|
||||
);
|
||||
this.measurements.type('volumePercent').variant('predicted').position('atequipment')
|
||||
.value(percent, writeTs, '%');
|
||||
|
||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTs };
|
||||
}
|
||||
|
||||
selectBestNetFlow() {
|
||||
const type = 'flow';
|
||||
const unit = this.measurements.getUnit(type) || 'm3/s';
|
||||
|
||||
for (const variant of this.flowVariants) {
|
||||
const bucket = this.measurements.measurements?.[type]?.[variant];
|
||||
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;
|
||||
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
|
||||
|
||||
const net = inflow - outflow;
|
||||
this.measurements.type('netFlowRate').variant(variant).position('atequipment')
|
||||
.value(net, Date.now(), unit);
|
||||
const result = { value: net, source: variant, direction: this.deriveDirection(net) };
|
||||
this._lastNetFlow = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
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) };
|
||||
this._lastNetFlow = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (this.logger) this.logger.warn('No usable measurements to compute net flow; assuming steady.');
|
||||
const result = { value: 0, source: null, direction: 'steady' };
|
||||
this._lastNetFlow = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
computeRemainingTime(netFlow) {
|
||||
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) {
|
||||
this._lastRemaining = { seconds: null, source: null };
|
||||
return this._lastRemaining;
|
||||
}
|
||||
|
||||
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
|
||||
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) {
|
||||
this._lastRemaining = { seconds: null, source: null };
|
||||
return this._lastRemaining;
|
||||
}
|
||||
|
||||
for (const variant of this.levelVariants) {
|
||||
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
|
||||
if (!Number.isFinite(lvl)) continue;
|
||||
|
||||
const remainingHeight = netFlow.value > 0
|
||||
? Math.max(overflowLevel - lvl, 0)
|
||||
: Math.max(lvl - outflowLevel, 0);
|
||||
const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value);
|
||||
if (!Number.isFinite(seconds)) continue;
|
||||
|
||||
this._lastRemaining = { seconds, source: `${netFlow.source}/${variant}` };
|
||||
return this._lastRemaining;
|
||||
}
|
||||
|
||||
this._lastRemaining = { seconds: null, source: netFlow.source };
|
||||
return this._lastRemaining;
|
||||
}
|
||||
|
||||
deriveDirection(netFlow) {
|
||||
if (netFlow > this.flowThreshold) return 'filling';
|
||||
if (netFlow < -this.flowThreshold) return 'draining';
|
||||
return 'steady';
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.update();
|
||||
const netFlow = this.selectBestNetFlow();
|
||||
const remaining = this.computeRemainingTime(netFlow);
|
||||
return { netFlow, remaining };
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
direction: this._lastNetFlow.direction,
|
||||
netFlow: this._lastNetFlow.value,
|
||||
flowSource: this._lastNetFlow.source,
|
||||
secondsRemaining: this._lastRemaining.seconds,
|
||||
};
|
||||
}
|
||||
|
||||
_levelRate(variant) {
|
||||
const m = this.measurements.type('level').variant(variant).position('atequipment').get();
|
||||
if (!m || !m.values || m.values.length < 2) return null;
|
||||
const current = m.getLaggedSample?.(0);
|
||||
const previous = m.getLaggedSample?.(1);
|
||||
if (!current || !previous || previous.timestamp == null) return null;
|
||||
const dt = (current.timestamp - previous.timestamp) / 1000;
|
||||
if (!Number.isFinite(dt) || dt <= 0) return null;
|
||||
return (current.value - previous.value) / dt;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FlowAggregator;
|
||||
82
src/measurement/measurementRouter.js
Normal file
82
src/measurement/measurementRouter.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// MeasurementRouter — dispatches incoming measurement updates by type and
|
||||
// derives downstream measurements (volume from level, predicted level from
|
||||
// pressure). Pure domain over a context bag; no Node-RED dependency.
|
||||
|
||||
const { coolprop, interpolation } = require('generalFunctions');
|
||||
|
||||
const G = 9.80665;
|
||||
const ASSUMED_TEMPERATURE_C = 15;
|
||||
const ATMOSPHERIC_PRESSURE_PA = 101325;
|
||||
|
||||
class MeasurementRouter {
|
||||
constructor(ctx = {}) {
|
||||
if (!ctx.measurements) throw new Error('MeasurementRouter: ctx.measurements is required');
|
||||
if (!ctx.basin) throw new Error('MeasurementRouter: ctx.basin is required');
|
||||
|
||||
this.measurements = ctx.measurements;
|
||||
this.basin = ctx.basin;
|
||||
this.logger = ctx.logger || null;
|
||||
this._interp = ctx.interpolation || new interpolation();
|
||||
}
|
||||
|
||||
route(measurementType, value, position, eventData = {}) {
|
||||
switch (measurementType) {
|
||||
case 'level':
|
||||
this.onLevelMeasurement(position, value, eventData);
|
||||
return true;
|
||||
case 'pressure':
|
||||
this.onPressureMeasurement(position, value, eventData);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onLevelMeasurement(position, value, context = {}) {
|
||||
this.measurements.type('level').variant('measured').position(position)
|
||||
.value(value).unit(context.unit);
|
||||
|
||||
const series = this.measurements.type('level').variant('measured').position(position);
|
||||
const levelMeters = series.getCurrentValue('m');
|
||||
if (levelMeters == null) return;
|
||||
|
||||
const surfaceArea = this.basin.surfaceArea;
|
||||
const volume = surfaceArea > 0 ? Math.max(levelMeters, 0) * surfaceArea : 0;
|
||||
const percent = this._interp.interpolate_lin_single_point(
|
||||
volume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||
);
|
||||
|
||||
this.measurements.type('volume').variant('measured').position('atequipment')
|
||||
.value(volume, context.timestamp, 'm3');
|
||||
this.measurements.type('volumePercent').variant('measured').position('atequipment')
|
||||
.value(percent, context.timestamp, '%');
|
||||
}
|
||||
|
||||
onPressureMeasurement(position, value, context = {}) {
|
||||
let kelvin = this.measurements
|
||||
.type('temperature').variant('measured').position('atequipment')
|
||||
.getCurrentValue('K') ?? null;
|
||||
|
||||
if (kelvin === null) {
|
||||
if (this.logger) {
|
||||
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
|
||||
}
|
||||
this.measurements.type('temperature').variant('assumed').position('atequipment')
|
||||
.value(ASSUMED_TEMPERATURE_C, Date.now(), 'C');
|
||||
kelvin = this.measurements.type('temperature').variant('assumed').position('atequipment')
|
||||
.getCurrentValue('K');
|
||||
}
|
||||
if (kelvin == null) return;
|
||||
|
||||
const density = coolprop.PropsSI('D', 'T', kelvin, 'P', ATMOSPHERIC_PRESSURE_PA, 'Water');
|
||||
const pressurePa = this.measurements.type('pressure').variant('measured').position(position)
|
||||
.getCurrentValue('Pa');
|
||||
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
|
||||
|
||||
const level = pressurePa / (density * G);
|
||||
this.measurements.type('level').variant('predicted').position(position)
|
||||
.value(level, context.timestamp, 'm');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MeasurementRouter;
|
||||
153
src/safety/safetyController.js
Normal file
153
src/safety/safetyController.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// Safety controller for the pumping-station basin.
|
||||
//
|
||||
// Two hard rules, applied independently every tick:
|
||||
//
|
||||
// 1. DRY-RUN (volume below minVol while draining): pumps must stop.
|
||||
// Shuts down all DOWNSTREAM machines + machine groups + child
|
||||
// stations. Sets blocked=true so the orchestrator skips control
|
||||
// logic — only a manual override or estop can restart pumps.
|
||||
//
|
||||
// 2. OVERFILL (volume above overflow level while filling): pumps must
|
||||
// keep running. Shuts down UPSTREAM equipment only (stop more water
|
||||
// coming in) and child stations. Does NOT touch machine groups or
|
||||
// downstream pumps — they must keep draining. blocked stays false
|
||||
// so level-based control keeps demanding maximum throughput.
|
||||
//
|
||||
// A third path: if no volume reading is available, panic — shut down
|
||||
// every machine and block control.
|
||||
|
||||
function pickVariant(measurements, type, variants, position, unit) {
|
||||
for (const variant of variants) {
|
||||
const v = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||
if (Number.isFinite(v)) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class SafetyController {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {object} ctx.measurements MeasurementContainer-like instance
|
||||
* @param {object} ctx.basin BasinGeometry snapshot ({maxVolAtOverflow, minVol, ...})
|
||||
* @param {object} ctx.config pumpingStation config (uses .safety subtree)
|
||||
* @param {object} ctx.logger generalFunctions logger
|
||||
* @param {object} ctx.machines map of childId → rotatingMachine
|
||||
* @param {object} ctx.stations map of childId → child pumpingStation
|
||||
* @param {object} ctx.machineGroups map of childId → machineGroupControl
|
||||
* @param {string[]} [ctx.volVariants] order of volume variants to try
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.volVariants = ctx.volVariants || ['measured', 'predicted'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the dry-run + overfill rules against the current measurement state.
|
||||
*
|
||||
* @param {object} flowSnapshot { direction: 'filling'|'draining'|'steady',
|
||||
* secondsRemaining: number|null }
|
||||
* @returns {{blocked:boolean, reason:string|null, triggered:string[]}}
|
||||
*/
|
||||
evaluate(flowSnapshot) {
|
||||
const { measurements, basin, config, logger, machines } = this.ctx;
|
||||
const direction = flowSnapshot?.direction ?? 'steady';
|
||||
const secondsRemaining = flowSnapshot?.secondsRemaining ?? null;
|
||||
|
||||
const volUnit = measurements.getUnit('volume');
|
||||
const vol = pickVariant(measurements, 'volume', this.volVariants, 'atequipment', volUnit);
|
||||
|
||||
if (vol == null) {
|
||||
Object.values(machines).forEach((m) => m.handleInput('parent', 'execSequence', 'shutdown'));
|
||||
logger.warn('No volume data available to safe guard system; shutting down all machines.');
|
||||
return { blocked: true, reason: 'no-volume-data', triggered: ['no-volume-data'] };
|
||||
}
|
||||
|
||||
const triggered = [];
|
||||
let blocked = false;
|
||||
let reason = null;
|
||||
|
||||
const dry = this._dryRunRule(vol, direction, secondsRemaining);
|
||||
if (dry.triggered) {
|
||||
this._shutdownDownstream(vol, secondsRemaining);
|
||||
blocked = true;
|
||||
reason = 'dry-run';
|
||||
triggered.push(...dry.flags);
|
||||
}
|
||||
|
||||
const over = this._overfillRule(vol, direction, secondsRemaining);
|
||||
if (over.triggered) {
|
||||
this._shutdownUpstream(vol, secondsRemaining);
|
||||
// Overfill never sets blocked — control keeps running.
|
||||
if (reason == null) reason = 'overfill';
|
||||
triggered.push(...over.flags);
|
||||
}
|
||||
|
||||
return { blocked, reason, triggered };
|
||||
}
|
||||
|
||||
_safetyConfig() {
|
||||
return this.ctx.config.safety || {};
|
||||
}
|
||||
|
||||
_dryRunRule(vol, direction, secondsRemaining) {
|
||||
if (direction !== 'draining') return { triggered: false, flags: [] };
|
||||
const s = this._safetyConfig();
|
||||
const dryRunEnabled = Boolean(s.enableDryRunProtection);
|
||||
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||
const triggerLowVol = this.ctx.basin.minVol * (1 + ((Number(s.dryRunThresholdPercent) || 0) / 100));
|
||||
|
||||
const flags = [];
|
||||
if (dryRunEnabled && vol < triggerLowVol) flags.push('dry-run-volume');
|
||||
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||
flags.push('time-remaining');
|
||||
}
|
||||
return { triggered: flags.length > 0, flags };
|
||||
}
|
||||
|
||||
_overfillRule(vol, direction, secondsRemaining) {
|
||||
if (direction !== 'filling') return { triggered: false, flags: [] };
|
||||
const s = this._safetyConfig();
|
||||
const overfillEnabled = Boolean(s.enableOverfillProtection);
|
||||
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * ((Number(s.overfillThresholdPercent) || 0) / 100);
|
||||
|
||||
const flags = [];
|
||||
if (overfillEnabled && vol > triggerHighVol) flags.push('overfill-volume');
|
||||
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||
flags.push('time-remaining');
|
||||
}
|
||||
return { triggered: flags.length > 0, flags };
|
||||
}
|
||||
|
||||
_shutdownDownstream(vol, secondsRemaining) {
|
||||
const { machines, machineGroups, stations, logger } = this.ctx;
|
||||
Object.values(machines).forEach((machine) => {
|
||||
const pos = machine?.config?.functionality?.positionVsParent;
|
||||
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
|
||||
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||
}
|
||||
});
|
||||
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
|
||||
Object.values(machineGroups).forEach((g) => g.turnOffAllMachines());
|
||||
logger.warn(
|
||||
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
|
||||
);
|
||||
}
|
||||
|
||||
_shutdownUpstream(vol, secondsRemaining) {
|
||||
const { machines, stations, logger } = this.ctx;
|
||||
Object.values(machines).forEach((machine) => {
|
||||
const pos = machine?.config?.functionality?.positionVsParent;
|
||||
if (pos === 'upstream' && machine._isOperationalState()) {
|
||||
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||
}
|
||||
});
|
||||
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
|
||||
// Machine groups intentionally NOT shut down — they must keep draining.
|
||||
logger.warn(
|
||||
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SafetyController;
|
||||
@@ -861,179 +861,4 @@ class PumpingStation {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PumpingStation;
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Example usage */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
if (require.main === module) {
|
||||
const Measurement = require('../../measurement/src/specificClass');
|
||||
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
|
||||
|
||||
function createPumpingStationConfig(name) {
|
||||
return {
|
||||
general: {
|
||||
logging: { enabled: true, logLevel: 'debug' },
|
||||
name,
|
||||
id: `${name}-${Date.now()}`,
|
||||
flowThreshold: 1e-4
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'pumpingStation',
|
||||
role: 'stationcontroller'
|
||||
},
|
||||
basin: {
|
||||
volume: 43.75,
|
||||
height: 10,
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 3.2
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 0
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection:false,
|
||||
enableOverfillProtection:false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createLevelMeasurementConfig(name) {
|
||||
return {
|
||||
general: {
|
||||
logging: { enabled: true, logLevel: 'debug' },
|
||||
name,
|
||||
id: `${name}-${Date.now()}`,
|
||||
unit: 'm'
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'measurement',
|
||||
role: 'sensor',
|
||||
positionVsParent: 'atequipment'
|
||||
},
|
||||
asset: {
|
||||
category: 'sensor',
|
||||
type: 'level',
|
||||
model: 'demo-level',
|
||||
supplier: 'demoCo',
|
||||
unit: 'm'
|
||||
},
|
||||
scaling: { enabled: false },
|
||||
smoothing: { smoothWindow: 5, smoothMethod: 'none' }
|
||||
};
|
||||
}
|
||||
|
||||
function createFlowMeasurementConfig(name, position) {
|
||||
return {
|
||||
general: {
|
||||
logging: { enabled: true, logLevel: 'debug' },
|
||||
name,
|
||||
id: `${name}-${Date.now()}`,
|
||||
unit: 'm3/s'
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'measurement',
|
||||
role: 'sensor',
|
||||
positionVsParent: position
|
||||
},
|
||||
asset: {
|
||||
category: 'sensor',
|
||||
type: 'flow',
|
||||
model: 'demo-flow',
|
||||
supplier: 'demoCo',
|
||||
unit: 'm3/s'
|
||||
},
|
||||
scaling: { enabled: false },
|
||||
smoothing: { smoothWindow: 5, smoothMethod: 'none' }
|
||||
};
|
||||
}
|
||||
|
||||
function createMachineConfig(name,position) {
|
||||
return {
|
||||
general: {
|
||||
name,
|
||||
logging: { enabled: false, logLevel: 'debug' }
|
||||
},
|
||||
functionality: {
|
||||
softwareType: "machine",
|
||||
positionVsParent: position
|
||||
},
|
||||
asset: {
|
||||
supplier: 'Hydrostal',
|
||||
type: 'pump',
|
||||
category: 'centrifugal',
|
||||
model: 'hidrostal-H05K-S03R'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createMachineStateConfig() {
|
||||
return {
|
||||
general: {
|
||||
logging: {
|
||||
enabled: true,
|
||||
logLevel: 'debug'
|
||||
}
|
||||
},
|
||||
movement: { speed: 1 },
|
||||
time: {
|
||||
starting: 2,
|
||||
warmingup: 3,
|
||||
stopping: 2,
|
||||
coolingdown: 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function seedSample(measurement, type, value, unit) {
|
||||
const pos = measurement.config.functionality.positionVsParent;
|
||||
measurement.measurements.type(type).variant('measured').position(pos).value(value, Date.now(), unit);
|
||||
}
|
||||
|
||||
(async function demo() {
|
||||
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
|
||||
const pump1 = new RotatingMachine(createMachineConfig('Pump1','downstream'), createMachineStateConfig());
|
||||
//const pump2 = new RotatingMachine(createMachineConfig('Pump2','upstream'), createMachineStateConfig());
|
||||
|
||||
//const levelSensor = new Measurement(createLevelMeasurementConfig('WetWellLevel'));
|
||||
//const inflowSensor = new Measurement(createFlowMeasurementConfig('InfluentFlow', 'in'));
|
||||
//const outflowSensor = new Measurement(createFlowMeasurementConfig('PumpDischargeFlow', 'out'));
|
||||
|
||||
//station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType);
|
||||
//station.childRegistrationUtils.registerChild(inflowSensor, inflowSensor.config.functionality.softwareType);
|
||||
//station.childRegistrationUtils.registerChild(outflowSensor, outflowSensor.config.functionality.softwareType);
|
||||
|
||||
station.childRegistrationUtils.registerChild(pump1, 'machine');
|
||||
//station.childRegistrationUtils.registerChild(pump2, 'machine');
|
||||
|
||||
// Seed initial measurements
|
||||
|
||||
//seedSample(levelSensor, 'level', 1.8, 'm');
|
||||
//seedSample(inflowSensor, 'flow', 0.35, 'm3/s');
|
||||
//seedSample(outflowSensor, 'flow', 0.20, 'm3/s');
|
||||
|
||||
|
||||
|
||||
setInterval(
|
||||
() => station.tick(), 1000);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
console.log('Initial state:', station.state);
|
||||
station.setManualInflow(300,Date.now(),'l/s');
|
||||
station.calibratePredictedVolume(3.4);
|
||||
//await pump1.handleInput('parent', 'execSequence', 'startup');
|
||||
//await pump1.handleInput('parent', 'execMovement', 10);
|
||||
//
|
||||
//await pump2.handleInput('parent', 'execSequence', 'startup');
|
||||
//await pump2.handleInput('parent', 'execMovement', 10);
|
||||
|
||||
console.log('Station state:', station.state);
|
||||
console.log('Station output:', station.getOutput());
|
||||
})().catch((err) => {
|
||||
console.error('Demo failed:', err);
|
||||
});
|
||||
}
|
||||
//*/
|
||||
module.exports = PumpingStation;
|
||||
Reference in New Issue
Block a user