Files
pumpingStation/src/nodeClass.js
znetsixe f869296832 feat: level-based control now reaches machine groups + manual Qd forwarding
Two additions to pumpingStation:

1. _controlLevelBased now calls _applyMachineGroupLevelControl in
   addition to _applyMachineLevelControl when the basin is filling
   above startLevel. Previously only direct-child machines received
   the level-based percent-control signal; in a hierarchical topology
   (PS → MGC → pumps) the machines sit under MGC and PS.machines is
   empty, so the level control never reached them.

2. New 'Qd' input topic + forwardDemandToChildren() method. When PS
   is in 'manual' mode (matching the pattern from rotatingMachine's
   virtualControl), operator demand from a dashboard slider is forwarded
   to all child machine groups and direct machines. When PS is in any
   other mode (levelbased, flowbased, etc.), the Qd msg is silently
   dropped with a debug log so the automatic control isn't overridden.

No breaking changes — existing flows that don't send 'Qd' are unaffected,
and _controlLevelBased's additional call to machineGroupLevelControl
is a no-op when no machine groups are registered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:27:11 +02:00

263 lines
8.5 KiB
JavaScript

const { outputUtils, configManager } = require('generalFunctions');
const Specific = require("./specificClass");
class nodeClass {
/**
* Create a node.
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
* @param {object} nodeInstance - The Node-RED node instance.
* @param {string} nameOfNode - The name of the node, used for
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
// Load default & UI config
this._loadConfig(uiConfig,this.node);
// Instantiate core class
this._setupSpecificClass();
// Wire up event and lifecycle handlers
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* Load and merge default config with user-defined settings.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig,node) {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
// Build config: base sections + pumpingStation-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
basin: {
volume: uiConfig.basinVolume,
height: uiConfig.basinHeight,
heightInlet: uiConfig.heightInlet,
heightOutlet: uiConfig.heightOutlet,
heightOverflow: uiConfig.heightOverflow,
},
hydraulics: {
refHeight: uiConfig.refHeight,
minHeightBasedOn: uiConfig.minHeightBasedOn,
basinBottomRef: uiConfig.basinBottomRef,
},
control:{
mode: uiConfig.controlMode,
levelbased:{
startLevel:uiConfig.startLevel,
stopLevel:uiConfig.stopLevel,
minFlowLevel:uiConfig.minFlowLevel,
maxFlowLevel:uiConfig.maxFlowLevel
}
},
safety:{
enableDryRunProtection: uiConfig.enableDryRunProtection,
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
enableOverfillProtection: uiConfig.enableOverfillProtection,
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
}
});
// Utility for formatting outputs
this._output = new outputUtils();
}
/**
* Instantiate the core logic and store as source.
*/
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source; // Store the source in the node instance for easy access
}
/**
* Bind Node-RED status updates.
*/
_bindEvents() {
}
// init registration msg
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{ topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null},
]);
}, 100);
}
_updateNodeStatus() {
const ps = this.source;
const pickVariant = (type, prefer = ['measured', 'predicted'], position = 'atEquipment', unit) => {
for (const variant of prefer) {
const chain = ps.measurements.type(type).variant(variant).position(position);
const value = unit ? chain.getCurrentValue(unit) : chain.getCurrentValue();
if (value != null) return { value, variant };
}
return { value: null, variant: null };
};
const vol = pickVariant('volume', ['measured', 'predicted'], 'atEquipment', 'm3');
const volPercent = pickVariant('volumePercent', ['measured','predicted'], 'atEquipment'); // already unitless
const level = pickVariant('level', ['measured', 'predicted'], 'atEquipment', 'm');
const netFlow = pickVariant('netFlowRate', ['measured', 'predicted'], 'atEquipment', 'm3/h');
const maxVolBeforeOverflow = ps.basin?.maxVolOverflow ?? ps.basin?.maxVol ?? 0;
const currentVolume = vol.value ?? 0;
const currentvolPercent = volPercent.value ?? 0;
const netFlowM3h = netFlow.value ?? 0;
const direction = ps.state?.direction ?? 'unknown';
const secondsRemaining = ps.state?.seconds ?? null;
const timeRemainingMinutes = secondsRemaining != null ? Math.round(secondsRemaining / 60) : null;
const badgePieces = [];
badgePieces.push(`${currentvolPercent.toFixed(1)}% `);
badgePieces.push(
`V=${currentVolume.toFixed(2)} / ${maxVolBeforeOverflow.toFixed(2)}`
);
badgePieces.push(`net: ${netFlowM3h.toFixed(0)} m³/h`);
if (timeRemainingMinutes != null) {
badgePieces.push(`t≈${timeRemainingMinutes} min)`);
}
const { symbol, fill } = (() => {
switch (direction) {
case 'filling': return { symbol: '⬆️', fill: 'blue' };
case 'draining': return { symbol: '⬇️', fill: 'orange' };
case 'steady': return { symbol: '⏸️', fill: 'green' };
default: return { symbol: '❔', fill: 'grey' };
}
})();
badgePieces[0] = `${symbol} ${badgePieces[0]}`;
return {
fill,
shape: 'dot',
text: badgePieces.join(' | ')
};
}
// any time based functions here
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
this._statusInterval = setInterval(() => {
const status = this._updateNodeStatus();
this.node.status(status);
}, 1000);
}, 1000);
}
/**
* Execute a single tick: update measurement, format and send outputs.
*/
_tick() {
//pumping station needs time based ticks to recalc level when predicted
this.source.tick();
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
// Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
//example
case 'changemode':
this.source.changeMode(msg.payload);
break;
case 'registerChild': {
// Register this node as a child of the parent node
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
this.source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
break;
}
case 'calibratePredictedVolume': {
const injectedVol = parseFloat(msg.payload);
this.source.calibratePredictedVolume(injectedVol);
break;
}
case 'calibratePredictedLevel': {
const injectedLevel = parseFloat(msg.payload);
this.source.calibratePredictedLevel(injectedLevel);
break;
}
case 'q_in': {
// payload can be number or { value, unit, timestamp }
const val = Number(msg.payload);
const unit = msg?.unit;
const ts = msg?.timestamp || Date.now();
this.source.setManualInflow(val, ts, unit);
break;
}
case 'Qd': {
// Manual demand: operator sets the target output via a
// dashboard slider. Only accepted when PS is in 'manual'
// mode — mirrors how rotatingMachine gates commands by
// mode (virtualControl vs auto).
const demand = Number(msg.payload);
if (!Number.isFinite(demand)) {
this.source.logger.warn(`Invalid Qd value: ${msg.payload}`);
break;
}
if (this.source.mode === 'manual') {
this.source.forwardDemandToChildren(demand).catch((err) =>
this.source.logger.error(`Failed to forward demand: ${err.message}`)
);
} else {
this.source.logger.debug(
`Qd ignored in ${this.source.mode} mode. Switch to manual to use the demand slider.`
);
}
break;
}
}
done();
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
done();
});
}
}
module.exports = nodeClass;