Runtime (specificClass.js): - Replace direction-based hysteresis with level-armed _shiftArmed state. Arms when level rises past shiftLevel; disarms when level drops below startLevel. While armed, ramp foot moves to startLevel and ramp top to shiftLevel — both ends shift left, then saturate at 100 % up to maxLevel. - _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so the saturation point follows the shift state. - New setManualOutflow mirroring setManualInflow. Adapter (nodeClass.js): - Pipe enableShiftedRamp / shiftLevel through to control.levelbased. - New q_out topic handler. Editor (pumpingStation.html + new src/editor/ modules): - Split monolithic <script> into modules: index.js (helpers), basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js, oneditsave.js — served via /pumpingStation/editor/:file. - Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 % flat from start→inlet, ramp inlet→max, optional shifted-down curve start→shift with 100 % saturation past shift. - Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh / overflow), level markers (dryRun derived, start, inlet, max, shift, overflow), validation ribbon that blocks save on bad ordering. - Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is always visible. - All level inputs moved to a side panel left of each diagram, color- coded to match line strokes; hover-couple highlights the paired SVG line on input focus / mouseover. - Removed UI for non-static parameters: minHeightBasedOn, pipelineLength, maxDischargeHead, staticHead, defaultFluid, maxInflowRate, temperatureReferenceDegC, timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter, outletPipeDiameter, minLevel (now derived = dryRunLevel). - foreignObject inputs in basin SVG removed (single source of truth in side panel). Dashboard example (examples/basic-dashboard.flow.json): - Add manual Q_OUT slider + q_out builder mirroring the existing q_in trio so the basin can be exercised end-to-end without a connected rotating-machine downstream. Tests (test/basic/specificClass.test.js): - Replace direction-shift test with two new cases covering shift-disabled hold-zone behaviour and shift-armed/disarmed transitions through shiftLevel and startLevel boundaries. 53/53 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1170 lines
46 KiB
JavaScript
1170 lines
46 KiB
JavaScript
const EventEmitter = require('events');
|
||
const {
|
||
logger,
|
||
configUtils,
|
||
configManager,
|
||
childRegistrationUtils,
|
||
MeasurementContainer,
|
||
coolprop,
|
||
interpolation,
|
||
POSITIONS
|
||
} = require('generalFunctions');
|
||
|
||
class PumpingStation {
|
||
/**
|
||
* PumpingStation — S88 Process Cell.
|
||
*
|
||
* Models a wet-well basin with inflow/outflow and orchestrates child
|
||
* equipment (pumps via rotatingMachine, pump groups via MGC, nested
|
||
* stations) to keep the water level within safe bounds.
|
||
*
|
||
* Full behaviour, threshold semantics, control modes, and the basin
|
||
* diagram are documented in the wiki:
|
||
* wiki/functional-description.md + wiki/modes/*.md
|
||
*
|
||
* Tick loop (1 s): predicted volume → net flow → safety → control.
|
||
*/
|
||
constructor(config = {}) {
|
||
// --- Dependency injection & config merge ---
|
||
this.emitter = new EventEmitter();
|
||
this.configManager = new configManager();
|
||
this.defaultConfig = this.configManager.getConfig('pumpingStation');
|
||
this.configUtils = new configUtils(this.defaultConfig);
|
||
// initConfig deep-merges user config over schema defaults so every
|
||
// field is guaranteed present even if the caller omits it.
|
||
this.config = this.configUtils.initConfig(config);
|
||
this.interpolate = new interpolation();
|
||
this.logger = new logger(this.config.general.logging.enabled, this.config.general.logging.logLevel, this.config.general.name);
|
||
|
||
// --- Measurement store ---
|
||
// autoConvert: incoming values in any unit are stored in their
|
||
// original unit but getCurrentValue(targetUnit) converts on read.
|
||
// preferredUnits: the canonical units used for ALL internal math.
|
||
// Flow and netFlowRate MUST be m3/s because the volume integrator
|
||
// multiplies flow × seconds to get m3. Level in m and volume in m3
|
||
// keep the basin geometry math unit-consistent.
|
||
this.measurements = new MeasurementContainer({
|
||
autoConvert: true,
|
||
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' }
|
||
});
|
||
|
||
// --- Child registries ---
|
||
// Children register via Port 2 handshake. Each dict is keyed by
|
||
// the child's config.general.id.
|
||
// machines : rotatingMachine instances (direct pumps, no MGC)
|
||
// stations : nested pumpingStation instances (cascaded basins)
|
||
// machineGroups : MGC instances (each manages its own pump pool)
|
||
this.childRegistrationUtils = new childRegistrationUtils(this);
|
||
this.machines = {};
|
||
this.stations = {};
|
||
this.machineGroups = {};
|
||
// predictedFlowChildren tracks predicted flow subscriptions per child.
|
||
// Key = childId, value = { in: <last m3/s>, out: <last m3/s> }.
|
||
// Only the highest-level aggregator is subscribed (MGC if present,
|
||
// otherwise individual machines) to avoid double-counting.
|
||
this.predictedFlowChildren = new Map();
|
||
|
||
// --- Variant priority ---
|
||
// Order determines which variant is used for CONTROL decisions:
|
||
// 'measured' is preferred; 'predicted' is the fallback.
|
||
//
|
||
// IMPORTANT — both variants are ALWAYS computed regardless of which
|
||
// one drives control. The output exposes both values plus a flag
|
||
// indicating which variant is currently driving control decisions.
|
||
// This lets operators see the difference between measured and
|
||
// predicted, which is valuable for:
|
||
// - Detecting sensor drift (measured diverges from predicted)
|
||
// - Validating the volume integrator (predicted tracks measured?)
|
||
// - Diagnosing control issues (was the wrong source active?)
|
||
//
|
||
// Implementation: _selectBestNetFlow computes both and stores both
|
||
// in MeasurementContainer; it returns the winning variant as the
|
||
// control source. getOutput() exposes all variants.
|
||
this.flowVariants = ['measured', 'predicted'];
|
||
this.levelVariants = ['measured', 'predicted'];
|
||
this.volVariants = ['measured', 'predicted'];
|
||
// Position aliases — two naming conventions coexist because:
|
||
// - Measurement children (sensors) store their raw
|
||
// positionVsParent from config: 'upstream' / 'downstream'
|
||
// - Predicted-flow children (MGC, machines) map positions to
|
||
// shorthand: 'in' / 'out' (see _registerPredictedFlowChild)
|
||
//
|
||
// The .sum() helper aggregates across an array of position names,
|
||
// so this map gives each logical direction ALL its aliases. This
|
||
// way sum('flow', 'predicted', flowPositions.outflow) catches both
|
||
// a measurement stored under 'downstream' AND a prediction stored
|
||
// under 'out'.
|
||
this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] };
|
||
|
||
// --- Runtime state ---
|
||
this.mode = this.config.control.mode;
|
||
// state is the public snapshot updated at the end of each tick().
|
||
// Consumers (nodeClass, dashboard) read this for display/telemetry.
|
||
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
||
// percControl: the 0-100% demand sent to MGC / direct machines in
|
||
// levelbased mode. Exposed in getOutput() for dashboards.
|
||
this.percControl = 0;
|
||
|
||
// --- Level-armed hysteresis state ---
|
||
// _shiftArmed flips true when level rises past shiftLevel (with
|
||
// enableShiftedRamp). While armed, the demand ramp's lower foot
|
||
// is startLevel instead of inflowLevel — so on the way down the
|
||
// pumps stay aggressive until level falls below startLevel, at
|
||
// which point the arm clears.
|
||
this._shiftArmed = false;
|
||
|
||
// --- Flow dead-band ---
|
||
// flowThreshold (m3/s) prevents control actions on noise.
|
||
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
|
||
// treated as 'steady' (no filling, no draining).
|
||
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||
|
||
// Geometry + threshold ordering check. initBasinProperties seeds
|
||
// predicted volume at minVol; _validateThresholdOrdering warns if
|
||
// any physical/control invariant is violated. Non-fatal — prefer
|
||
// continuity over refusal to start (availability-first).
|
||
this.initBasinProperties();
|
||
this.thresholdIssues = this._validateThresholdOrdering();
|
||
this.logger.debug('PumpingStation initialized');
|
||
}
|
||
|
||
/* --------------------------- Registration --------------------------- */
|
||
|
||
registerChild(child, softwareType) {
|
||
this.logger.debug(`Registering child (${softwareType}) "${child.config.general.name}"`);
|
||
|
||
if (softwareType === 'measurement') {
|
||
this._registerMeasurementChild(child);
|
||
return;
|
||
}
|
||
|
||
if (softwareType === 'machine') {
|
||
this.machines[child.config.general.id] = child;
|
||
} else if (softwareType === 'pumpingstation') {
|
||
this.stations[child.config.general.id] = child;
|
||
} else if (softwareType === 'machinegroup') {
|
||
this.machineGroups[child.config.general.id] = child;
|
||
}
|
||
|
||
// Register predicted-flow subscription. Only register the HIGHEST-
|
||
// level aggregator: if a machinegroup is present, subscribe to IT
|
||
// (its flow.predicted already aggregates all child machines). Do NOT
|
||
// also subscribe to individual machines — that would double-count
|
||
// because each pump's flow is included in the group total.
|
||
//
|
||
// Individual machines (softwareType='machine') are only subscribed
|
||
// when there is NO machinegroup parent — i.e., pumps wired directly
|
||
// to the pumping station without an MGC in between.
|
||
if (softwareType === 'machinegroup' || softwareType === 'pumpingstation') {
|
||
this._registerPredictedFlowChild(child);
|
||
} else if (softwareType === 'machine' && Object.keys(this.machineGroups).length === 0) {
|
||
// Direct-child machine, no group above it — register its flow.
|
||
this._registerPredictedFlowChild(child);
|
||
}
|
||
}
|
||
|
||
_registerMeasurementChild(child) {
|
||
const position = child.config.functionality.positionVsParent;
|
||
const measurementType = child.config.asset.type;
|
||
const eventName = `${measurementType}.measured.${position}`;
|
||
|
||
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
||
this.logger.debug(
|
||
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
||
);
|
||
|
||
this.measurements
|
||
.type(measurementType)
|
||
.variant('measured')
|
||
.position(position)
|
||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||
|
||
this._handleMeasurement(measurementType, eventData.value, position, eventData);
|
||
});
|
||
}
|
||
|
||
_registerPredictedFlowChild(child) {
|
||
const position = (child.config.functionality.positionVsParent || '').toLowerCase();
|
||
const childName = child.config.general.name;
|
||
const childId = child.config.general.id ?? childName;
|
||
|
||
let posKey;
|
||
let eventName;
|
||
switch (position) {
|
||
case 'downstream':
|
||
case 'out':
|
||
case 'atequipment':
|
||
posKey = 'out';
|
||
// Subscribe to ONE event only. 'downstream' is the most specific
|
||
// — avoids double-counting from 'atequipment' which carries the
|
||
// same total flow on a different event name.
|
||
eventName = 'flow.predicted.downstream';
|
||
break;
|
||
case 'upstream':
|
||
case 'in':
|
||
posKey = 'in';
|
||
eventName = 'flow.predicted.upstream';
|
||
break;
|
||
default:
|
||
this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`);
|
||
return;
|
||
}
|
||
|
||
if (!this.predictedFlowChildren.has(childId)) {
|
||
this.predictedFlowChildren.set(childId, { in: 0, out: 0 });
|
||
}
|
||
|
||
const handler = (eventData = {}) => {
|
||
const unit = eventData.unit || child.config?.general?.unit;
|
||
const ts = eventData.timestamp || Date.now();
|
||
|
||
this.logger.debug(`Emitting for child ${unit} `);
|
||
this.measurements
|
||
.type('flow')
|
||
.variant('predicted')
|
||
.position(posKey)
|
||
.child(childId)
|
||
.value(eventData.value, ts, unit);
|
||
};
|
||
|
||
child.measurements.emitter.on(eventName, handler);
|
||
}
|
||
|
||
/* --------------------------- Calibration --------------------------- */
|
||
|
||
calibratePredictedVolume(calibratedVol, timestamp = Date.now()) {
|
||
const volume = this.measurements.type('volume').variant('predicted').position('atequipment').get();
|
||
const level = this.measurements.type('level').variant('predicted').position('atequipment').get();
|
||
|
||
if (volume) {
|
||
volume.values = [];
|
||
volume.timestamps = [];
|
||
}
|
||
|
||
if (level) {
|
||
level.values = [];
|
||
level.timestamps = [];
|
||
}
|
||
|
||
this.measurements.type('volume').variant('predicted').position('atequipment').value(calibratedVol, timestamp, 'm3').unit('m3');
|
||
this.measurements.type('level').variant('predicted').position('atequipment').value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm');
|
||
|
||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||
}
|
||
|
||
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
|
||
// Rebuild the chain each time — MeasurementContainer is stateful
|
||
// (its type/variant/position methods mutate the container itself,
|
||
// so cached chain references share one cursor).
|
||
const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||
if (volMeas.exists()) {
|
||
const m = volMeas.get();
|
||
m.values = []; m.timestamps = [];
|
||
}
|
||
const lvlMeas = this.measurements.type('level').variant('predicted').position('atequipment');
|
||
if (lvlMeas.exists()) {
|
||
const m = lvlMeas.get();
|
||
m.values = []; m.timestamps = [];
|
||
}
|
||
|
||
this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit);
|
||
this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3');
|
||
|
||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||
}
|
||
|
||
setManualInflow(value, timestamp = Date.now(), unit) {
|
||
const num = Number(value);
|
||
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
|
||
}
|
||
|
||
setManualOutflow(value, timestamp = Date.now(), unit) {
|
||
const num = Number(value);
|
||
this.measurements.type('flow').variant('predicted').position('out').child('manual-qout').value(num, timestamp, unit);
|
||
}
|
||
|
||
/* --------------------------- Tick / Control --------------------------- */
|
||
|
||
tick() {
|
||
this._updatePredictedVolume();
|
||
|
||
const netFlow = this._selectBestNetFlow();
|
||
const remaining = this._computeRemainingTime(netFlow);
|
||
|
||
this._safetyController(remaining.seconds, netFlow.direction);
|
||
if (this.safetyControllerActive) return;
|
||
|
||
this._controlLogic(netFlow.direction);
|
||
|
||
this.state = {
|
||
direction: netFlow.direction,
|
||
netFlow: netFlow.value,
|
||
flowSource: netFlow.source,
|
||
seconds: remaining.seconds,
|
||
remainingSource: remaining.source
|
||
};
|
||
|
||
this.logger.debug(`netflow = ${JSON.stringify(netFlow)}`);
|
||
this.logger.debug(
|
||
`Height : ${this.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m')} m`
|
||
);
|
||
}
|
||
|
||
changeMode(newMode){
|
||
if ( this.config.control.allowedModes.has(newMode) ){
|
||
const currentMode = this.mode;
|
||
this.logger.info(`Control mode changing from ${currentMode} to ${newMode}`);
|
||
this.mode = newMode;
|
||
}
|
||
else{
|
||
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
|
||
}
|
||
|
||
}
|
||
|
||
|
||
_controlLogic(direction) {
|
||
switch (this.mode) {
|
||
case 'levelbased':
|
||
this._controlLevelBased(direction);
|
||
break;
|
||
case 'flowbased':
|
||
this._controlFlowBased?.();
|
||
break;
|
||
case 'manual':
|
||
break;
|
||
default:
|
||
this.logger.warn(`Unsupported control mode: ${this.mode}`);
|
||
}
|
||
}
|
||
|
||
async _controlLevelBased(_direction) {
|
||
const { startLevel, minLevel } = this.config.control.levelbased;
|
||
const levelUnit = this.measurements.getUnit('level');
|
||
|
||
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
|
||
if (level == null) {
|
||
this.logger.warn('No valid level found');
|
||
return;
|
||
}
|
||
|
||
// Level-based pump control via MGC (see wiki/modes/levelbased.md).
|
||
//
|
||
// level < minLevel → STOP (unconditional MGC shutdown)
|
||
// level < startLevel → 0 % (pumps held off)
|
||
// level in [startLevel..rampStart] → 0 % (HOLD zone)
|
||
// level in [rampStart..maxLevel] → 0..100 % (linear or log curve)
|
||
// level > maxLevel → ≥100 % (MGC clamps internally)
|
||
//
|
||
// With enableShiftedRamp:
|
||
// rampStart = inflowLevel by default
|
||
// when level rises past shiftLevel → arm → rampStart = startLevel
|
||
// when level drops below startLevel → disarm → rampStart = inflowLevel
|
||
// Without enableShiftedRamp: rampStart = inflowLevel always.
|
||
|
||
if (level < minLevel) {
|
||
this.percControl = 0;
|
||
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||
return;
|
||
}
|
||
|
||
this._updateShiftArmed(level);
|
||
const rampStartLevel = this._levelBasedRampStart();
|
||
const rampTopLevel = this._levelBasedRampTop();
|
||
|
||
// HOLD/MINIMUM DEMAND — below the active ramp start, command 0 %
|
||
// without latching dry-run. Dry-run remains the safety layer's job.
|
||
if (level < rampStartLevel) {
|
||
this.percControl = 0;
|
||
await this._applyMachineGroupLevelControl(0);
|
||
return;
|
||
}
|
||
|
||
// RUN — above rampStartLevel, compute demand and forward to MGC.
|
||
// _scaleLevelToFlowPercent maps [rampStartLevel..rampTopLevel] → [0..100].
|
||
// Above rampTopLevel demand saturates at 100 %.
|
||
const rawPercControl = this._scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel);
|
||
const percControl = Math.max(0, rawPercControl);
|
||
this.percControl = percControl;
|
||
this.logger.debug(`Level-based control: level=${level} armed=${this._shiftArmed} foot=${rampStartLevel} top=${rampTopLevel} percControl=${percControl}`);
|
||
|
||
await this._applyMachineGroupLevelControl(percControl);
|
||
}
|
||
|
||
_controlFlowBased() {
|
||
// placeholder for flow-based logic
|
||
}
|
||
|
||
/**
|
||
* Forward a manual demand value to all child machine groups + direct
|
||
* machines. Called from the 'Qd' topic handler when PS is in manual
|
||
* mode — mirrors how rotatingMachine gates commands by mode.
|
||
* @param {number} demand - the operator-set demand (interpretation
|
||
* depends on MGC scaling: 'absolute' = m³/h, 'normalized' = 0-100%)
|
||
*/
|
||
async forwardDemandToChildren(demand) {
|
||
this.logger.info(`Manual demand forwarded: ${demand}`);
|
||
// Forward to machine groups (MGC)
|
||
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
|
||
await Promise.all(
|
||
Object.values(this.machineGroups).map((group) =>
|
||
group.handleInput('parent', demand).catch((err) => {
|
||
this.logger.error(`Failed to forward demand to group: ${err.message}`);
|
||
})
|
||
)
|
||
);
|
||
}
|
||
// Forward to direct machines (if any)
|
||
if (this.machines && Object.keys(this.machines).length > 0) {
|
||
const perMachine = demand / Object.keys(this.machines).length;
|
||
for (const machine of Object.values(this.machines)) {
|
||
try {
|
||
await machine.handleInput('parent', 'execMovement', perMachine);
|
||
} catch (err) {
|
||
this.logger.error(`Failed to forward demand to machine: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async _applyMachineGroupLevelControl(percentControl) {
|
||
if (!this.machineGroups || Object.keys(this.machineGroups).length === 0) return;
|
||
await Promise.all(
|
||
Object.values(this.machineGroups).map((group) =>
|
||
group.handleInput('parent', percentControl).catch((err) => {
|
||
this.logger.error(`Failed to send level control to group "${group.config.general.name}": ${err.message}`);
|
||
})
|
||
)
|
||
);
|
||
}
|
||
|
||
async _applyMachineLevelControl(percentControl) {
|
||
const machines = Object.values(this.machines).filter((machine) => {
|
||
const pos = machine?.config?.functionality?.positionVsParent;
|
||
return (pos === 'downstream' || pos === 'atequipment');
|
||
});
|
||
|
||
if (!machines.length) return;
|
||
|
||
const perMachine = percentControl / machines.length;
|
||
for (const machine of machines) {
|
||
try {
|
||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||
await machine.handleInput('parent', 'execMovement', perMachine);
|
||
} catch (err) {
|
||
this.logger.error(`Failed to start machine "${machine.config.general.name}": ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* --------------------------- Measurements --------------------------- */
|
||
|
||
_handleMeasurement(measurementType, value, position, context) {
|
||
switch (measurementType) {
|
||
case 'level':
|
||
this._onLevelMeasurement(position, value, context);
|
||
break;
|
||
case 'pressure':
|
||
this._onPressureMeasurement(position, value, context);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
_onLevelMeasurement(position, value, context = {}) {
|
||
this.measurements.type('level').variant('measured').position(position).value(value).unit(context.unit);
|
||
const levelSeries = this.measurements.type('level').variant('measured').position(position);
|
||
const levelMeters = levelSeries.getCurrentValue('m');
|
||
if (levelMeters == null) return;
|
||
|
||
const volume = this._calcVolumeFromLevel(levelMeters);
|
||
const percent = this.interpolate.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 kelvinTemp =
|
||
this.measurements.type('temperature').variant('measured').position('atequipment').getCurrentValue('K') ?? null;
|
||
|
||
if (kelvinTemp === null) {
|
||
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
|
||
this.measurements.type('temperature').variant('assumed').position('atequipment').value(15, Date.now(), 'C');
|
||
kelvinTemp = this.measurements.type('temperature').variant('assumed').position('atequipment').getCurrentValue('K');
|
||
}
|
||
|
||
if (kelvinTemp == null) return;
|
||
|
||
const density = coolprop.PropsSI('D', 'T', kelvinTemp, 'P', 101325, 'Water');
|
||
const pressurePa = this.measurements.type('pressure').variant('measured').position(position).getCurrentValue('Pa');
|
||
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
|
||
|
||
const g = 9.80665;
|
||
const level = pressurePa / (density * g);
|
||
this.measurements.type('level').variant('predicted').position(position).value(level, context.timestamp, 'm');
|
||
}
|
||
|
||
/* --------------------------- Core Calculations --------------------------- */
|
||
|
||
_pickVariant(type, variants, position, unit) {
|
||
for (const variant of variants) {
|
||
const val = this.measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||
if (!Number.isFinite(val)) continue;
|
||
return val;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_levelBasedRampStart() {
|
||
const { startLevel, enableShiftedRamp } = this.config.control.levelbased;
|
||
const inflowLevel = this.basin?.inflowLevel;
|
||
if (enableShiftedRamp && this._shiftArmed) return startLevel;
|
||
if (Number.isFinite(inflowLevel)) return inflowLevel;
|
||
return startLevel;
|
||
}
|
||
|
||
_levelBasedRampTop() {
|
||
// Returns the upper level at which demand saturates at 100 %.
|
||
// While the shift is armed, top moves left from maxLevel to shiftLevel
|
||
// so output reaches 100 % earlier and stays at 100 % until level
|
||
// falls back through shiftLevel on the way down.
|
||
const { maxLevel, enableShiftedRamp, shiftLevel } = this.config.control.levelbased;
|
||
if (enableShiftedRamp && this._shiftArmed
|
||
&& Number.isFinite(shiftLevel) && shiftLevel > 0
|
||
&& shiftLevel <= maxLevel) {
|
||
return shiftLevel;
|
||
}
|
||
return maxLevel;
|
||
}
|
||
|
||
_updateShiftArmed(level) {
|
||
const { enableShiftedRamp, shiftLevel, startLevel } = this.config.control.levelbased;
|
||
if (!enableShiftedRamp) {
|
||
this._shiftArmed = false;
|
||
return;
|
||
}
|
||
const trigger = Number.isFinite(shiftLevel) && shiftLevel > 0 ? shiftLevel : null;
|
||
if (!this._shiftArmed && trigger != null && level >= trigger) {
|
||
this._shiftArmed = true;
|
||
this.logger.debug(`Shift armed at level=${level} (shiftLevel=${trigger})`);
|
||
} else if (this._shiftArmed && Number.isFinite(startLevel) && level < startLevel) {
|
||
this._shiftArmed = false;
|
||
this.logger.debug(`Shift disarmed at level=${level} (startLevel=${startLevel})`);
|
||
}
|
||
}
|
||
|
||
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
|
||
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
|
||
const start = Number.isFinite(rampStartLevel) ? rampStartLevel : this.config.control.levelbased.startLevel;
|
||
const top = Number.isFinite(rampTopLevel) ? rampTopLevel : maxLevel;
|
||
if (!Number.isFinite(level) || !Number.isFinite(start) || !Number.isFinite(top)) return 0;
|
||
if (top <= start) return level >= top ? 100 : 0;
|
||
|
||
const x = Math.max(0, Math.min(1, (level - start) / (top - start)));
|
||
if (curveType === 'log') {
|
||
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
|
||
? Number(logCurveFactor)
|
||
: 9;
|
||
return 100 * (Math.log1p(factor * x) / Math.log1p(factor));
|
||
}
|
||
|
||
return x * 100;
|
||
}
|
||
|
||
_levelRate(variant) {
|
||
const chain = this.measurements.type('level').variant(variant).position('atequipment');
|
||
if (!chain.exists({ requireValues: true })) return null;
|
||
const m = chain.get();
|
||
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;
|
||
}
|
||
|
||
_updatePredictedVolume() {
|
||
const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below
|
||
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 timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||
const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0);
|
||
const netVolumeChange = deltaSeconds > 0 ? (inflow - outflow) * deltaSeconds : 0;
|
||
|
||
const volumeSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||
const currentVolume = volumeSeries.getCurrentValue('m3');
|
||
|
||
const nextVolume = currentVolume + netVolumeChange;
|
||
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
|
||
|
||
volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); //olifant
|
||
|
||
const nextLevel = this._calcLevelFromVolume(nextVolume);
|
||
this.measurements
|
||
.type('level')
|
||
.variant('predicted')
|
||
.position('atequipment')
|
||
.value(nextLevel, writeTimestamp, 'm')
|
||
.unit('m');
|
||
|
||
const percent = this.interpolate.interpolate_lin_single_point(
|
||
nextVolume,
|
||
this.basin.minVol,
|
||
this.basin.maxVolAtOverflow,
|
||
0,
|
||
100
|
||
);
|
||
|
||
this.measurements
|
||
.type('volumePercent')
|
||
.variant('predicted')
|
||
.position('atequipment')
|
||
.value(percent, writeTimestamp, '%');
|
||
|
||
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTimestamp };
|
||
}
|
||
|
||
_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);
|
||
return { value: net, source: variant, direction: this._deriveDirection(net) };
|
||
}
|
||
|
||
// Fallback: level trend
|
||
for (const variant of this.levelVariants) {
|
||
const rate = this._levelRate(variant);
|
||
if (!Number.isFinite(rate)) continue;
|
||
const netFlow = rate * this.basin.surfaceArea;
|
||
return { value: netFlow, source: `level:${variant}`, direction: this._deriveDirection(netFlow) };
|
||
}
|
||
|
||
this.logger.warn('No usable measurements to compute net flow; assuming steady.');
|
||
return { value: 0, source: null, direction: 'steady' };
|
||
}
|
||
|
||
_computeRemainingTime(netFlow) {
|
||
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) return { seconds: null, source: null };
|
||
|
||
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
|
||
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) return { seconds: null, source: null };
|
||
|
||
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;
|
||
|
||
return { seconds, source: `${netFlow.source}/${variant}` };
|
||
}
|
||
|
||
return { seconds: null, source: netFlow.source };
|
||
}
|
||
|
||
_deriveDirection(netFlow) {
|
||
if (netFlow > this.flowThreshold) return 'filling';
|
||
if (netFlow < -this.flowThreshold) return 'draining';
|
||
return 'steady';
|
||
}
|
||
|
||
/* --------------------------- Safety --------------------------- */
|
||
|
||
/**
|
||
* Safety controller — two hard rules:
|
||
*
|
||
* 1. BELOW minLevel (dry-run): pumps CANNOT start.
|
||
* Shuts down all downstream machines + machine groups.
|
||
* Only a manual override or emergency can restart them.
|
||
* safetyControllerActive = true → blocks _controlLogic.
|
||
*
|
||
* 2. ABOVE high-volume safety level: pumps CANNOT stop.
|
||
* Shuts down UPSTREAM equipment only (stop more water coming in).
|
||
* Does NOT shut down downstream pumps or machine groups — they
|
||
* must keep draining. Does NOT set safetyControllerActive — the
|
||
* level-based control keeps running so pumps stay at the demand
|
||
* dictated by the current level (which will be >100% near overflow,
|
||
* meaning all pumps at maximum via the normal demand curve).
|
||
* Only a manual override or emergency stop can shut pumps during
|
||
* a high-volume or overflowing event.
|
||
*/
|
||
_safetyController(remainingTime, direction) {
|
||
this.safetyControllerActive = false;
|
||
|
||
const volUnit = this.measurements.getUnit('volume');
|
||
const vol = this._pickVariant('volume', this.volVariants, 'atequipment', volUnit);
|
||
if (vol == null) {
|
||
Object.values(this.machines).forEach((machine) => machine.handleInput('parent', 'execSequence', 'shutdown'));
|
||
this.logger.warn('No volume data available to safe guard system; shutting down all machines.');
|
||
this.safetyControllerActive = true;
|
||
return;
|
||
}
|
||
|
||
const {
|
||
enableDryRunProtection,
|
||
dryRunThresholdPercent,
|
||
enableOverfillProtection,
|
||
enableHighVolumeSafety,
|
||
timeleftToFullOrEmptyThresholdSeconds
|
||
} = this.config.safety || {};
|
||
|
||
const dryRunEnabled = Boolean(enableDryRunProtection);
|
||
const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
|
||
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
|
||
const safety = this._computeSafetyPoints();
|
||
const triggerHighVol = safety.highVolumeSafetyVol;
|
||
const triggerLowVol = safety.dryRunSafetyVol;
|
||
const currentLevel = this._pickVariant('level', this.levelVariants, 'atequipment', 'm');
|
||
|
||
this.safetyState = {
|
||
dryRunActive: false,
|
||
highVolumeActive: false,
|
||
isOverflowing: Number.isFinite(currentLevel) && currentLevel >= this.basin.overflowLevel,
|
||
dryRunLevel: safety.dryRunLevel,
|
||
highVolumeSafetyLevel: safety.highVolumeSafetyLevel,
|
||
dryRunSafetyVol: safety.dryRunSafetyVol,
|
||
highVolumeSafetyVol: safety.highVolumeSafetyVol
|
||
};
|
||
|
||
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
|
||
if (direction === 'draining') {
|
||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
|
||
if (timeTriggered || dryRunTriggered) {
|
||
this.safetyState.dryRunActive = true;
|
||
// Shut down all downstream equipment — pumps must stop.
|
||
Object.values(this.machines).forEach((machine) => {
|
||
const pos = machine?.config?.functionality?.positionVsParent;
|
||
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
|
||
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||
}
|
||
});
|
||
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
|
||
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||
this.logger.warn(
|
||
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
|
||
);
|
||
// Block _controlLogic so level-based control can't restart pumps.
|
||
this.safetyControllerActive = true;
|
||
}
|
||
}
|
||
|
||
// Rule 2: OVERFILL — above overflow level, pumps cannot stop.
|
||
// Only shut down UPSTREAM equipment. Downstream pumps + machine
|
||
// groups keep running at whatever the level control demands
|
||
// (which will be >100% near overflow = all pumps at max).
|
||
// Do NOT set safetyControllerActive — _controlLogic must keep
|
||
// running to maintain pump demand.
|
||
if (direction === 'filling') {
|
||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||
const highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
|
||
if (timeTriggered || highVolumeTriggered) {
|
||
this.safetyState.highVolumeActive = true;
|
||
// Shut down UPSTREAM only — stop more water coming in.
|
||
Object.values(this.machines).forEach((machine) => {
|
||
const pos = machine?.config?.functionality?.positionVsParent;
|
||
if (pos === 'upstream' && machine._isOperationalState()) {
|
||
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||
}
|
||
});
|
||
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
|
||
// NOTE: machine groups (downstream pumps) are NOT shut down.
|
||
// They must keep draining to prevent overflow from worsening.
|
||
this.logger.warn(
|
||
`High-volume safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
|
||
);
|
||
// NOTE: safetyControllerActive is NOT set — level control
|
||
// keeps commanding pumps at maximum demand.
|
||
}
|
||
}
|
||
}
|
||
|
||
_computeSafetyPoints() {
|
||
const safety = this.config.safety || {};
|
||
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||
const highPct = Number(
|
||
safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent ?? 98
|
||
) || 0;
|
||
const dryRunSafetyVol = this.basin.minVol * (1 + (dryRunPct / 100));
|
||
const dryRunLevel = this._calcLevelFromVolume(dryRunSafetyVol);
|
||
const highVolumeSafetyVol = this.basin.maxVolAtOverflow * (highPct / 100);
|
||
const highVolumeSafetyLevel = this._calcLevelFromVolume(highVolumeSafetyVol);
|
||
|
||
return {
|
||
dryRunSafetyVol,
|
||
dryRunLevel,
|
||
highVolumeSafetyVol,
|
||
highVolumeSafetyLevel
|
||
};
|
||
}
|
||
|
||
/* --------------------------- Basin --------------------------- */
|
||
|
||
/**
|
||
* Compute basin geometry from config and seed the initial predicted
|
||
* volume at the operational floor.
|
||
*
|
||
* Basin is modelled as a rectangular prism (constant cross-section),
|
||
* so `volume = level × surfaceArea`. See the wiki's basin-model
|
||
* diagram for the full threshold layout and naming conventions:
|
||
* wiki/functional-description.md#basin-model
|
||
*
|
||
* `minHeightBasedOn` ('inlet' | 'outlet') selects which pipe height
|
||
* defines `minVol` — the 0 % point of fill-percent and the default
|
||
* dry-run reference.
|
||
*/
|
||
initBasinProperties() {
|
||
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
|
||
const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity
|
||
const heightBasin = this.config.basin.height; // m — floor to rim
|
||
const inflowLevel = this.config.basin.inflowLevel; // m — inlet pipe bottom/invert
|
||
const outflowLevel = this.config.basin.outflowLevel; // m — outlet/pump suction pipe top
|
||
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest
|
||
const inletPipeDiameter = this.config.basin.inletPipeDiameter;
|
||
const outletPipeDiameter = this.config.basin.outletPipeDiameter;
|
||
|
||
// Constant cross-section assumption: volume = level × area
|
||
const surfaceArea = volEmptyBasin / heightBasin;
|
||
|
||
// Volume at each critical height
|
||
const maxVol = heightBasin * surfaceArea; // ≡ volEmptyBasin (see note above)
|
||
const maxVolAtOverflow = overflowLevel * surfaceArea; // spill threshold
|
||
const minVolAtOutflow = outflowLevel * surfaceArea; // dry-run threshold
|
||
const minVolAtInflow = inflowLevel * surfaceArea; // gravity-feed threshold
|
||
|
||
// Operational floor: which pipe defines "basin too low"
|
||
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
|
||
|
||
this.basin = {
|
||
volEmptyBasin,
|
||
heightBasin,
|
||
inflowLevel,
|
||
outflowLevel,
|
||
overflowLevel,
|
||
inletPipeDiameter,
|
||
outletPipeDiameter,
|
||
surfaceArea,
|
||
maxVol,
|
||
maxVolAtOverflow,
|
||
minVolAtInflow,
|
||
minVolAtOutflow,
|
||
minVol,
|
||
minHeightBasedOn
|
||
};
|
||
|
||
// Seed predicted volume at operational floor — the station assumes
|
||
// the basin is at minimum until calibrated by a real measurement.
|
||
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
|
||
}
|
||
|
||
/**
|
||
* Validate basin + control threshold ordering.
|
||
*
|
||
* Every pair is a strict physical or control invariant. Violations
|
||
* don't throw — they log a warning and return the list so callers
|
||
* (tests, node-status, the eval harness) can surface them. Returning
|
||
* [] means "all invariants hold".
|
||
*
|
||
* Strict invariants (bottom → top):
|
||
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||
* dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
|
||
*
|
||
* dryRunLevel and highVolumeSafetyLevel are DERIVED — computed
|
||
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
|
||
* highVolumeSafetyThresholdPercent/100 in the safety layer. Validating those
|
||
* catches config that would let minLevel sit below where safety has
|
||
* already force-stopped the pumps (no-op control band).
|
||
*/
|
||
_validateThresholdOrdering() {
|
||
const basin = this.basin;
|
||
const lvl = this.config.control?.levelbased || {};
|
||
|
||
const safetyPoints = this._computeSafetyPoints();
|
||
const dryRunLevel = safetyPoints.dryRunLevel;
|
||
const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
|
||
|
||
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],
|
||
['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
|
||
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
|
||
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||
];
|
||
|
||
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) {
|
||
const msg = `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`;
|
||
issues.push({ aName, a, op, bName, b, msg });
|
||
this.logger.warn(msg);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
|
||
_calcVolumeFromLevel(level) {
|
||
return Math.max(level, 0) * this.basin.surfaceArea;
|
||
}
|
||
|
||
/** Convert volume (m3) → level (m from floor). Clamps to 0. */
|
||
_calcLevelFromVolume(volume) {
|
||
return Math.max(volume, 0) / this.basin.surfaceArea;
|
||
}
|
||
|
||
/* --------------------------- Output --------------------------- */
|
||
|
||
getOutput() {
|
||
const output = this.measurements.getFlattenedOutput();
|
||
const safety = this._computeSafetyPoints();
|
||
output.direction = this.state.direction;
|
||
output.flowSource = this.state.flowSource;
|
||
output.timeleft = this.state.seconds;
|
||
output.volEmptyBasin = this.basin.volEmptyBasin;
|
||
output.inflowLevel = this.basin.inflowLevel;
|
||
output.outflowLevel = this.basin.outflowLevel;
|
||
output.overflowLevel = this.basin.overflowLevel;
|
||
output.inletPipeDiameter = this.basin.inletPipeDiameter;
|
||
output.outletPipeDiameter = this.basin.outletPipeDiameter;
|
||
output.maxVol = this.basin.maxVol;
|
||
output.minVol = this.basin.minVol;
|
||
output.maxVolAtOverflow = this.basin.maxVolAtOverflow;
|
||
output.minVolAtOutflow = this.basin.minVolAtOutflow;
|
||
output.minVolAtInflow = this.basin.minVolAtInflow;
|
||
output.minHeightBasedOn = this.basin.minHeightBasedOn;
|
||
output.dryRunLevel = safety.dryRunLevel;
|
||
output.dryRunSafetyVol = safety.dryRunSafetyVol;
|
||
output.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
|
||
output.highVolumeSafetyVol = safety.highVolumeSafetyVol;
|
||
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
|
||
output.safetyState = this._deriveSafetyState();
|
||
output.percControl = this.percControl;
|
||
return output;
|
||
}
|
||
|
||
_deriveSafetyState() {
|
||
if (this.safetyState?.isOverflowing) return 'overflowing';
|
||
if (this.safetyState?.highVolumeActive) return 'highVolume';
|
||
if (this.safetyState?.dryRunActive) return 'dryRun';
|
||
return 'normal';
|
||
}
|
||
}
|
||
|
||
module.exports = PumpingStation;
|
||
/* ------------------------------------------------------------------------- */
|
||
/* 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,
|
||
inletPipeDiameter: 0.4,
|
||
outletPipeDiameter: 0.3
|
||
},
|
||
hydraulics: {
|
||
refHeight: 'NAP',
|
||
basinBottomRef: 0,
|
||
minHeightBasedOn: 'outlet'
|
||
},
|
||
safety: {
|
||
enableDryRunProtection:false,
|
||
enableHighVolumeSafety:false,
|
||
highVolumeSafetyThresholdPercent: 98
|
||
}
|
||
};
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|
||
//*/
|