Compare commits
28 Commits
fa30be5e2d
...
dev-Rene
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7efd3b0a07 | ||
|
|
c81ee1b470 | ||
|
|
955c17a466 | ||
|
|
052ded7b6e | ||
|
|
321ea33bf7 | ||
|
|
288bd244dd | ||
|
|
d91609b3a4 | ||
|
|
5a575a29fe | ||
|
|
0a6c7ee2e1 | ||
|
|
4cc529b1c2 | ||
|
|
fbfcec4b47 | ||
|
|
43eb97407f | ||
|
|
9e4b149b64 | ||
|
|
1848486f1c | ||
|
|
d44cbc978b | ||
|
|
f243761f00 | ||
|
|
2a31c7ec69 | ||
|
|
69f68adffe | ||
|
|
5a1eff37d7 | ||
|
|
e8f9207a92 | ||
|
|
6e9ae9fc7e | ||
|
|
371f3c65e7 | ||
|
|
b8b7871e38 | ||
|
|
f29aa4f5af | ||
|
|
65807881d5 | ||
|
|
f9f6e874d1 | ||
|
|
eabaa1b0bf | ||
|
|
d94d5874bc |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "pumpingStation",
|
||||
"name": "pumpingstation",
|
||||
"version": "1.0.0",
|
||||
"description": "Control module",
|
||||
"main": "pumpingStation.js",
|
||||
@@ -11,7 +11,7 @@
|
||||
"url": "https://gitea.centraal.wbd-rd.nl/RnD/pumpingStation.git"
|
||||
},
|
||||
"keywords": [
|
||||
"pumpingStation",
|
||||
"pumpingstation",
|
||||
"node-red",
|
||||
"recipient",
|
||||
"water"
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"node-red": {
|
||||
"nodes": {
|
||||
"pumpingStation": "pumpingStation.js"
|
||||
"pumpingstation": "pumpingStation.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
<!--
|
||||
| S88-niveau | Primair (blokkleur) | Tekstkleur |
|
||||
| ---------------------- | ------------------- | ---------- |
|
||||
| **Area** | `#0f52a5` | wit |
|
||||
| **Process Cell** | `#0c99d9` | wit |
|
||||
| **Unit** | `#50a8d9` | zwart |
|
||||
| **Equipment (Module)** | `#86bbdd` | zwart |
|
||||
| **Control Module** | `#a9daee` | zwart |
|
||||
|
||||
-->
|
||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||
|
||||
<script>//test
|
||||
RED.nodes.registerType("pumpingStation", {
|
||||
category: "EVOLV",
|
||||
color: "#e4a363", // color for the node based on the S88 schema
|
||||
color: "#0c99d9", // color for the node based on the S88 schema
|
||||
defaults: {
|
||||
|
||||
// Define specific properties
|
||||
scaling: { value: false },
|
||||
i_min: { value: 0, required: true },
|
||||
i_max: { value: 0, required: true },
|
||||
i_offset: { value: 0 },
|
||||
o_min: { value: 0, required: true },
|
||||
o_max: { value: 1, required: true },
|
||||
// Define station-specific properties
|
||||
simulator: { value: false },
|
||||
smooth_method: { value: "" },
|
||||
count: { value: "10", required: true },
|
||||
basinVolume: { value: 1 }, // m³, total empty basin
|
||||
basinHeight: { value: 1 }, // m, floor to top
|
||||
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
|
||||
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
|
||||
heightOverflow: { value: 0.9 }, // m, overflow elevation
|
||||
timeleftToFullOrEmptyThresholdSeconds:{value:0}, // time threshold to safeguard starting or stopping pumps in seconds
|
||||
enableDryRunProtection: { value: true },
|
||||
enableOverfillProtection: { value: true },
|
||||
dryRunThresholdPercent: { value: 2 },
|
||||
overfillThresholdPercent: { value: 98 },
|
||||
minHeightBasedOn: { value: "outlet" }, // basis for minimum height check: inlet or outlet
|
||||
|
||||
// Advanced reference information
|
||||
refHeight: { value: "NAP" }, // reference height
|
||||
basinBottomRef: { value: 1 }, // absolute elevation of basin floor
|
||||
|
||||
//define asset properties
|
||||
uuid: { value: "" },
|
||||
@@ -37,7 +53,16 @@
|
||||
hasDistance: { value: false },
|
||||
distance: { value: 0 },
|
||||
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 }
|
||||
|
||||
},
|
||||
|
||||
@@ -45,16 +70,16 @@
|
||||
outputs: 3,
|
||||
inputLabels: ["Input"],
|
||||
outputLabels: ["process", "dbase", "parent"],
|
||||
icon: "font-awesome/fa-tachometer",
|
||||
icon: "font-awesome/fa-tint",
|
||||
|
||||
label: function () {
|
||||
return this.positionIcon + " " + this.assetType || "Measurement";
|
||||
return this.positionIcon + " PumpingStation";
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
||||
window.EVOLV.nodes.measurement.initEditor(this);
|
||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||
window.EVOLV.nodes.pumpingStation.initEditor(this);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
@@ -62,180 +87,269 @@
|
||||
// Wait for the menu data to be ready before initializing the editor
|
||||
waitForMenuData();
|
||||
|
||||
// THIS IS NODE SPECIFIC --------------- Initialize the dropdowns and other specific UI elements -------------- this should be derived from the config in the future (make config based menu)
|
||||
// Populate smoothing methods dropdown
|
||||
const smoothMethodSelect = document.getElementById('node-input-smooth_method');
|
||||
const options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
|
||||
// NODE SPECIFIC
|
||||
document.getElementById("node-input-basinVolume");
|
||||
document.getElementById("node-input-basinHeight");
|
||||
document.getElementById("node-input-heightInlet");
|
||||
document.getElementById("node-input-heightOutlet");
|
||||
document.getElementById("node-input-heightOverflow");
|
||||
document.getElementById("node-input-refHeight");
|
||||
document.getElementById("node-input-basinBottomRef");
|
||||
|
||||
// Clear existing options
|
||||
smoothMethodSelect.innerHTML = '';
|
||||
|
||||
// Add empty option
|
||||
const emptyOption = document.createElement('option');
|
||||
emptyOption.value = '';
|
||||
emptyOption.textContent = 'Select method...';
|
||||
smoothMethodSelect.appendChild(emptyOption);
|
||||
|
||||
// Add smoothing method options
|
||||
options.forEach(option => {
|
||||
const optionElement = document.createElement('option');
|
||||
optionElement.value = option.value;
|
||||
optionElement.textContent = option.value;
|
||||
optionElement.title = option.description; // Add tooltip with full description
|
||||
smoothMethodSelect.appendChild(optionElement);
|
||||
});
|
||||
|
||||
// Set current value if it exists
|
||||
if (this.smooth_method) {
|
||||
smoothMethodSelect.value = this.smooth_method;
|
||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
||||
if (refHeightEl) {
|
||||
refHeightEl.value = this.refHeight || "NAP";
|
||||
}
|
||||
|
||||
// --- Scale rows toggle ---
|
||||
const chk = document.getElementById('node-input-scaling');
|
||||
const rowMin = document.getElementById('row-input-i_min');
|
||||
const rowMax = document.getElementById('row-input-i_max');
|
||||
|
||||
function toggleScalingRows() {
|
||||
const show = chk.checked;
|
||||
rowMin.style.display = show ? 'block' : 'none';
|
||||
rowMax.style.display = show ? 'block' : 'none';
|
||||
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
|
||||
if (minHeightBasedOnEl) {
|
||||
minHeightBasedOnEl.value = this.minHeightBasedOn;
|
||||
}
|
||||
|
||||
// wire and initialize
|
||||
chk.addEventListener('change', toggleScalingRows);
|
||||
toggleScalingRows();
|
||||
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");
|
||||
|
||||
const toggleInput = (toggleEl, inputEl) => {
|
||||
if (!toggleEl || !inputEl) { return; }
|
||||
inputEl.disabled = !toggleEl.checked;
|
||||
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||
};
|
||||
|
||||
if (dryRunToggle && dryRunPercent) {
|
||||
dryRunToggle.checked = !!this.enableDryRunProtection;
|
||||
dryRunPercent.value = Number.isFinite(this.dryRunThresholdPercent) ? this.dryRunThresholdPercent : 2;
|
||||
dryRunToggle.addEventListener('change', () => toggleInput(dryRunToggle, dryRunPercent));
|
||||
toggleInput(dryRunToggle, dryRunPercent);
|
||||
}
|
||||
|
||||
if (overfillToggle && overfillPercent) {
|
||||
overfillToggle.checked = !!this.enableOverfillProtection;
|
||||
overfillPercent.value = Number.isFinite(this.overfillThresholdPercent) ? this.overfillThresholdPercent : 98;
|
||||
overfillToggle.addEventListener('change', () => toggleInput(overfillToggle, overfillPercent));
|
||||
toggleInput(overfillToggle, overfillPercent);
|
||||
}
|
||||
|
||||
const timeLeftInput = document.getElementById("node-input-timeleftToFullOrEmptyThresholdSeconds");
|
||||
if (timeLeftInput) {
|
||||
timeLeftInput.value = Number.isFinite(this.timeleftToFullOrEmptyThresholdSeconds)
|
||||
? this.timeleftToFullOrEmptyThresholdSeconds
|
||||
: 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 ------------------- //
|
||||
},
|
||||
oneditsave: function () {
|
||||
const node = this;
|
||||
|
||||
// Validate asset properties using the asset menu
|
||||
if (window.EVOLV?.nodes?.measurement?.assetMenu?.saveEditor) {
|
||||
success = window.EVOLV.nodes.measurement.assetMenu.saveEditor(this);
|
||||
}
|
||||
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||
|
||||
// Validate logger properties using the logger menu
|
||||
if (window.EVOLV?.nodes?.measurement?.loggerMenu?.saveEditor) {
|
||||
success = window.EVOLV.nodes.measurement.loggerMenu.saveEditor(node);
|
||||
}
|
||||
//node specific
|
||||
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;
|
||||
|
||||
// save position field
|
||||
if (window.EVOLV?.nodes?.measurement?.positionMenu?.saveEditor) {
|
||||
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
|
||||
}
|
||||
["basinVolume","basinHeight","heightInlet","heightOutlet","heightOverflow","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
|
||||
.forEach(field => {
|
||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||
});
|
||||
|
||||
// Save basic properties
|
||||
["smooth_method"].forEach(
|
||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
|
||||
);
|
||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
||||
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").checked;
|
||||
node.enableOverfillProtection = document.getElementById("node-input-enableOverfillProtection").checked;
|
||||
|
||||
// Save numeric and boolean properties
|
||||
["scaling", "simulator"].forEach(
|
||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
|
||||
);
|
||||
// control strategy
|
||||
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
|
||||
|
||||
["i_min", "i_max", "i_offset", "o_min", "o_max", "count"].forEach(
|
||||
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
||||
);
|
||||
|
||||
// Validation checks
|
||||
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
||||
RED.notify("Scaling enabled, but input range is incomplete!", "error");
|
||||
}
|
||||
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');
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Main UI -->
|
||||
|
||||
<script type="text/html" data-template-name="measurement">
|
||||
<script type="text/html" data-template-name="pumpingStation">
|
||||
|
||||
<!-- Scaling Checkbox -->
|
||||
<h4>Simulation</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-scaling"
|
||||
><i class="fa fa-compress"></i> Scaling</label>
|
||||
<input type="checkbox" id="node-input-scaling" style="width:20px; vertical-align:baseline;"/>
|
||||
<span>Enable input scaling?</span>
|
||||
<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;" />
|
||||
<span>Run station in simulated mode</span>
|
||||
</div>
|
||||
|
||||
<!-- Source Min/Max (only if scaling is true) -->
|
||||
<div class="form-row" id="row-input-i_min">
|
||||
<label for="node-input-i_min"><i class="fa fa-arrow-down"></i> Source Min</label>
|
||||
<input type="number" id="node-input-i_min" placeholder="0" />
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="form-row" id="row-input-i_max">
|
||||
<label for="node-input-i_max"><i class="fa fa-arrow-up"></i> Source Max</label>
|
||||
<input type="number" id="node-input-i_max" placeholder="3000" />
|
||||
</div>
|
||||
|
||||
<!-- Offset -->
|
||||
<h4>Basin Geometry</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-i_offset"><i class="fa fa-adjust"></i> Input Offset</label>
|
||||
<input type="number" id="node-input-i_offset" placeholder="0" />
|
||||
</div>
|
||||
|
||||
<!-- Output / Process Min/Max -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
|
||||
<input type="number" id="node-input-o_min" placeholder="0" />
|
||||
<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" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
|
||||
<input type="number" id="node-input-o_max" placeholder="1" />
|
||||
<label for="node-input-basinHeight"><i class="fa fa-arrows-v"></i> Basin Height (m)</label>
|
||||
<input type="number" id="node-input-basinHeight" min="0" step="0.1" />
|
||||
</div>
|
||||
|
||||
<!-- Simulator Checkbox -->
|
||||
<!-- Inlet/Outlet elevations -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-simulator"><i class="fa fa-cog"></i> Simulator</label>
|
||||
<input type="checkbox" id="node-input-simulator" style="width:20px; vertical-align:baseline;"/>
|
||||
<span>Activate internal simulation?</span>
|
||||
<label for="node-input-heightInlet"><i class="fa fa-long-arrow-up"></i> Inlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightInlet" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOutlet"><i class="fa fa-long-arrow-down"></i> Outlet Elevation (m)</label>
|
||||
<input type="number" id="node-input-heightOutlet" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-heightOverflow"><i class="fa fa-tint"></i> Overflow Level (m)</label>
|
||||
<input type="number" id="node-input-heightOverflow" min="0" step="0.01" />
|
||||
</div>
|
||||
|
||||
<!-- Smoothing Method -->
|
||||
<hr>
|
||||
|
||||
<h4>Control Strategy</h4>
|
||||
<div class="form-row">
|
||||
<label for="node-input-smooth_method"><i class="fa fa-line-chart"></i> Smoothing</label>
|
||||
<select id="node-input-smooth_method" style="width:60%;">
|
||||
<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>
|
||||
|
||||
<!-- Smoothing Window -->
|
||||
<div id="ps-mode-levelbased" class="ps-mode-section">
|
||||
<div class="form-row">
|
||||
<label for="node-input-count">Window</label>
|
||||
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
||||
<div class="form-tips">Number of samples for smoothing</div>
|
||||
<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>
|
||||
|
||||
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
|
||||
<!-- Asset fields will be injected here -->
|
||||
<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 -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-minHeightBasedOn"><i class="fa fa-arrows-v"></i> Minimum Height Based On</label>
|
||||
<select id="node-input-minHeightBasedOn" style="width:60%;">
|
||||
<option value="inlet">Inlet Elevation</option>
|
||||
<option value="outlet">Outlet Elevation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-refHeight"><i class="fa fa-map-marker"></i> Reference height</label>
|
||||
<select id="node-input-refHeight" style="width:60%;">
|
||||
<option value="NAP">NAP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-basinBottomRef"><i class="fa fa-level-down"></i> Basin Bottom (m Refheight)</label>
|
||||
<input type="number" id="node-input-basinBottomRef" step="0.01" />
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Safety</h4>
|
||||
|
||||
<!-- Safety settings -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-timeleftToFullOrEmptyThresholdSeconds"><i class="fa fa-clock-o"></i> Time To Empty/Full (s)</label>
|
||||
<input type="number" id="node-input-timeleftToFullOrEmptyThresholdSeconds" min="0" step="1" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableDryRunProtection">
|
||||
<i class="fa fa-shield"></i> Dry-run Protection
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableDryRunProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Prevent pumps from running on low volume</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dryRunThresholdPercent" style="padding-left:20px;">Low Volume Threshold (%)</label>
|
||||
<input type="number" id="node-input-dryRunThresholdPercent" min="0" max="100" step="0.1" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableOverfillProtection">
|
||||
<i class="fa fa-exclamation-triangle"></i> Overfill Protection
|
||||
</label>
|
||||
<input type="checkbox" id="node-input-enableOverfillProtection" style="width:20px;vertical-align:baseline;" />
|
||||
<span>Stop filling when approaching overflow</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<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" />
|
||||
</div>
|
||||
<hr>
|
||||
<!-- Shared asset/logger/position menus -->
|
||||
<div id="asset-fields-placeholder"></div>
|
||||
|
||||
<!-- loglevel checkbox -->
|
||||
<div id="logger-fields-placeholder"></div>
|
||||
|
||||
<!-- Position fields will be injected here -->
|
||||
<div id="position-fields-placeholder"></div>
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<script type="text/html" data-help-name="measurement">
|
||||
<p><b>Measurement Node</b>: Scales, smooths, and simulates measurement data.</p>
|
||||
<p>Use this node to scale, smooth, and simulate measurement data. The node can be configured to scale input data to a specified range, smooth the data using a variety of methods, and simulate data for testing purposes.</p>
|
||||
<li><b>Supplier:</b> Select a supplier to populate machine options.</li>
|
||||
<li><b>SubType:</b> Select a subtype if applicable to further categorize the asset.</li>
|
||||
<li><b>Model:</b> Define the specific model for more granular asset configuration.</li>
|
||||
<li><b>Unit:</b> Assign a unit to standardize measurements or operations.</li>
|
||||
<li><b>Scaling:</b> Enable or disable input scaling. When enabled, you must provide the source min and max values.</li>
|
||||
<li><b>Source Min/Max:</b> Define the minimum and maximum values for the input range when scaling is enabled.</li>
|
||||
<li><b>Input Offset:</b> Specify an offset value to be added to the input measurement.</li>
|
||||
<li><b>Process Min/Max:</b> Define the minimum and maximum values for the output range after processing.</li>
|
||||
<li><b>Simulator:</b> Activate internal simulation for testing purposes.</li>
|
||||
<li><b>Smoothing:</b> Select a smoothing method to apply to the measurement data.</li>
|
||||
<li><b>Window:</b> Define the number of samples to use for smoothing.</li>
|
||||
<li><b>Enable Log:</b> Enable or disable logging for this node.</li>
|
||||
<li><b>Log Level:</b> Select the log level (Info, Debug, Warn, Error) for logging messages.</li>
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<script type="text/html" data-help-name="pumpingStation">
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
146
src/nodeClass.js
146
src/nodeClass.js
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* basin.class.js
|
||||
*
|
||||
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
|
||||
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
|
||||
*/
|
||||
|
||||
const { outputUtils, configManager } = require('generalFunctions');
|
||||
const Specific = require("./specificClass");
|
||||
|
||||
@@ -58,6 +53,34 @@ class nodeClass {
|
||||
functionality: {
|
||||
positionVsParent: uiConfig.positionVsParent,// Default to 'atEquipment' if not specified
|
||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
||||
},
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,10 +105,7 @@ class nodeClass {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this node as a child upstream and downstream.
|
||||
* Delayed to avoid Node-RED startup race conditions.
|
||||
*/
|
||||
// init registration msg
|
||||
_registerChild() {
|
||||
setTimeout(() => {
|
||||
this.node.send([
|
||||
@@ -96,12 +116,73 @@ class nodeClass {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic tick loop to drive the Measurement class.
|
||||
*/
|
||||
_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)} m³`
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -109,11 +190,12 @@ class nodeClass {
|
||||
* Execute a single tick: update measurement, format and send outputs.
|
||||
*/
|
||||
_tick() {
|
||||
//this.source.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.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||
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]);
|
||||
@@ -126,13 +208,33 @@ class nodeClass {
|
||||
this.node.on('input', (msg, send, done) => {
|
||||
switch (msg.topic) {
|
||||
//example
|
||||
/*case 'simulator':
|
||||
this.source.toggleSimulation();
|
||||
case 'changemode':
|
||||
this.source.changeMode(msg.payload);
|
||||
break;
|
||||
default:
|
||||
this.source.handleInput(msg);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
done();
|
||||
});
|
||||
@@ -144,7 +246,7 @@ class nodeClass {
|
||||
_attachCloseHandler() {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
//clearInterval(this._statusInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,180 +1,801 @@
|
||||
const EventEmitter = require('events');
|
||||
const {logger,configUtils,configManager,MeasurementContainer,coolprop} = require('generalFunctions');
|
||||
const {
|
||||
logger,
|
||||
configUtils,
|
||||
configManager,
|
||||
childRegistrationUtils,
|
||||
MeasurementContainer,
|
||||
coolprop,
|
||||
interpolation
|
||||
} = require('generalFunctions');
|
||||
|
||||
class pumpingStation {
|
||||
constructor(config={}) {
|
||||
|
||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||
class PumpingStation {
|
||||
constructor(config = {}) {
|
||||
this.emitter = new EventEmitter();
|
||||
this.configManager = new configManager();
|
||||
this.defaultConfig = this.configManager.getConfig('pumpingStation');
|
||||
this.configUtils = new configUtils(this.defaultConfig);
|
||||
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);
|
||||
|
||||
// Init after config is set
|
||||
this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
|
||||
|
||||
// General properties
|
||||
this.measurements = new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
windowSize: this.config.smoothing.smoothWindow
|
||||
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' }
|
||||
});
|
||||
|
||||
// pumpingStation-specific properties
|
||||
this.flowrate = null; // Function to calculate flow rate based on water level rise or fall
|
||||
this.timeBeforeOverflow = null; // Time before the basin overflows at current inflow rate
|
||||
this.timeBeforeEmpty = null; // Time before the basin empties at current outflow rate
|
||||
this.heightInlet = null; // Height of the inlet pipe from the bottom of the basin
|
||||
this.heightOutlet = null; // Height of the outlet pipe from the bottom of the basin
|
||||
this.heightOverflow = null; // Height of the overflow point from the bottom of the basin
|
||||
this.volume = null; // Total volume of water in the basin, calculated from water level and basin dimensions
|
||||
this.emptyVolume = null; // Volume in the basin when empty (at level of outlet pipe)
|
||||
this.fullVolume = null; // Volume in the basin when at level of overflow point
|
||||
this.crossSectionalArea = null; // Cross-sectional area of the basin, used to calculate volume from water level
|
||||
this.childRegistrationUtils = new childRegistrationUtils(this);
|
||||
this.machines = {};
|
||||
this.stations = {};
|
||||
this.machineGroups = {};
|
||||
this.predictedFlowChildren = new Map();
|
||||
|
||||
this.flowVariants = ['measured', 'predicted'];
|
||||
this.levelVariants = ['measured', 'predicted'];
|
||||
this.volVariants = ['measured', 'predicted'];
|
||||
this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] };
|
||||
|
||||
this.mode = this.config.control.mode;
|
||||
this._levelState = { crossed: new Set(), dwellUntil: null };
|
||||
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
||||
|
||||
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||||
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||||
|
||||
// Initialize basin-specific properties from config
|
||||
this.initBasinProperties();
|
||||
|
||||
this.logger.debug('PumpingStation initialized');
|
||||
}
|
||||
|
||||
/*------------------- Register child events -------------------*/
|
||||
registerChild(child, softwareType) {
|
||||
this.logger.debug('Setting up child event for softwaretype ' + softwareType);
|
||||
/* --------------------------- Registration --------------------------- */
|
||||
|
||||
if(softwareType === "measurement"){
|
||||
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;
|
||||
this._registerPredictedFlowChild(child);
|
||||
}
|
||||
|
||||
if (softwareType === 'machine' || softwareType === 'pumpingstation' || softwareType === 'machinegroup') {
|
||||
this._registerPredictedFlowChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
_registerMeasurementChild(child) {
|
||||
const position = child.config.functionality.positionVsParent;
|
||||
const distance = child.config.functionality.distanceVsParent || 0;
|
||||
const measurementType = child.config.asset.type;
|
||||
const key = `${measurementType}_${position}`;
|
||||
//rebuild to measurementype.variant no position and then switch based on values not strings or names.
|
||||
const eventName = `${measurementType}.measured.${position}`;
|
||||
|
||||
this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`);
|
||||
// Register event listener for measurement updates
|
||||
child.measurements.emitter.on(eventName, (eventData) => {
|
||||
this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
|
||||
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
||||
this.logger.debug(
|
||||
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
|
||||
);
|
||||
|
||||
console.log(` Emitting... ${eventName} with data:`);
|
||||
// Store directly in parent's measurement container
|
||||
this.measurements
|
||||
.type(measurementType)
|
||||
.variant("measured")
|
||||
.variant('measured')
|
||||
.position(position)
|
||||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||||
|
||||
// Call the appropriate handler
|
||||
this._callMeasurementHandler(measurementType, eventData.value, position, eventData);
|
||||
this._handleMeasurement(measurementType, eventData.value, position, eventData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_callMeasurementHandler(measurementType, value, position, context) {
|
||||
switch (measurementType) {
|
||||
case 'pressure':
|
||||
this.updateMeasuredPressure(value, position, context);
|
||||
_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 eventNames;
|
||||
switch (position) {
|
||||
case 'downstream':
|
||||
case 'out':
|
||||
case 'atequipment':
|
||||
posKey = 'out';
|
||||
eventNames = ['flow.predicted.downstream', 'flow.predicted.atequipment'];
|
||||
break;
|
||||
|
||||
case 'flow':
|
||||
this.updateMeasuredFlow(value, position, context);
|
||||
case 'upstream':
|
||||
case 'in':
|
||||
posKey = 'in';
|
||||
eventNames = ['flow.predicted.upstream', 'flow.predicted.atequipment'];
|
||||
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);
|
||||
};
|
||||
|
||||
eventNames.forEach((ev) => child.measurements.emitter.on(ev, 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') {
|
||||
const volumeChain = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||
const levelChain = this.measurements.type('level').variant('predicted').position('atequipment');
|
||||
|
||||
const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null;
|
||||
if (volumeMeasurement) {
|
||||
volumeMeasurement.values = [];
|
||||
volumeMeasurement.timestamps = [];
|
||||
}
|
||||
|
||||
const levelMeasurement = levelChain.exists() ? levelChain.get() : null;
|
||||
if (levelMeasurement) {
|
||||
levelMeasurement.values = [];
|
||||
levelMeasurement.timestamps = [];
|
||||
}
|
||||
|
||||
levelChain.value(val, timestamp).unit(unit);
|
||||
volumeChain.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);
|
||||
}
|
||||
|
||||
/* --------------------------- 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, stopLevel } = this.config.control.levelbased;
|
||||
const flowUnit = this.measurements.getUnit('flow');
|
||||
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;
|
||||
}
|
||||
|
||||
if (level > startLevel && direction === 'filling') {
|
||||
const percControl = this._scaleLevelToFlowPercent(level);
|
||||
this.logger.debug(`Controllevel based => Level ${level} control applying to pump : ${percControl}`);
|
||||
await this._applyMachineLevelControl(percControl);
|
||||
}
|
||||
|
||||
if (level < stopLevel && direction === 'draining') {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
_controlFlowBased() {
|
||||
// placeholder for flow-based logic
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
case 'temperature':
|
||||
this.updateMeasuredTemperature(value, position, context);
|
||||
break;
|
||||
|
||||
case 'level':
|
||||
this.updateMeasuredLevel(value, position, context);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(`No handler for measurement type: ${measurementType}`);
|
||||
// Generic handler - just update position
|
||||
this.updatePosition();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// context handler for pressure updates
|
||||
updateMeasuredPressure(value, position, context = {}) {
|
||||
|
||||
let kelvinTemp = null;
|
||||
|
||||
//pressure updates come from pressure boxes inside the basin they get converted to a level and stored as level measured at position inlet or outlet
|
||||
this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`);
|
||||
|
||||
// Store in parent's measurement container for the first time
|
||||
this.measurements.type("pressure").variant("measured").position(position).value(value, context.timestamp, context.unit);
|
||||
|
||||
//convert pressure to level based on density of water and height of pressure sensor
|
||||
const mTemp = this.measurements.type("temperature").variant("measured").position("atEquipment").getCurrentValue('K'); //default to 20C if no temperature measurement
|
||||
|
||||
//prefer measured temp but otherwise assume nominal temp for wastewater
|
||||
if(mTemp === null){
|
||||
this.logger.warn(`No temperature measurement available, defaulting to 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');
|
||||
} else {
|
||||
kelvinTemp = mTemp;
|
||||
}
|
||||
this.logger.debug(`Using temperature: ${kelvinTemp} K for calculations`);
|
||||
const density = coolprop.PropsSI('D','T',kelvinTemp,'P',101325,'Water'); //density in kg/m3 at temp and surface pressure
|
||||
const g =
|
||||
|
||||
//calculate how muc flow went in or out based on pressure difference
|
||||
this.logger.debug(`Using pressure: ${pressure} for calculations`);
|
||||
_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.maxVolOverflow,
|
||||
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;
|
||||
}
|
||||
|
||||
//scaled for robin min 2039 - 2960 max 53.04
|
||||
_scaleLevelToFlowPercent(level) {
|
||||
const { minFlowLevel, maxFlowLevel } = this.config.control.levelbased;
|
||||
this.logger.debug(`Scaling minflow level : ${minFlowLevel} and maxflowLevel : ${maxFlowLevel}`);
|
||||
return this.interpolate.interpolate_lin_single_point(level, minFlowLevel, maxFlowLevel, 0, 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.maxVolOverflow,
|
||||
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 { heightOverflow, heightOutlet, 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(heightOverflow - lvl, 0) : Math.max(lvl - heightOutlet, 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 --------------------------- */
|
||||
|
||||
_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,
|
||||
overfillThresholdPercent,
|
||||
timeleftToFullOrEmptyThresholdSeconds
|
||||
} = this.config.safety || {};
|
||||
|
||||
const dryRunEnabled = Boolean(enableDryRunProtection);
|
||||
const overfillEnabled = Boolean(enableOverfillProtection);
|
||||
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||
const triggerHighVol = this.basin.maxVolOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
|
||||
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100));
|
||||
|
||||
if (direction === 'draining') {
|
||||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||||
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
|
||||
if (timeTriggered || dryRunTriggered) {
|
||||
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(
|
||||
`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
|
||||
);
|
||||
this.safetyControllerActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'filling') {
|
||||
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
|
||||
const overfillTriggered = overfillEnabled && vol > triggerHighVol;
|
||||
if (timeTriggered || overfillTriggered) {
|
||||
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.machineGroups).forEach((group) => group.turnOffAllMachines());
|
||||
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
|
||||
this.logger.warn(
|
||||
`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment`
|
||||
);
|
||||
this.safetyControllerActive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- Basin --------------------------- */
|
||||
|
||||
initBasinProperties() {
|
||||
// Initialize basin-specific properties from config
|
||||
this.heightInlet = this.config.basin.heightInlet || 0; // Default to 0 if not specified
|
||||
this.heightOutlet = this.config.basin.heightOutlet || 0; // Default to 0 if not specified
|
||||
this.heightOverflow = this.config.basin.heightOverflow || 0; // Default to 0 if not specified
|
||||
this.crossSectionalArea = this.config.basin.crossSectionalArea || 1; // Default to 1 m² if not specified
|
||||
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
|
||||
const volEmptyBasin = this.config.basin.volume;
|
||||
const heightBasin = this.config.basin.height;
|
||||
const heightInlet = this.config.basin.heightInlet;
|
||||
const heightOutlet = this.config.basin.heightOutlet;
|
||||
const heightOverflow = this.config.basin.heightOverflow;
|
||||
|
||||
const surfaceArea = volEmptyBasin / heightBasin;
|
||||
const maxVol = heightBasin * surfaceArea;
|
||||
const maxVolOverflow = heightOverflow * surfaceArea;
|
||||
const minVolOut = heightOutlet * surfaceArea;
|
||||
const minVolIn = heightInlet * surfaceArea;
|
||||
const minVol = minHeightBasedOn === 'inlet' ? minVolIn : minVolOut;
|
||||
|
||||
this.basin = {
|
||||
volEmptyBasin,
|
||||
heightBasin,
|
||||
heightInlet,
|
||||
heightOutlet,
|
||||
heightOverflow,
|
||||
surfaceArea,
|
||||
maxVol,
|
||||
maxVolOverflow,
|
||||
minVolIn,
|
||||
minVolOut,
|
||||
minVol,
|
||||
minHeightBasedOn
|
||||
};
|
||||
|
||||
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
|
||||
}
|
||||
|
||||
_calcVolumeFromLevel(level) {
|
||||
return Math.max(level, 0) * this.basin.surfaceArea;
|
||||
}
|
||||
|
||||
_calcLevelFromVolume(volume) {
|
||||
return Math.max(volume, 0) / this.basin.surfaceArea;
|
||||
}
|
||||
|
||||
/* --------------------------- Output --------------------------- */
|
||||
|
||||
getOutput() {
|
||||
return {
|
||||
volume: this.volume,
|
||||
};
|
||||
const output = this.measurements.getFlattenedOutput();
|
||||
output.direction = this.state.direction;
|
||||
output.flowSource = this.state.flowSource;
|
||||
output.timeleft = this.state.seconds;
|
||||
output.volEmptyBasin = this.basin.volEmptyBasin;
|
||||
output.heightInlet = this.basin.heightInlet;
|
||||
output.heightOverflow = this.basin.heightOverflow;
|
||||
output.maxVol = this.basin.maxVol;
|
||||
output.minVol = this.basin.minVol;
|
||||
output.maxVolOverflow = this.basin.maxVolOverflow;
|
||||
output.minVolOut = this.basin.minVolOut;
|
||||
output.minVolIn = this.basin.minVolIn;
|
||||
output.minHeightBasedOn = this.basin.minHeightBasedOn;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Basin;
|
||||
module.exports = PumpingStation;
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Example usage */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
if (require.main === module) {
|
||||
const Measurement = require('../../measurement/src/specificClass');
|
||||
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
|
||||
|
||||
// */
|
||||
|
||||
|
||||
(async () => {
|
||||
const PropsSI = await coolprop.getPropsSI();
|
||||
|
||||
// 👇 replace these with your real inputs
|
||||
const tC_input = 25; // °C
|
||||
const pPa_input = 101325; // Pa
|
||||
|
||||
// Sanitize & convert
|
||||
const T = Number(tC_input) + 273.15; // K
|
||||
const P = Number(pPa_input); // Pa
|
||||
const fluid = 'Water';
|
||||
|
||||
// Preconditions
|
||||
if (!Number.isFinite(T) || !Number.isFinite(P)) {
|
||||
throw new Error(`Bad inputs: T=${T} K, P=${P} Pa`);
|
||||
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,
|
||||
heightInlet: 3,
|
||||
heightOutlet: 0.2,
|
||||
heightOverflow: 3.2
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
basinBottomRef: 0
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection:false,
|
||||
enableOverfillProtection:false
|
||||
}
|
||||
if (T <= 0) throw new Error(`Temperature must be in Kelvin (>0). Got ${T}.`);
|
||||
if (P <= 0) throw new Error(`Pressure must be >0 Pa. Got ${P}.`);
|
||||
|
||||
// Try T,P order
|
||||
let rho = PropsSI('D', 'T', T, 'P', P, fluid);
|
||||
// Fallback: P,T order (should be equivalent)
|
||||
if (!Number.isFinite(rho)) rho = PropsSI('D', 'P', P, 'T', T, fluid);
|
||||
|
||||
console.log({ T, P, rho });
|
||||
|
||||
if (!Number.isFinite(rho)) {
|
||||
console.error('Still Infinity. Extra checks:');
|
||||
console.error('typeof T:', typeof T, 'typeof P:', typeof P);
|
||||
console.error('Example known-good call:', PropsSI('D', 'T', 298.15, 'P', 101325, 'Water'));
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
//*/
|
||||
Reference in New Issue
Block a user