updating to corrospend with reality

This commit is contained in:
znetsixe
2025-11-27 17:46:24 +01:00
parent d91609b3a4
commit 288bd244dd
3 changed files with 252 additions and 64 deletions

View File

@@ -53,7 +53,16 @@
hasDistance: { value: false }, hasDistance: { value: false },
distance: { value: 0 }, distance: { value: 0 },
distanceUnit: { value: "m" }, distanceUnit: { value: "m" },
distanceDescription: { value: "" } distanceDescription: { value: "" },
// control strategy
controlMode: { value: "none" },
startLevel: { value: null },
stopLevel: { value: null },
minFlowLevel: { value: null },
maxFlowLevel: { value: null },
flowSetpoint: { value: null },
flowDeadband: { value: null }
}, },
@@ -129,6 +138,32 @@
: 0; : 0;
} }
// control mode toggle UI
const toggleModeSections = (val) => {
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
const active = document.getElementById(`ps-mode-${val}`);
if (active) active.style.display = '';
};
const modeSelect = document.getElementById('node-input-controlMode');
if (modeSelect) {
modeSelect.value = this.controlMode || 'none';
toggleModeSections(modeSelect.value);
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
}
const setNumberField = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = Number.isFinite(val) ? val : '';
};
setNumberField('node-input-startLevel', this.startLevel);
setNumberField('node-input-stopLevel', this.stopLevel);
setNumberField('node-input-minFlowLevel', this.minFlowLevel);
setNumberField('node-input-maxFlowLevel', this.maxFlowLevel);
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
setNumberField('node-input-flowDeadband', this.flowDeadband);
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- // //------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
}, },
oneditsave: function () { oneditsave: function () {
@@ -151,6 +186,18 @@
node.refHeight = document.getElementById("node-input-refHeight").value || ""; node.refHeight = document.getElementById("node-input-refHeight").value || "";
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked; node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked; node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
// control strategy
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
node.startLevel = parseNum('node-input-startLevel');
node.stopLevel = parseNum('node-input-stopLevel');
node.minFlowLevel = parseNum('node-input-minFlowLevel');
node.maxFlowLevel = parseNum('node-input-maxFlowLevel');
node.flowSetpoint = parseNum('node-input-flowSetpoint');
node.flowDeadband = parseNum('node-input-flowDeadband');
}, },
}); });
@@ -160,7 +207,7 @@
<script type="text/html" data-template-name="pumpingStation"> <script type="text/html" data-template-name="pumpingStation">
<!-- Simulator toggle --> <h4>Simulation</h4>
<div class="form-row"> <div class="form-row">
<label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label> <label for="node-input-simulator"><i class="fa fa-play-circle"></i> Simulator</label>
<input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" /> <input type="checkbox" id="node-input-simulator" style="width:20px;vertical-align:baseline;" />
@@ -168,8 +215,8 @@
</div> </div>
<hr> <hr>
<!-- Basin geometry --> <h4>Basin Geometry</h4>
<div class="form-row"> <div class="form-row">
<label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label> <label for="node-input-basinVolume"><i class="fa fa-cube"></i> Basin Volume (m³)</label>
<input type="number" id="node-input-basinVolume" min="0" step="0.1" /> <input type="number" id="node-input-basinVolume" min="0" step="0.1" />
@@ -195,6 +242,50 @@
<hr> <hr>
<h4>Control Strategy</h4>
<div class="form-row">
<label for="node-input-controlMode"><i class="fa fa-sliders"></i> Control mode</label>
<select id="node-input-controlMode">
<option value="none">None / Manual</option>
<option value="levelbased">Level-based</option>
<option value="flowbased">Flow-based</option>
</select>
</div>
<div id="ps-mode-levelbased" class="ps-mode-section">
<div class="form-row">
<label for="node-input-startLevel">startLevel</label>
<input type="number" id="node-input-startLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-stopLevel">stopLevel</label>
<input type="number" id="node-input-stopLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-minFlowLevel">Min flow (m)</label>
<input type="number" id="node-input-minFlowLevel" placeholder="m" />
</div>
<div class="form-row">
<label for="node-input-maxFlowLevel">Max flow (m)</label>
<input type="number" id="node-input-maxFlowLevel" placeholder="m" />
</div>
</div>
<div id="ps-mode-flowbased" class="ps-mode-section" style="display:none">
<div class="form-row">
<label for="node-input-flowSetpoint">Flow setpoint</label>
<input type="number" id="node-input-flowSetpoint" placeholder="m3/h" />
</div>
<div class="form-row">
<label for="node-input-flowDeadband">Deadband</label>
<input type="number" id="node-input-flowDeadband" placeholder="m3/h" />
</div>
</div>
<hr>
<h4>Reference</h4>
<!-- Reference data --> <!-- Reference data -->
<div class="form-row"> <div class="form-row">
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label> <label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
@@ -217,6 +308,8 @@
<hr> <hr>
<h4>Safety</h4>
<!-- Safety settings --> <!-- Safety settings -->
<div class="form-row"> <div class="form-row">
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label> <label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
@@ -246,7 +339,7 @@
<label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label> <label for="node-input-overfillThresholdPercent" style="padding-left:20px;">High Volume Threshold (%)</label>
<input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" /> <input type="number" id="node-input-overfillThresholdPercent" min="0" max="100" step="0.1" />
</div> </div>
<hr>
<!-- Shared asset/logger/position menus --> <!-- Shared asset/logger/position menus -->
<div id="asset-fields-placeholder"></div> <div id="asset-fields-placeholder"></div>
<div id="logger-fields-placeholder"></div> <div id="logger-fields-placeholder"></div>

View File

@@ -66,6 +66,15 @@ class nodeClass {
minHeightBasedOn: uiConfig.minHeightBasedOn, minHeightBasedOn: uiConfig.minHeightBasedOn,
basinBottomRef: uiConfig.basinBottomRef, basinBottomRef: uiConfig.basinBottomRef,
}, },
control:{
mode: uiConfig.controlMode,
levelbased:{
startLevel:uiConfig.startLevel,
stopLevel:uiConfig.stopLevel,
minFlowLevel:uiConfig.minFlowLevel,
maxFlowLevel:uiConfig.maxFlowLevel
}
},
safety:{ safety:{
enableDryRunProtection: uiConfig.enableDryRunProtection, enableDryRunProtection: uiConfig.enableDryRunProtection,
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent, dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
@@ -216,6 +225,16 @@ class nodeClass {
.getCurrentValue('m3'); .getCurrentValue('m3');
this.source.calibratePredictedVolume(calibratedVolume); this.source.calibratePredictedVolume(calibratedVolume);
break; break;
case 'q_in': {
// payload can be number or { value, unit, timestamp }
const val = Number(msg.payload);
const unit = msg?.unit || 'm3/s';
const ts = msg?.timestamp || Date.now();
this.source.setManualInflow(val, ts, unit);
break;
}
} }
done(); done();
}); });

View File

@@ -1,5 +1,5 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation} = require('generalFunctions'); const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation,convert} = require('generalFunctions');
class PumpingStation { class PumpingStation {
constructor(config = {}) { constructor(config = {}) {
@@ -12,10 +12,12 @@ class PumpingStation {
this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel,this.config.general.name); this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel,this.config.general.name);
this.measurements = new MeasurementContainer({ autoConvert: true }); this.measurements = new MeasurementContainer({ autoConvert: true });
this.measurements.setPreferredUnit('flow', 'm3/s'); this.preferredUnits = {flow: "m3/s"};
this.measurements.setPreferredUnit('flow', this.preferredUnits.flow);
this.measurements.setPreferredUnit('netFlowRate', 'm3/s'); this.measurements.setPreferredUnit('netFlowRate', 'm3/s');
this.measurements.setPreferredUnit('level', 'm'); this.measurements.setPreferredUnit('level', 'm');
this.measurements.setPreferredUnit('volume', 'm3'); this.measurements.setPreferredUnit('volume', 'm3');
this.childRegistrationUtils = new childRegistrationUtils(this); this.childRegistrationUtils = new childRegistrationUtils(this);
this.machines = {}; this.machines = {};
this.stations = {}; this.stations = {};
@@ -192,52 +194,69 @@ class PumpingStation {
} }
async _controlLevelBased(snapshot, remainingTime) { _scaleLevelToFlowPercent(level,minflow,maxflow) {
const { minFlowLevel, maxFlowLevel } = this.config.control.levelbased;
const output = this.interpolate_lin_single_point(level,minFlowLevel,maxFlowLevel,minflow,maxflow);
return output;
}
// current volume as a percentage of usable capacity async _controlLevelBased(snapshot) {
const vol = this._resolveVolume(snapshot); const {startLevel, stopLevel} = this.config.control.levelbased;
if (vol == null) {
this.logger.warn('No valid volume found for level-based control'); //pick level prefering measured then predicted
const level = (snapshot) => {
for (const variant of this.levelVariants) {
const levelSnap = snapshot.levels?.[variant];
if (levelSnap?.samples?.current?.value !== undefined) {
return levelSnap.samples.current.value;
}
}
return null;
};
if (level == null) {
this.logger.warn('No valid level found');
return; return;
} }
const { thresholds, timeThresholdSeconds } = this.config.control.levelbased; if(level > startLevel){
const percentFull = (vol / this.basin.maxVolOverflow) * 100; let maxFlow = 0;
let minTotalFlow = Infinity;
Object.entries(this.machines).forEach(([machineId, machine]) => {
sumFlow =+ machine.measurements.type('flow').variant('predicted').variant('max').getCurrentValue(this.preferredUnits.flow);
const minflow = machine.measurements.type('flow').variant('predicted').variant('min').getCurrentValue(this.preferredUnits.flow);
if(minTotalFlow < minflow){ minflow = minTotalFlow};
});
// pick thresholds that are now crossed but were not crossed before Object.entries(this.machineGroups).forEach(([groupId, group]) => {
const newlyCrossed = thresholds.filter(t => percentFull >= t && !this._levelState.crossed.has(t)); sumFlow = group.dynamicTotals.flow.max;
this.logger.debug(`Level-based control: vol=${vol.toFixed(2)} m³ (${percentFull.toFixed(1)}%), newly crossed thresholds: [${newlyCrossed.join(', ')}]`);
if (!newlyCrossed.length) return;
const now = Date.now(); });
if (!this._levelState.dwellUntil) {
this._levelState.dwellUntil = now + timeThresholdSeconds * 1000;
this.logger.debug(`Level-based control: waiting ${timeThresholdSeconds}s before acting`); //start machines and or give group % control
return; Object.entries(this.machines).forEach(([machineId, machine]) => {
const position = machine?.config?.functionality?.positionVsParent;
if ((position === 'downstream' || position === 'atEquipment') && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'startup');
this.logger.warn(`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down machine "${machineId}"`);
}
});
Object.entries(this.machineGroups).forEach(([groupId, group]) => {
group.handleInput(Qd);
this.logger.warn(`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down machine group "${groupId}"`);
});
} }
this.logger.debug(`Level-based control: dwelling for another ${Math.round((this._levelState.dwellUntil - now) / 1000)} seconds`); const percControl = this._scaleLevelToFlowPercent(level);
if (now < this._levelState.dwellUntil) return; // still waiting
this._levelState.dwellUntil = null; // dwell satisfied, let pumps start
this._levelState.dwellUntil = null; await this._applyMachineGroupLevelControl(percControl);
await this._applyIdleMachineLevelControl(percControl);
newlyCrossed.forEach((threshold) => this._levelState.crossed.add(threshold));
const percentControl = this._calculateLevelControlPercent(thresholds);
if (percentControl <= 0) {
this.logger.debug('Level-based control: percent control resolved to 0%, skipping commands');
return;
}
this.logger.info(
`level-based control: thresholds [${newlyCrossed.join(', ')}]% reached, requesting ${percentControl.toFixed(1)}% control (vol=${vol.toFixed(2)} m³)`
);
await this._applyMachineGroupLevelControl(percentControl);
await this._applyIdleMachineLevelControl(percentControl);
} }
async _applyMachineGroupLevelControl(percentControl) { async _applyMachineGroupLevelControl(percentControl) {
@@ -275,14 +294,6 @@ class PumpingStation {
} }
} }
_calculateLevelControlPercent(thresholds = []) {
if (!thresholds.length) return 0;
const total = thresholds.length;
const crossed = this._levelState.crossed.size;
const pct = (crossed / total) * 100;
return Math.min(100, Math.max(0, pct));
}
_resolveVolume(snapshot) { _resolveVolume(snapshot) {
for (const variant of this.volVariants) { for (const variant of this.volVariants) {
@@ -300,13 +311,13 @@ class PumpingStation {
} }
//control logic //control logic
_controlLogic(snapshot, remainingTime){ _controlLogic(snapshot){
const mode = this.mode; const mode = this.mode;
switch(mode){ switch(mode){
case "levelbased": case "levelbased":
this.logger.debug(`Executing level-based control logic`); this.logger.debug(`Executing level-based control logic`);
this._controlLevelBased(snapshot, remainingTime); this._controlLevelBased(snapshot);
break; break;
case "flowbased": case "flowbased":
this._controlFlowBased(); this._controlFlowBased();
@@ -332,6 +343,11 @@ class PumpingStation {
.variant('predicted') .variant('predicted')
.position('atequipment'); .position('atequipment');
const levelChain = this.measurements
.type('level')
.variant('predicted')
.position('atequipment');
//if we have existing values clear them out //if we have existing values clear them out
const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null; const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null;
if (volumeMeasurement) { if (volumeMeasurement) {
@@ -339,20 +355,51 @@ class PumpingStation {
volumeMeasurement.timestamps = []; volumeMeasurement.timestamps = [];
} }
const levelMeasurement = levelChain.exists() ? levelChain.get() : null;
if (levelMeasurement) {
levelMeasurement.values = [];
levelMeasurement.timestamps = [];
}
volumeChain.value(calibratedVol, timestamp, 'm3').unit('m3'); volumeChain.value(calibratedVol, timestamp, 'm3').unit('m3');
levelChain.value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm');
this._predictedFlowState = {
inflow: 0,
outflow: 0,
lastTimestamp: timestamp
};
}
calibratePredictedLevel(val,timestamp = Date.now(),unit = 'm'){
const volumeChain = this.measurements
.type('volume')
.variant('predicted')
.position('atequipment');
const levelChain = this.measurements const levelChain = this.measurements
.type('level') .type('level')
.variant('predicted') .variant('predicted')
.position('atequipment'); .position('atequipment');
//if we have existing values clear them out
const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null;
if (volumeMeasurement) {
volumeMeasurement.values = [];
volumeMeasurement.timestamps = [];
}
const levelMeasurement = levelChain.exists() ? levelChain.get() : null; const levelMeasurement = levelChain.exists() ? levelChain.get() : null;
if (levelMeasurement) { if (levelMeasurement) {
levelMeasurement.values = []; levelMeasurement.values = [];
levelMeasurement.timestamps = []; levelMeasurement.timestamps = [];
} }
levelChain.value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm'); levelChain.value(val, timestamp).unit(unit);
volumeChain.value(this._calcVolumeFromLevel(val), timestamp, 'm3');
this._predictedFlowState = { this._predictedFlowState = {
inflow: 0, inflow: 0,
@@ -448,29 +495,58 @@ class PumpingStation {
} }
const handler = (eventData = {}) => { const handler = (eventData = {}) => {
const value = Number.isFinite(eventData.value) ? eventData.value : 0; const preferred = this.preferredUnits?.flow || 'm3/s';
const timestamp = eventData.timestamp ?? Date.now(); const unit = eventData.unit || 'l/s'; // dont assume m3/s
const unit = eventData.unit ?? 'm3/s'; const raw = Number.isFinite(eventData.value) ? eventData.value : 0;
const ts = eventData.timestamp || Date.now();
this.logger.debug(`Predicted flow update from ${childName} (${childId}, ${posKey}) -> ${value} ${unit}`); const normalized = convert(raw).from(unit).to(preferred);
this.predictedFlowChildren.get(childId)[posKey] = normalized;
this.predictedFlowChildren.get(childId)[posKey] = value; this._refreshAggregatedPredictedFlow(posKey, ts, preferred);
this._refreshAggregatedPredictedFlow(posKey, timestamp, unit);
}; };
eventNames.forEach((eventName) => child.measurements.emitter.on(eventName, handler)); eventNames.forEach((eventName) => child.measurements.emitter.on(eventName, handler));
} }
_refreshAggregatedPredictedFlow(direction, timestamp = Date.now(), unit = 'm3/s') { _refreshAggregatedPredictedFlow(direction) {
const preferredUnit = this.preferredUnits.flow;
const sum = Array.from(this.predictedFlowChildren.values()) const sum = Array.from(this.predictedFlowChildren.values())
.map((entry) => (Number.isFinite(entry[direction]) ? entry[direction] : 0)) .map((entry) => {
const v = entry[direction];
const num = Number(v?.value ?? v);
if (!Number.isFinite(num)) return 0;
const unit = v?.unit || preferredUnit; // default if none stored
return convert(num).from(unit).to(preferredUnit);
})
.reduce((acc, val) => acc + val, 0); .reduce((acc, val) => acc + val, 0);
this.measurements this.measurements
.type('flow') .type('flow')
.variant('predicted') .variant('predicted')
.position(direction) .position(direction)
.value(sum, timestamp, unit); .value(sum, Date.now(), preferredUnit);
}
setManualInflow(value, timestamp = Date.now(), unit) {
const num = Number(value);
const preferredUnit = this.preferredUnits.flow;
// Store the manual inflow in the measurement container with its source unit.
this.measurements.type('flow').variant('manual').position('in').value(num, timestamp, unit);
// Read back in preferred units so the aggregated predicted flow uses consistent units.
const entry = this.measurements
.type('flow')
.variant('manual')
.position('in')
.getCurrentValue(preferredUnit);
const predFlow = this.predictedFlowChildren.get('manual-qin') || { in: 0, out: 0 };
predFlow.in = Number.isFinite(entry) ? entry : 0;
this.predictedFlowChildren.set('manual-qin', predFlow);
// Pass preferred unit so we don't double-convert when writing the aggregate series.
this._refreshAggregatedPredictedFlow('in', timestamp, preferredUnit);
} }
_handleMeasurement(measurementType, value, position, context) { _handleMeasurement(measurementType, value, position, context) {