Compare commits
2 Commits
e2ebb31816
...
52d3889fbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d3889fbc | ||
|
|
7afcd6e54a |
57
CONTRACT.md
Normal file
57
CONTRACT.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# pumpingStation — Contract
|
||||||
|
|
||||||
|
Hand-maintained for Phase 2; the `## Inputs` table is generated from
|
||||||
|
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
|
||||||
|
|
||||||
|
## Inputs (msg.topic on Port 0)
|
||||||
|
|
||||||
|
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `set.mode` | `changemode` | `string` — one of `manual`, `levelbased`, `flowbased`, `none` | Switches the control strategy. |
|
||||||
|
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils` at the supplied `msg.positionVsParent`. |
|
||||||
|
| `cmd.calibrate.volume` | `calibratePredictedVolume` | numeric (number or numeric string) — m³ | Resets the predicted-volume series and seeds it with the supplied value; recomputes level. |
|
||||||
|
| `cmd.calibrate.level` | `calibratePredictedLevel` | numeric — metres | Resets the predicted-level series and seeds it with the supplied value; recomputes volume. |
|
||||||
|
| `set.inflow` | `q_in` | number, numeric string, or `{ value, unit, timestamp }` | Pushes a manual inflow measurement onto the predicted-flow series. `unit` may be on the message (`msg.unit`) or inside the object payload. |
|
||||||
|
| `set.demand` | `Qd` | numeric — child setpoint demand | Forwards the demand to direct children (machineGroups / machines / stations). Only honoured in `manual` mode; in other modes the call is logged at `debug` and discarded. |
|
||||||
|
|
||||||
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|
||||||
|
## Outputs (msg.topic on Port 0/1/2)
|
||||||
|
|
||||||
|
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
||||||
|
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
|
||||||
|
(only changed fields are emitted).
|
||||||
|
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
||||||
|
`'influxdb'` formatter.
|
||||||
|
- **Port 2 (registration):** at startup the node sends one
|
||||||
|
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
|
||||||
|
to the upstream parent.
|
||||||
|
|
||||||
|
## Events emitted by `source.measurements.emitter`
|
||||||
|
|
||||||
|
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
|
||||||
|
the corresponding series receives a new value. Parents subscribe via the
|
||||||
|
generic `child.measurements.emitter.on(eventName, ...)` handshake.
|
||||||
|
pumpingStation publishes:
|
||||||
|
|
||||||
|
- `volume.predicted.atequipment` — basin volume integrator output (m³).
|
||||||
|
- `level.predicted.atequipment` — basin level (m), recomputed from volume.
|
||||||
|
- `flow.predicted.in` (childed `manual-qin`) — manual inflow injections.
|
||||||
|
- `volume.measured.atequipment`, `level.measured.<position>`,
|
||||||
|
`pressure.measured.<position>`, `temperature.measured.atequipment`,
|
||||||
|
`flow.predicted.<in|out>` (childed by upstream child id) — when a
|
||||||
|
matching child measurement arrives.
|
||||||
|
|
||||||
|
The exact set is data-driven by which children register and what they
|
||||||
|
publish; downstream consumers should subscribe by event name, not assume
|
||||||
|
a fixed catalogue.
|
||||||
|
|
||||||
|
## Children registered by this node
|
||||||
|
|
||||||
|
pumpingStation acts as a parent for `measurement`, `machine`, `machinegroup`,
|
||||||
|
and `pumpingstation` software types. Position labels accepted from
|
||||||
|
children are `upstream`, `downstream`, `atequipment` (and the synonyms
|
||||||
|
`in` / `out` for predicted-flow children). Child-registration plumbing is
|
||||||
|
documented in `MODULE_SPLIT.md`; this node does not receive children
|
||||||
|
through Port 0 input — registration arrives on Port 2 from the child via
|
||||||
|
the standard `childRegistrationUtils` handshake.
|
||||||
57
examples/standalone-demo.js
Normal file
57
examples/standalone-demo.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Standalone PumpingStation demo — run with `node examples/standalone-demo.js`.
|
||||||
|
* Builds a station + one pump, calibrates predicted volume, ticks once.
|
||||||
|
* Useful for sanity-checking the orchestrator without Node-RED.
|
||||||
|
*/
|
||||||
|
const PumpingStation = require('../src/specificClass');
|
||||||
|
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
|
||||||
|
|
||||||
|
function createPumpingStationConfig(name) {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
logging: { enabled: true, logLevel: 'debug' },
|
||||||
|
name,
|
||||||
|
id: `${name}-${Date.now()}`,
|
||||||
|
flowThreshold: 1e-4,
|
||||||
|
},
|
||||||
|
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller' },
|
||||||
|
basin: { volume: 43.75, height: 10, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 3.2 },
|
||||||
|
hydraulics: { refHeight: 'NAP', basinBottomRef: 0 },
|
||||||
|
safety: { enableDryRunProtection: false, enableOverfillProtection: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function demo() {
|
||||||
|
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
|
||||||
|
const pump1 = new RotatingMachine(createMachineConfig('Pump1', 'downstream'), createMachineStateConfig());
|
||||||
|
|
||||||
|
station.childRegistrationUtils.registerChild(pump1, 'machine');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
console.log('Station state:', station.state);
|
||||||
|
console.log('Station output:', station.getOutput());
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error('Demo failed:', err);
|
||||||
|
});
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
| **Control Module** | `#a9daee` | zwart |
|
| **Control Module** | `#a9daee` | zwart |
|
||||||
|
|
||||||
-->
|
-->
|
||||||
<script src="/pumpingStation/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
<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 src="/pumpingStation/configData.js"></script> <!-- Load the config script for node information -->
|
||||||
|
<script src="/pumpingStation/editor.js"></script> <!-- Load the basin-diagram editor logic -->
|
||||||
|
|
||||||
<script>//test
|
<script>//test
|
||||||
RED.nodes.registerType("pumpingStation", {
|
RED.nodes.registerType("pumpingStation", {
|
||||||
@@ -86,296 +87,14 @@
|
|||||||
return this.positionIcon + " PumpingStation";
|
return this.positionIcon + " PumpingStation";
|
||||||
},
|
},
|
||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function () {
|
||||||
const waitForMenuData = () => {
|
window.EVOLV?.nodes?.pumpingStation?.editor?.init(this);
|
||||||
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
|
||||||
window.EVOLV.nodes.pumpingStation.initEditor(this);
|
|
||||||
} else {
|
|
||||||
setTimeout(waitForMenuData, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Wait for the menu data to be ready before initializing the editor
|
|
||||||
waitForMenuData();
|
|
||||||
|
|
||||||
// NODE SPECIFIC
|
|
||||||
document.getElementById("node-input-basinVolume");
|
|
||||||
document.getElementById("node-input-basinHeight");
|
|
||||||
document.getElementById("node-input-inflowLevel");
|
|
||||||
document.getElementById("node-input-outflowLevel");
|
|
||||||
document.getElementById("node-input-overflowLevel");
|
|
||||||
document.getElementById("node-input-refHeight");
|
|
||||||
document.getElementById("node-input-basinBottomRef");
|
|
||||||
|
|
||||||
const refHeightEl = document.getElementById("node-input-refHeight");
|
|
||||||
if (refHeightEl) {
|
|
||||||
refHeightEl.value = this.refHeight || "NAP";
|
|
||||||
}
|
|
||||||
|
|
||||||
const minHeightBasedOnEl = document.getElementById("node-input-minHeightBasedOn");
|
|
||||||
if (minHeightBasedOnEl) {
|
|
||||||
minHeightBasedOnEl.value = this.minHeightBasedOn;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dryRunToggle = document.getElementById("node-input-enableDryRunProtection");
|
|
||||||
const dryRunPercent = document.getElementById("node-input-dryRunThresholdPercent");
|
|
||||||
const overfillToggle = document.getElementById("node-input-enableOverfillProtection");
|
|
||||||
const overfillPercent = document.getElementById("node-input-overfillThresholdPercent");
|
|
||||||
|
|
||||||
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-minLevel', this.minLevel);
|
|
||||||
setNumberField('node-input-maxLevel', this.maxLevel);
|
|
||||||
setNumberField('node-input-flowSetpoint', this.flowSetpoint);
|
|
||||||
setNumberField('node-input-flowDeadband', this.flowDeadband);
|
|
||||||
|
|
||||||
// Interactive diagram: place every threshold line/input at its
|
|
||||||
// proportional y on the tank, plus compute derived safety levels
|
|
||||||
// (dryRunLevel, overfillLevel) that are shown both in the diagram
|
|
||||||
// and next to the safety-% fields. Same formulas as
|
|
||||||
// specificClass._validateThresholdOrdering.
|
|
||||||
const DIAG = { topY: 40, botY: 380 };
|
|
||||||
const fNum = (id) => {
|
|
||||||
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
|
||||||
return Number.isFinite(v) ? v : null;
|
|
||||||
};
|
|
||||||
const yForLevel = (val, basinH) => {
|
|
||||||
if (val == null || !basinH) return null;
|
|
||||||
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
|
||||||
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
|
||||||
};
|
|
||||||
// Place a row — line, label, input, unit all share the same y.
|
|
||||||
// The diagram is a schematic ordered list (value order is
|
|
||||||
// preserved, but the y-positions are distributed with a
|
|
||||||
// guaranteed minimum gap for readability), not a strictly
|
|
||||||
// proportional rendering.
|
|
||||||
const placeItem = (id, y) => {
|
|
||||||
const line = document.getElementById(`ps-line-${id}`);
|
|
||||||
const label = document.getElementById(`ps-label-${id}`);
|
|
||||||
const unit = document.getElementById(`ps-unit-${id}`);
|
|
||||||
const fo = document.getElementById(`ps-fo-${id}`);
|
|
||||||
const sub = document.getElementById(`ps-sub-${id}`);
|
|
||||||
const lead = document.getElementById(`ps-leader-${id}`);
|
|
||||||
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
|
||||||
if (label) label.setAttribute('y', y + 4);
|
|
||||||
if (unit) unit.setAttribute('y', y + 4);
|
|
||||||
if (fo) fo.setAttribute('y', y - 11);
|
|
||||||
if (sub) sub.setAttribute('y', y + 15);
|
|
||||||
if (lead) lead.setAttribute('visibility', 'hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const redraw = () => {
|
|
||||||
const basinH = fNum('basinHeight') || 5;
|
|
||||||
|
|
||||||
// Derived safety levels (participate in the right-column stack)
|
|
||||||
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
|
|
||||||
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
|
|
||||||
const dryPct = fNum('dryRunThresholdPercent');
|
|
||||||
const ovfPct = fNum('overfillThresholdPercent');
|
|
||||||
const ovf = fNum('overflowLevel');
|
|
||||||
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
|
||||||
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
|
|
||||||
|
|
||||||
// Right-column stack. TWO anchors: basinHeight pinned at the
|
|
||||||
// tank rim (top) and outflowLevel pinned at its proportional y
|
|
||||||
// (bottom). Everything between is nudged to maintain a minimum
|
|
||||||
// vertical gap via two passes — top-down from the rim, then
|
|
||||||
// bottom-up from the outlet — so the dashed lines keep their
|
|
||||||
// value-order and outlet stays near the floor where it belongs.
|
|
||||||
const items = [
|
|
||||||
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
|
||||||
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
|
||||||
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
|
|
||||||
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
|
|
||||||
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), basinH) },
|
|
||||||
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
|
||||||
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
|
||||||
].filter(it => it.yIdeal != null);
|
|
||||||
|
|
||||||
const GAP = 36;
|
|
||||||
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
|
||||||
for (const it of items) it.y = it.yIdeal;
|
|
||||||
// Pass 1: top-down — push DOWN to maintain GAP; pinned items don't move
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
|
||||||
if (items[i].pinned) continue;
|
|
||||||
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
|
||||||
}
|
|
||||||
// Pass 2: bottom-up — push UP so outflow's pin propagates up the stack
|
|
||||||
for (let i = items.length - 2; i >= 0; i--) {
|
|
||||||
if (items[i].pinned) continue;
|
|
||||||
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
|
||||||
}
|
|
||||||
for (const it of items) placeItem(it.id, it.y);
|
|
||||||
|
|
||||||
// Zone labels between adjacent thresholds (italic, centered).
|
|
||||||
// Hidden if either bracketing threshold is missing, or the gap
|
|
||||||
// is too small to read (< 14 px).
|
|
||||||
const placeZone = (zoneId, topId, botId) => {
|
|
||||||
const el = document.getElementById(`ps-zone-${zoneId}`);
|
|
||||||
if (!el) return;
|
|
||||||
const top = items.find(it => it.id === topId);
|
|
||||||
const bot = items.find(it => it.id === botId);
|
|
||||||
if (!top || !bot || (bot.y - top.y) < 14) {
|
|
||||||
el.setAttribute('visibility', 'hidden'); return;
|
|
||||||
}
|
|
||||||
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
|
||||||
el.setAttribute('visibility', 'visible');
|
|
||||||
};
|
|
||||||
placeZone('spare', 'overflowLevel', 'maxLevel');
|
|
||||||
placeZone('sewage', 'maxLevel', 'startLevel');
|
|
||||||
placeZone('buffer1', 'startLevel', 'minLevel');
|
|
||||||
placeZone('buffer2', 'minLevel', 'dryRunLevel');
|
|
||||||
// "Dead volume" sits inside the blue band between outflowLevel and the floor
|
|
||||||
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
|
||||||
const deadLbl = document.getElementById('ps-zone-dead');
|
|
||||||
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
|
||||||
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
|
||||||
deadLbl.setAttribute('visibility', 'visible');
|
|
||||||
} else if (deadLbl) {
|
|
||||||
deadLbl.setAttribute('visibility', 'hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inlet arrow — sole item on the left, no stacking concerns
|
|
||||||
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
|
||||||
if (inflowY != null) {
|
|
||||||
const line = document.getElementById('ps-line-inflowLevel');
|
|
||||||
const lbl = document.getElementById('ps-label-inflowLevel');
|
|
||||||
const sub = document.getElementById('ps-sub-inflowLevel');
|
|
||||||
const fo = document.getElementById('ps-fo-inflowLevel');
|
|
||||||
const unit = document.getElementById('ps-unit-inflowLevel');
|
|
||||||
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
|
||||||
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
|
||||||
if (sub) sub.setAttribute('y', inflowY + 8);
|
|
||||||
if (fo) fo.setAttribute('y', inflowY - 11);
|
|
||||||
if (unit) unit.setAttribute('y', inflowY + 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dead-volume band: from the (possibly-nudged) outflow line
|
|
||||||
// down to the floor. Use the nudged y so the band meets the
|
|
||||||
// outflow line exactly.
|
|
||||||
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
|
||||||
const deadvol = document.getElementById('ps-deadvol');
|
|
||||||
if (deadvol && outflowItem) {
|
|
||||||
deadvol.setAttribute('y', outflowItem.y);
|
|
||||||
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
|
||||||
}
|
|
||||||
|
|
||||||
// dryRunLevel label text (derived, read-only)
|
|
||||||
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
|
||||||
if (dryLbl) dryLbl.textContent = dryLvl != null
|
|
||||||
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
|
|
||||||
: 'dryRunLevel ≈ — m (safety — from %)';
|
|
||||||
|
|
||||||
// Safety-section readouts (second view, beneath the diagram)
|
|
||||||
const d1 = document.getElementById('derived-dryRunLevel');
|
|
||||||
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
|
|
||||||
const d2 = document.getElementById('derived-overfillLevel');
|
|
||||||
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
|
|
||||||
|
|
||||||
// Ordering warning ribbon
|
|
||||||
const warn = document.getElementById('ps-warning');
|
|
||||||
const issues = [];
|
|
||||||
const pairs = [
|
|
||||||
['outflowLevel', 'inflowLevel', '<'],
|
|
||||||
['inflowLevel', 'overflowLevel', '<'],
|
|
||||||
['minLevel', 'startLevel', '<='],
|
|
||||||
['startLevel', 'maxLevel', '<'],
|
|
||||||
['maxLevel', 'overflowLevel', '<='],
|
|
||||||
];
|
|
||||||
for (const [a, b, op] of pairs) {
|
|
||||||
const av = fNum(a), bv = fNum(b);
|
|
||||||
if (av == null || bv == null) continue;
|
|
||||||
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
|
|
||||||
}
|
|
||||||
if (warn) {
|
|
||||||
if (issues.length) { warn.setAttribute('visibility', 'visible'); warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`; }
|
|
||||||
else { warn.setAttribute('visibility', 'hidden'); }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
|
|
||||||
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'].forEach((id) => {
|
|
||||||
const el = document.getElementById(`node-input-${id}`);
|
|
||||||
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
|
|
||||||
});
|
|
||||||
setTimeout(redraw, 60);
|
|
||||||
|
|
||||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
|
||||||
},
|
},
|
||||||
oneditsave: function () {
|
oneditsave: function () {
|
||||||
const node = this;
|
const node = this;
|
||||||
|
|
||||||
//window.EVOLV?.nodes?.pumpingStation?.assetMenu?.saveEditor?.(node);
|
|
||||||
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
window.EVOLV?.nodes?.pumpingStation?.loggerMenu?.saveEditor?.(node);
|
||||||
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
window.EVOLV?.nodes?.pumpingStation?.positionMenu?.saveEditor?.(node);
|
||||||
|
window.EVOLV?.nodes?.pumpingStation?.editor?.save(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;
|
|
||||||
|
|
||||||
["basinVolume","basinHeight","inflowLevel","outflowLevel","overflowLevel","basinBottomRef","timeleftToFullOrEmptyThresholdSeconds","dryRunThresholdPercent","overfillThresholdPercent"]
|
|
||||||
.forEach(field => {
|
|
||||||
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
node.refHeight = document.getElementById("node-input-refHeight").value || "";
|
|
||||||
node.enableDryRunProtection = document.getElementById("node-input-enableDryRunProtection").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.minLevel = parseNum('node-input-minLevel');
|
|
||||||
node.maxLevel = parseNum('node-input-maxLevel');
|
|
||||||
node.flowSetpoint = parseNum('node-input-flowSetpoint');
|
|
||||||
node.flowDeadband = parseNum('node-input-flowDeadband');
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,4 +37,16 @@ module.exports = function(RED) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Editor.js — extracted SVG basin-diagram + oneditprepare/oneditsave logic.
|
||||||
|
RED.httpAdmin.get(`/${nameOfNode}/editor.js`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const script = fs.readFileSync(path.join(__dirname, 'src/editor.js'), 'utf8');
|
||||||
|
res.type('application/javascript').send(script);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send(`// Error loading editor.js: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
91
src/basin/BasinGeometry.js
Normal file
91
src/basin/BasinGeometry.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Basin geometry for a wet-well pumping station.
|
||||||
|
//
|
||||||
|
// Models the basin as a rectangular prism (constant cross-section), so
|
||||||
|
// volume = level × surfaceArea. Owns the level↔volume conversions and the
|
||||||
|
// derived threshold volumes used by control + safety. Pure domain — no
|
||||||
|
// Node-RED, no logger, no side effects beyond construction.
|
||||||
|
|
||||||
|
class BasinGeometry {
|
||||||
|
/**
|
||||||
|
* @param {object} basinConfig - { volume, height, inflowLevel, outflowLevel, overflowLevel }
|
||||||
|
* @param {object} hydraulicsConfig - { minHeightBasedOn: 'inlet' | 'outlet' }
|
||||||
|
*/
|
||||||
|
constructor(basinConfig, hydraulicsConfig) {
|
||||||
|
const volEmptyBasin = basinConfig.volume;
|
||||||
|
const heightBasin = basinConfig.height;
|
||||||
|
const inflowLevel = basinConfig.inflowLevel;
|
||||||
|
const outflowLevel = basinConfig.outflowLevel;
|
||||||
|
const overflowLevel = basinConfig.overflowLevel;
|
||||||
|
const minHeightBasedOn = hydraulicsConfig?.minHeightBasedOn;
|
||||||
|
|
||||||
|
const surfaceArea = volEmptyBasin / heightBasin;
|
||||||
|
|
||||||
|
// maxVol ≡ volEmptyBasin under the constant cross-section assumption;
|
||||||
|
// kept as a separate field for naming symmetry with the trigger volumes.
|
||||||
|
const maxVol = heightBasin * surfaceArea;
|
||||||
|
const maxVolAtOverflow = overflowLevel * surfaceArea;
|
||||||
|
const minVolAtOutflow = outflowLevel * surfaceArea;
|
||||||
|
const minVolAtInflow = inflowLevel * surfaceArea;
|
||||||
|
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
|
||||||
|
|
||||||
|
this._volEmptyBasin = volEmptyBasin;
|
||||||
|
this._heightBasin = heightBasin;
|
||||||
|
this._inflowLevel = inflowLevel;
|
||||||
|
this._outflowLevel = outflowLevel;
|
||||||
|
this._overflowLevel = overflowLevel;
|
||||||
|
this._surfaceArea = surfaceArea;
|
||||||
|
this._maxVol = maxVol;
|
||||||
|
this._maxVolAtOverflow = maxVolAtOverflow;
|
||||||
|
this._minVolAtInflow = minVolAtInflow;
|
||||||
|
this._minVolAtOutflow = minVolAtOutflow;
|
||||||
|
this._minVol = minVol;
|
||||||
|
this._minHeightBasedOn = minHeightBasedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
get volEmptyBasin() { return this._volEmptyBasin; }
|
||||||
|
get heightBasin() { return this._heightBasin; }
|
||||||
|
get inflowLevel() { return this._inflowLevel; }
|
||||||
|
get outflowLevel() { return this._outflowLevel; }
|
||||||
|
get overflowLevel() { return this._overflowLevel; }
|
||||||
|
get surfaceArea() { return this._surfaceArea; }
|
||||||
|
get maxVol() { return this._maxVol; }
|
||||||
|
get maxVolAtOverflow() { return this._maxVolAtOverflow; }
|
||||||
|
get minVolAtInflow() { return this._minVolAtInflow; }
|
||||||
|
get minVolAtOutflow() { return this._minVolAtOutflow; }
|
||||||
|
get minVol() { return this._minVol; }
|
||||||
|
get minHeightBasedOn() { return this._minHeightBasedOn; }
|
||||||
|
|
||||||
|
/** Convert level (m from floor) → volume (m3). Negative levels clamp to 0. */
|
||||||
|
volumeFromLevel(level) {
|
||||||
|
return Math.max(level, 0) * this._surfaceArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert volume (m3) → level (m from floor). Negative volumes clamp to 0. */
|
||||||
|
levelFromVolume(volume) {
|
||||||
|
return Math.max(volume, 0) / this._surfaceArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-object snapshot mirroring the legacy `this.basin` shape so
|
||||||
|
* getOutput / status code can keep using the same field names without
|
||||||
|
* caring whether it's holding a class instance or a plain object.
|
||||||
|
*/
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
volEmptyBasin: this._volEmptyBasin,
|
||||||
|
heightBasin: this._heightBasin,
|
||||||
|
inflowLevel: this._inflowLevel,
|
||||||
|
outflowLevel: this._outflowLevel,
|
||||||
|
overflowLevel: this._overflowLevel,
|
||||||
|
surfaceArea: this._surfaceArea,
|
||||||
|
maxVol: this._maxVol,
|
||||||
|
maxVolAtOverflow: this._maxVolAtOverflow,
|
||||||
|
minVolAtInflow: this._minVolAtInflow,
|
||||||
|
minVolAtOutflow: this._minVolAtOutflow,
|
||||||
|
minVol: this._minVol,
|
||||||
|
minHeightBasedOn: this._minHeightBasedOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BasinGeometry;
|
||||||
57
src/basin/thresholdValidator.js
Normal file
57
src/basin/thresholdValidator.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Threshold-ordering validator for the pumpingStation basin + control +
|
||||||
|
// safety config. Pure: returns the issues array, never logs or throws.
|
||||||
|
// The caller decides what to do (warn, surface to status badge, fail tests).
|
||||||
|
//
|
||||||
|
// Invariants enforced (level-space, bottom → top):
|
||||||
|
// 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
|
// dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel
|
||||||
|
//
|
||||||
|
// dryRunLevel and overfillLevel are DERIVED from safety percentages — the
|
||||||
|
// validator recomputes them so a config that places minLevel below the
|
||||||
|
// effective dry-run trigger (a no-op control band) is caught here.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} basin - BasinGeometry instance OR plain {inflowLevel, outflowLevel, overflowLevel, heightBasin, minHeightBasedOn}
|
||||||
|
* @param {object} levelbased - config.control.levelbased ({ minLevel, startLevel, maxLevel })
|
||||||
|
* @param {object} safety - config.safety ({ dryRunThresholdPercent, overfillThresholdPercent })
|
||||||
|
* @returns {Array<{aName, a, op, bName, b, msg}>}
|
||||||
|
*/
|
||||||
|
function validateThresholdOrdering(basin, levelbased, safety) {
|
||||||
|
const lvl = levelbased || {};
|
||||||
|
const sfy = safety || {};
|
||||||
|
|
||||||
|
const dryRunPct = Number(sfy.dryRunThresholdPercent) || 0;
|
||||||
|
const overfillPct = Number(sfy.overfillThresholdPercent) || 100;
|
||||||
|
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
|
||||||
|
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
|
||||||
|
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
for (const [aName, a, op, bName, b] of checks) {
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
|
||||||
|
const ok = op === '<' ? a < b : a <= b;
|
||||||
|
if (!ok) {
|
||||||
|
issues.push({
|
||||||
|
aName,
|
||||||
|
a,
|
||||||
|
op,
|
||||||
|
bName,
|
||||||
|
b,
|
||||||
|
msg: `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateThresholdOrdering };
|
||||||
87
src/commands/handlers.js
Normal file
87
src/commands/handlers.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Handler functions for pumpingStation commands. Each handler receives:
|
||||||
|
// source: the domain (specificClass) instance — has the public methods
|
||||||
|
// (changeMode, calibratePredicted*, setManualInflow, ...).
|
||||||
|
// msg: the Node-RED input message.
|
||||||
|
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||||
|
//
|
||||||
|
// Handlers are pure functions: they don't keep state. Validation that goes
|
||||||
|
// beyond the registry's typeof-check ladder lives here.
|
||||||
|
|
||||||
|
function _logger(source, ctx) {
|
||||||
|
return ctx?.logger || source?.logger || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.setMode = (source, msg) => {
|
||||||
|
source.changeMode(msg.payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.registerChild = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const childId = msg.payload;
|
||||||
|
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
|
||||||
|
if (!childObj || !childObj.source) {
|
||||||
|
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.calibrateVolume = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const v = parseFloat(msg.payload);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
log?.warn?.(`cmd.calibrate.volume: non-numeric payload '${msg.payload}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.calibratePredictedVolume(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.calibrateLevel = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const v = parseFloat(msg.payload);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
log?.warn?.(`cmd.calibrate.level: non-numeric payload '${msg.payload}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.calibratePredictedLevel(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setInflow = (source, msg) => {
|
||||||
|
// Payload is either a number (legacy q_in shape) or
|
||||||
|
// { value, unit, timestamp } (richer object form).
|
||||||
|
const p = msg.payload;
|
||||||
|
let value;
|
||||||
|
let unit;
|
||||||
|
let timestamp;
|
||||||
|
if (p !== null && typeof p === 'object') {
|
||||||
|
value = Number(p.value);
|
||||||
|
unit = p.unit;
|
||||||
|
timestamp = p.timestamp || Date.now();
|
||||||
|
} else {
|
||||||
|
value = Number(p);
|
||||||
|
unit = msg?.unit;
|
||||||
|
timestamp = msg?.timestamp || Date.now();
|
||||||
|
}
|
||||||
|
source.setManualInflow(value, timestamp, unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDemand = (source, msg, ctx) => {
|
||||||
|
const log = _logger(source, ctx);
|
||||||
|
const demand = Number(msg.payload);
|
||||||
|
if (!Number.isFinite(demand)) {
|
||||||
|
log?.warn?.(`set.demand: invalid Qd value '${msg.payload}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source.mode !== 'manual') {
|
||||||
|
log?.debug?.(
|
||||||
|
`set.demand ignored in '${source.mode}' mode; switch to manual to use the demand slider`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// forwardDemandToChildren returns a promise — surface failures via logger.
|
||||||
|
Promise.resolve(source.forwardDemandToChildren(demand)).catch((err) => {
|
||||||
|
log?.error?.(`set.demand: failed to forward demand: ${err && err.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
50
src/commands/index.js
Normal file
50
src/commands/index.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// pumpingStation command registry. Consumed by BaseNodeAdapter via
|
||||||
|
// `static commands = require('./commands')`. Each descriptor maps a
|
||||||
|
// canonical msg.topic to its handler; legacy names are listed under
|
||||||
|
// `aliases` and emit a one-time deprecation warning at runtime.
|
||||||
|
|
||||||
|
const handlers = require('./handlers');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'set.mode',
|
||||||
|
aliases: ['changemode'],
|
||||||
|
payloadSchema: { type: 'string' },
|
||||||
|
handler: handlers.setMode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'child.register',
|
||||||
|
aliases: ['registerChild'],
|
||||||
|
// payload is the Node-RED id (string) of the child node.
|
||||||
|
payloadSchema: { type: 'string' },
|
||||||
|
handler: handlers.registerChild,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'cmd.calibrate.volume',
|
||||||
|
aliases: ['calibratePredictedVolume'],
|
||||||
|
// any: payload may be a number or numeric string.
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.calibrateVolume,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'cmd.calibrate.level',
|
||||||
|
aliases: ['calibratePredictedLevel'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.calibrateLevel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.inflow',
|
||||||
|
aliases: ['q_in'],
|
||||||
|
// any: number, numeric string, or { value, unit, timestamp } object.
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.setInflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.demand',
|
||||||
|
aliases: ['Qd'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.setDemand,
|
||||||
|
},
|
||||||
|
];
|
||||||
11
src/control/flowBased.js
Normal file
11
src/control/flowBased.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Placeholder — flow-based control mode is not yet implemented.
|
||||||
|
// The dispatcher routes here when config.control.mode === 'flowbased',
|
||||||
|
// at which point a real implementation should land in this file.
|
||||||
|
async function run(ctx) {
|
||||||
|
ctx?.logger?.debug?.('flow-based mode not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'flowbased',
|
||||||
|
run,
|
||||||
|
};
|
||||||
20
src/control/index.js
Normal file
20
src/control/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const levelBased = require('./levelBased');
|
||||||
|
const flowBased = require('./flowBased');
|
||||||
|
const manual = require('./manual');
|
||||||
|
|
||||||
|
const strategies = {
|
||||||
|
[levelBased.name]: levelBased,
|
||||||
|
[flowBased.name]: flowBased,
|
||||||
|
[manual.name]: manual,
|
||||||
|
};
|
||||||
|
|
||||||
|
function dispatch(mode, ctx, controlState) {
|
||||||
|
const s = strategies[mode];
|
||||||
|
if (!s) {
|
||||||
|
ctx.logger?.warn?.(`Unsupported control mode: ${mode}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return s.run(ctx, controlState);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { strategies, dispatch, manual };
|
||||||
92
src/control/levelBased.js
Normal file
92
src/control/levelBased.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const { interpolation } = require('generalFunctions');
|
||||||
|
|
||||||
|
const _interp = new interpolation();
|
||||||
|
|
||||||
|
// Maps [startLevel..maxLevel] → [0..100]. Outside the range,
|
||||||
|
// interpolate_lin_single_point clamps to o_min / o_max.
|
||||||
|
function _scaleLevelToFlowPercent(level, levelbased, logger) {
|
||||||
|
const { startLevel, maxLevel } = levelbased;
|
||||||
|
logger?.debug?.(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
|
||||||
|
return _interp.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
|
||||||
|
if (!machineGroups || Object.keys(machineGroups).length === 0) return;
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(machineGroups).map((group) =>
|
||||||
|
group.handleInput('parent', percentControl).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err.message}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _applyMachineLevelControl(machines, percentControl, logger) {
|
||||||
|
const filtered = Object.values(machines).filter((machine) => {
|
||||||
|
const pos = machine?.config?.functionality?.positionVsParent;
|
||||||
|
return (pos === 'downstream' || pos === 'atequipment');
|
||||||
|
});
|
||||||
|
if (!filtered.length) return;
|
||||||
|
|
||||||
|
const perMachine = percentControl / filtered.length;
|
||||||
|
for (const machine of filtered) {
|
||||||
|
try {
|
||||||
|
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||||
|
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error?.(`Failed to start machine "${machine.config?.general?.name}": ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pickVariant(measurements, type, variants, position, unit) {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const val = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||||
|
if (!Number.isFinite(val)) continue;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(ctx, controlState) {
|
||||||
|
const { measurements, config, logger, machineGroups, levelVariants } = ctx;
|
||||||
|
const { startLevel, minLevel } = config.control.levelbased;
|
||||||
|
const levelUnit = measurements.getUnit('level');
|
||||||
|
|
||||||
|
const variants = levelVariants || ['measured', 'predicted'];
|
||||||
|
const level = _pickVariant(measurements, 'level', variants, 'atequipment', levelUnit);
|
||||||
|
if (level == null) {
|
||||||
|
logger?.warn?.('No valid level found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three-zone level control:
|
||||||
|
// level < minLevel → STOP (unconditional MGC shutdown)
|
||||||
|
// minLevel ≤ level < startLevel → DEAD ZONE (no-op)
|
||||||
|
// level ≥ startLevel → RUN (linear ramp → MGC)
|
||||||
|
if (level < minLevel) {
|
||||||
|
controlState.percControl = 0;
|
||||||
|
Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level < startLevel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPercControl = _scaleLevelToFlowPercent(level, config.control.levelbased, logger);
|
||||||
|
const percControl = Math.max(0, rawPercControl);
|
||||||
|
controlState.percControl = percControl;
|
||||||
|
logger?.debug?.(`Level-based control: level=${level} percControl=${percControl}`);
|
||||||
|
|
||||||
|
await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'levelbased',
|
||||||
|
run,
|
||||||
|
// Exposed for future reuse / tests; not part of the strategy contract.
|
||||||
|
_scaleLevelToFlowPercent,
|
||||||
|
_applyMachineGroupLevelControl,
|
||||||
|
_applyMachineLevelControl,
|
||||||
|
};
|
||||||
36
src/control/manual.js
Normal file
36
src/control/manual.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
async function run() {
|
||||||
|
// No-op: manual mode is event-driven via set.demand → forwardDemand,
|
||||||
|
// not tick-driven.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardDemand(ctx, demand) {
|
||||||
|
const { machineGroups, machines, logger } = ctx;
|
||||||
|
logger?.info?.(`Manual demand forwarded: ${demand}`);
|
||||||
|
|
||||||
|
if (machineGroups && Object.keys(machineGroups).length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(machineGroups).map((group) =>
|
||||||
|
group.handleInput('parent', demand).catch((err) => {
|
||||||
|
logger?.error?.(`Failed to forward demand to group: ${err.message}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (machines && Object.keys(machines).length > 0) {
|
||||||
|
const perMachine = demand / Object.keys(machines).length;
|
||||||
|
for (const machine of Object.values(machines)) {
|
||||||
|
try {
|
||||||
|
await machine.handleInput('parent', 'execMovement', perMachine);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error?.(`Failed to forward demand to machine: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'manual',
|
||||||
|
run,
|
||||||
|
forwardDemand,
|
||||||
|
};
|
||||||
281
src/editor.js
Normal file
281
src/editor.js
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
(function () {
|
||||||
|
// Namespace declaration — Node-RED admin scripts share window state.
|
||||||
|
window.EVOLV = window.EVOLV || {};
|
||||||
|
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||||
|
window.EVOLV.nodes.pumpingStation = window.EVOLV.nodes.pumpingStation || {};
|
||||||
|
|
||||||
|
// SVG diagram constants — viewBox-coordinate top/bottom of the tank rect.
|
||||||
|
const DIAG = { topY: 40, botY: 380 };
|
||||||
|
|
||||||
|
const fNum = (id) => {
|
||||||
|
const v = parseFloat(document.getElementById(`node-input-${id}`)?.value);
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const yForLevel = (val, basinH) => {
|
||||||
|
if (val == null || !basinH) return null;
|
||||||
|
const y = DIAG.botY - (val / basinH) * (DIAG.botY - DIAG.topY);
|
||||||
|
return Math.max(DIAG.topY - 8, Math.min(DIAG.botY + 8, y));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Position a row — line, label, input, unit all share the same y.
|
||||||
|
const placeItem = (id, y) => {
|
||||||
|
const line = document.getElementById(`ps-line-${id}`);
|
||||||
|
const label = document.getElementById(`ps-label-${id}`);
|
||||||
|
const unit = document.getElementById(`ps-unit-${id}`);
|
||||||
|
const fo = document.getElementById(`ps-fo-${id}`);
|
||||||
|
const sub = document.getElementById(`ps-sub-${id}`);
|
||||||
|
const lead = document.getElementById(`ps-leader-${id}`);
|
||||||
|
if (line) { line.setAttribute('y1', y); line.setAttribute('y2', y); }
|
||||||
|
if (label) label.setAttribute('y', y + 4);
|
||||||
|
if (unit) unit.setAttribute('y', y + 4);
|
||||||
|
if (fo) fo.setAttribute('y', y - 11);
|
||||||
|
if (sub) sub.setAttribute('y', y + 15);
|
||||||
|
if (lead) lead.setAttribute('visibility', 'hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeZone = (zoneId, topId, botId, items) => {
|
||||||
|
const el = document.getElementById(`ps-zone-${zoneId}`);
|
||||||
|
if (!el) return;
|
||||||
|
const top = items.find(it => it.id === topId);
|
||||||
|
const bot = items.find(it => it.id === botId);
|
||||||
|
if (!top || !bot || (bot.y - top.y) < 14) {
|
||||||
|
el.setAttribute('visibility', 'hidden'); return;
|
||||||
|
}
|
||||||
|
el.setAttribute('y', (top.y + bot.y) / 2 + 3);
|
||||||
|
el.setAttribute('visibility', 'visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeStack = (basinH) => {
|
||||||
|
const basedOn = document.getElementById('node-input-minHeightBasedOn')?.value || 'outlet';
|
||||||
|
const refLow = basedOn === 'inlet' ? fNum('inflowLevel') : fNum('outflowLevel');
|
||||||
|
const dryPct = fNum('dryRunThresholdPercent');
|
||||||
|
const ovfPct = fNum('overfillThresholdPercent');
|
||||||
|
const ovf = fNum('overflowLevel');
|
||||||
|
const dryLvl = (refLow != null && dryPct != null) ? refLow * (1 + dryPct / 100) : null;
|
||||||
|
const ovfLvl = (ovf != null && ovfPct != null) ? ovf * (ovfPct / 100) : null;
|
||||||
|
|
||||||
|
// Right-column stack. TWO anchors: basinHeight at the rim (top),
|
||||||
|
// outflowLevel at its proportional y (bottom). Two passes nudge
|
||||||
|
// intermediate items by GAP so dashed lines keep their value-order.
|
||||||
|
const items = [
|
||||||
|
{ id: 'basinHeight', yIdeal: DIAG.topY, pinned: true },
|
||||||
|
{ id: 'overflowLevel', yIdeal: yForLevel(fNum('overflowLevel'), basinH) },
|
||||||
|
{ id: 'maxLevel', yIdeal: yForLevel(fNum('maxLevel'), basinH) },
|
||||||
|
{ id: 'startLevel', yIdeal: yForLevel(fNum('startLevel'), basinH) },
|
||||||
|
{ id: 'minLevel', yIdeal: yForLevel(fNum('minLevel'), basinH) },
|
||||||
|
{ id: 'dryRunLevel', yIdeal: yForLevel(dryLvl, basinH) },
|
||||||
|
{ id: 'outflowLevel', yIdeal: yForLevel(fNum('outflowLevel'), basinH), pinned: true },
|
||||||
|
].filter(it => it.yIdeal != null);
|
||||||
|
|
||||||
|
const GAP = 36;
|
||||||
|
items.sort((a, b) => a.yIdeal - b.yIdeal);
|
||||||
|
for (const it of items) it.y = it.yIdeal;
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
if (items[i].pinned) continue;
|
||||||
|
items[i].y = Math.max(items[i].y, items[i - 1].y + GAP);
|
||||||
|
}
|
||||||
|
for (let i = items.length - 2; i >= 0; i--) {
|
||||||
|
if (items[i].pinned) continue;
|
||||||
|
items[i].y = Math.min(items[i].y, items[i + 1].y - GAP);
|
||||||
|
}
|
||||||
|
return { items, dryLvl, ovfLvl };
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawInflow = (basinH) => {
|
||||||
|
const inflowY = yForLevel(fNum('inflowLevel'), basinH);
|
||||||
|
if (inflowY == null) return;
|
||||||
|
const line = document.getElementById('ps-line-inflowLevel');
|
||||||
|
const lbl = document.getElementById('ps-label-inflowLevel');
|
||||||
|
const sub = document.getElementById('ps-sub-inflowLevel');
|
||||||
|
const fo = document.getElementById('ps-fo-inflowLevel');
|
||||||
|
const unit = document.getElementById('ps-unit-inflowLevel');
|
||||||
|
if (line) { line.setAttribute('y1', inflowY); line.setAttribute('y2', inflowY); }
|
||||||
|
if (lbl) lbl.setAttribute('y', inflowY - 4);
|
||||||
|
if (sub) sub.setAttribute('y', inflowY + 8);
|
||||||
|
if (fo) fo.setAttribute('y', inflowY - 11);
|
||||||
|
if (unit) unit.setAttribute('y', inflowY + 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawOrderingWarning = () => {
|
||||||
|
const warn = document.getElementById('ps-warning');
|
||||||
|
if (!warn) return;
|
||||||
|
const issues = [];
|
||||||
|
const pairs = [
|
||||||
|
['outflowLevel', 'inflowLevel', '<'],
|
||||||
|
['inflowLevel', 'overflowLevel', '<'],
|
||||||
|
['minLevel', 'startLevel', '<='],
|
||||||
|
['startLevel', 'maxLevel', '<'],
|
||||||
|
['maxLevel', 'overflowLevel', '<='],
|
||||||
|
];
|
||||||
|
for (const [a, b, op] of pairs) {
|
||||||
|
const av = fNum(a), bv = fNum(b);
|
||||||
|
if (av == null || bv == null) continue;
|
||||||
|
if (op === '<' ? !(av < bv) : !(av <= bv)) issues.push(`${a} ${op} ${b}`);
|
||||||
|
}
|
||||||
|
if (issues.length) {
|
||||||
|
warn.setAttribute('visibility', 'visible');
|
||||||
|
warn.textContent = `⚠ Check ordering: ${issues.join(', ')}`;
|
||||||
|
} else {
|
||||||
|
warn.setAttribute('visibility', 'hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const redraw = () => {
|
||||||
|
const basinH = fNum('basinHeight') || 5;
|
||||||
|
const { items, dryLvl, ovfLvl } = computeStack(basinH);
|
||||||
|
for (const it of items) placeItem(it.id, it.y);
|
||||||
|
|
||||||
|
placeZone('spare', 'overflowLevel', 'maxLevel', items);
|
||||||
|
placeZone('sewage', 'maxLevel', 'startLevel', items);
|
||||||
|
placeZone('buffer1', 'startLevel', 'minLevel', items);
|
||||||
|
placeZone('buffer2', 'minLevel', 'dryRunLevel', items);
|
||||||
|
|
||||||
|
// "Dead volume" sits inside the blue band between outflowLevel and the floor.
|
||||||
|
const outflowPinned = items.find(it => it.id === 'outflowLevel');
|
||||||
|
const deadLbl = document.getElementById('ps-zone-dead');
|
||||||
|
if (deadLbl && outflowPinned && (DIAG.botY - outflowPinned.y) > 14) {
|
||||||
|
deadLbl.setAttribute('y', (outflowPinned.y + DIAG.botY) / 2 + 3);
|
||||||
|
deadLbl.setAttribute('visibility', 'visible');
|
||||||
|
} else if (deadLbl) {
|
||||||
|
deadLbl.setAttribute('visibility', 'hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
drawInflow(basinH);
|
||||||
|
|
||||||
|
// Dead-volume band: from the (possibly nudged) outflow line down to the floor.
|
||||||
|
const outflowItem = items.find(it => it.id === 'outflowLevel');
|
||||||
|
const deadvol = document.getElementById('ps-deadvol');
|
||||||
|
if (deadvol && outflowItem) {
|
||||||
|
deadvol.setAttribute('y', outflowItem.y);
|
||||||
|
deadvol.setAttribute('height', Math.max(0, DIAG.botY - outflowItem.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dryLbl = document.getElementById('ps-label-dryRunLevel');
|
||||||
|
if (dryLbl) dryLbl.textContent = dryLvl != null
|
||||||
|
? `dryRunLevel ≈ ${dryLvl.toFixed(2)} m (safety — from %)`
|
||||||
|
: 'dryRunLevel ≈ — m (safety — from %)';
|
||||||
|
|
||||||
|
const d1 = document.getElementById('derived-dryRunLevel');
|
||||||
|
if (d1) d1.textContent = dryLvl != null ? `→ dryRunLevel ≈ ${dryLvl.toFixed(2)} m` : '→ dryRunLevel ≈ — m';
|
||||||
|
const d2 = document.getElementById('derived-overfillLevel');
|
||||||
|
if (d2) d2.textContent = ovfLvl != null ? `→ overfillLevel ≈ ${ovfLvl.toFixed(2)} m` : '→ overfillLevel ≈ — m';
|
||||||
|
|
||||||
|
drawOrderingWarning();
|
||||||
|
};
|
||||||
|
|
||||||
|
const wireProtectionToggle = (toggleEl, inputEl) => {
|
||||||
|
if (!toggleEl || !inputEl) return;
|
||||||
|
const apply = () => {
|
||||||
|
inputEl.disabled = !toggleEl.checked;
|
||||||
|
inputEl.parentElement.classList.toggle('disabled', inputEl.disabled);
|
||||||
|
};
|
||||||
|
toggleEl.addEventListener('change', apply);
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleModeSections = (val) => {
|
||||||
|
document.querySelectorAll('.ps-mode-section').forEach((el) => el.style.display = 'none');
|
||||||
|
const active = document.getElementById(`ps-mode-${val}`);
|
||||||
|
if (active) active.style.display = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNumberField = (id, val) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = Number.isFinite(val) ? val : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = {
|
||||||
|
init(node) {
|
||||||
|
// Defer asset/menu init until shared menu data is loaded.
|
||||||
|
const waitForMenuData = () => {
|
||||||
|
if (window.EVOLV?.nodes?.pumpingStation?.initEditor) {
|
||||||
|
window.EVOLV.nodes.pumpingStation.initEditor(node);
|
||||||
|
} else {
|
||||||
|
setTimeout(waitForMenuData, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForMenuData();
|
||||||
|
|
||||||
|
const refHeightEl = document.getElementById('node-input-refHeight');
|
||||||
|
if (refHeightEl) refHeightEl.value = node.refHeight || 'NAP';
|
||||||
|
|
||||||
|
const minHeightBasedOnEl = document.getElementById('node-input-minHeightBasedOn');
|
||||||
|
if (minHeightBasedOnEl) minHeightBasedOnEl.value = node.minHeightBasedOn;
|
||||||
|
|
||||||
|
const dryRunToggle = document.getElementById('node-input-enableDryRunProtection');
|
||||||
|
const dryRunPercent = document.getElementById('node-input-dryRunThresholdPercent');
|
||||||
|
const overfillToggle = document.getElementById('node-input-enableOverfillProtection');
|
||||||
|
const overfillPercent = document.getElementById('node-input-overfillThresholdPercent');
|
||||||
|
|
||||||
|
if (dryRunToggle && dryRunPercent) {
|
||||||
|
dryRunToggle.checked = !!node.enableDryRunProtection;
|
||||||
|
dryRunPercent.value = Number.isFinite(node.dryRunThresholdPercent) ? node.dryRunThresholdPercent : 2;
|
||||||
|
wireProtectionToggle(dryRunToggle, dryRunPercent);
|
||||||
|
}
|
||||||
|
if (overfillToggle && overfillPercent) {
|
||||||
|
overfillToggle.checked = !!node.enableOverfillProtection;
|
||||||
|
overfillPercent.value = Number.isFinite(node.overfillThresholdPercent) ? node.overfillThresholdPercent : 98;
|
||||||
|
wireProtectionToggle(overfillToggle, overfillPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeLeftInput = document.getElementById('node-input-timeleftToFullOrEmptyThresholdSeconds');
|
||||||
|
if (timeLeftInput) {
|
||||||
|
timeLeftInput.value = Number.isFinite(node.timeleftToFullOrEmptyThresholdSeconds)
|
||||||
|
? node.timeleftToFullOrEmptyThresholdSeconds
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeSelect = document.getElementById('node-input-controlMode');
|
||||||
|
if (modeSelect) {
|
||||||
|
modeSelect.value = node.controlMode || 'none';
|
||||||
|
toggleModeSections(modeSelect.value);
|
||||||
|
modeSelect.addEventListener('change', (e) => toggleModeSections(e.target.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
setNumberField('node-input-startLevel', node.startLevel);
|
||||||
|
setNumberField('node-input-minLevel', node.minLevel);
|
||||||
|
setNumberField('node-input-maxLevel', node.maxLevel);
|
||||||
|
setNumberField('node-input-flowSetpoint', node.flowSetpoint);
|
||||||
|
setNumberField('node-input-flowDeadband', node.flowDeadband);
|
||||||
|
|
||||||
|
const watched = ['basinHeight','overflowLevel','maxLevel','startLevel','minLevel','inflowLevel','outflowLevel',
|
||||||
|
'dryRunThresholdPercent','overfillThresholdPercent','minHeightBasedOn'];
|
||||||
|
for (const id of watched) {
|
||||||
|
const el = document.getElementById(`node-input-${id}`);
|
||||||
|
if (el) { el.addEventListener('input', redraw); el.addEventListener('change', redraw); }
|
||||||
|
}
|
||||||
|
setTimeout(redraw, 60);
|
||||||
|
},
|
||||||
|
|
||||||
|
save(node) {
|
||||||
|
node.refHeight = document.getElementById('node-input-refHeight').value || 'NAP';
|
||||||
|
node.minHeightBasedOn = document.getElementById('node-input-minHeightBasedOn').value || 'outlet';
|
||||||
|
node.simulator = document.getElementById('node-input-simulator').checked;
|
||||||
|
|
||||||
|
const numericFields = ['basinVolume','basinHeight','inflowLevel','outflowLevel','overflowLevel',
|
||||||
|
'basinBottomRef','timeleftToFullOrEmptyThresholdSeconds',
|
||||||
|
'dryRunThresholdPercent','overfillThresholdPercent'];
|
||||||
|
for (const field of numericFields) {
|
||||||
|
node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original code reassigned refHeight here with default '' instead of 'NAP'.
|
||||||
|
// Preserve that behaviour byte-for-byte so saved node JSON is identical.
|
||||||
|
node.refHeight = document.getElementById('node-input-refHeight').value || '';
|
||||||
|
node.enableDryRunProtection = document.getElementById('node-input-enableDryRunProtection').checked;
|
||||||
|
node.enableOverfillProtection = document.getElementById('node-input-enableOverfillProtection').checked;
|
||||||
|
|
||||||
|
node.controlMode = document.getElementById('node-input-controlMode').value || 'none';
|
||||||
|
|
||||||
|
const parseNum = (id) => parseFloat(document.getElementById(id)?.value);
|
||||||
|
node.startLevel = parseNum('node-input-startLevel');
|
||||||
|
node.minLevel = parseNum('node-input-minLevel');
|
||||||
|
node.maxLevel = parseNum('node-input-maxLevel');
|
||||||
|
node.flowSetpoint = parseNum('node-input-flowSetpoint');
|
||||||
|
node.flowDeadband = parseNum('node-input-flowDeadband');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.EVOLV.nodes.pumpingStation.editor = editor;
|
||||||
|
})();
|
||||||
80
src/measurement/calibration.js
Normal file
80
src/measurement/calibration.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Calibration helpers for the pumping-station predicted volume / level
|
||||||
|
// streams. Pure functions over a context bag holding the live
|
||||||
|
// MeasurementContainer + basin geometry. After every calibration the
|
||||||
|
// integrator state is reset so the next tick starts from the new anchor.
|
||||||
|
|
||||||
|
function _resetFlowState(ctx, timestamp) {
|
||||||
|
if (ctx.flowAggregator?.resetState) {
|
||||||
|
ctx.flowAggregator.resetState(timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearSeries(measurements, type) {
|
||||||
|
const series = measurements.type(type).variant('predicted').position('atequipment');
|
||||||
|
if (series.exists()) {
|
||||||
|
const m = series.get();
|
||||||
|
if (m) {
|
||||||
|
m.values = [];
|
||||||
|
m.timestamps = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _levelFromVolume(basin, volume) {
|
||||||
|
const area = basin.surfaceArea;
|
||||||
|
return area > 0 ? Math.max(volume, 0) / area : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _volumeFromLevel(basin, level) {
|
||||||
|
const area = basin.surfaceArea;
|
||||||
|
return area > 0 ? Math.max(level, 0) * area : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calibratePredictedVolume(ctx, calibratedVol, timestamp = Date.now()) {
|
||||||
|
if (!ctx?.measurements || !ctx.basin) {
|
||||||
|
throw new Error('calibratePredictedVolume: ctx.measurements and ctx.basin required');
|
||||||
|
}
|
||||||
|
const { measurements, basin } = ctx;
|
||||||
|
|
||||||
|
_clearSeries(measurements, 'volume');
|
||||||
|
_clearSeries(measurements, 'level');
|
||||||
|
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(calibratedVol, timestamp, 'm3').unit('m3');
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(_levelFromVolume(basin, calibratedVol), timestamp, 'm');
|
||||||
|
|
||||||
|
_resetFlowState(ctx, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calibratePredictedLevel(ctx, level, timestamp = Date.now(), unit = 'm') {
|
||||||
|
if (!ctx?.measurements || !ctx.basin) {
|
||||||
|
throw new Error('calibratePredictedLevel: ctx.measurements and ctx.basin required');
|
||||||
|
}
|
||||||
|
const { measurements, basin } = ctx;
|
||||||
|
|
||||||
|
_clearSeries(measurements, 'volume');
|
||||||
|
_clearSeries(measurements, 'level');
|
||||||
|
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(level, timestamp, unit);
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(_volumeFromLevel(basin, level), timestamp, 'm3');
|
||||||
|
|
||||||
|
_resetFlowState(ctx, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setManualInflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
||||||
|
if (!ctx?.measurements) throw new Error('setManualInflow: ctx.measurements required');
|
||||||
|
const num = Number(value);
|
||||||
|
ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin')
|
||||||
|
.value(num, timestamp, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calibratePredictedVolume,
|
||||||
|
calibratePredictedLevel,
|
||||||
|
setManualInflow,
|
||||||
|
};
|
||||||
180
src/measurement/flowAggregator.js
Normal file
180
src/measurement/flowAggregator.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// FlowAggregator — owns the predicted-volume integrator + net-flow selection
|
||||||
|
// + remaining-time projection for the pumping-station basin.
|
||||||
|
//
|
||||||
|
// Pure domain. Takes a context bag with the live MeasurementContainer, the
|
||||||
|
// basin geometry, and the merged config; mutates measurements in place and
|
||||||
|
// keeps a tiny piece of integrator state internally.
|
||||||
|
|
||||||
|
const { interpolation } = require('generalFunctions');
|
||||||
|
|
||||||
|
const DEFAULT_FLOW_THRESHOLD = 1e-4;
|
||||||
|
const DEFAULT_FLOW_VARIANTS = ['measured', 'predicted'];
|
||||||
|
const DEFAULT_LEVEL_VARIANTS = ['measured', 'predicted'];
|
||||||
|
const DEFAULT_FLOW_POSITIONS = {
|
||||||
|
inflow: ['in', 'upstream'],
|
||||||
|
outflow: ['out', 'downstream'],
|
||||||
|
};
|
||||||
|
|
||||||
|
class FlowAggregator {
|
||||||
|
constructor(ctx = {}) {
|
||||||
|
if (!ctx.measurements) throw new Error('FlowAggregator: ctx.measurements is required');
|
||||||
|
if (!ctx.basin) throw new Error('FlowAggregator: ctx.basin is required');
|
||||||
|
|
||||||
|
this.measurements = ctx.measurements;
|
||||||
|
this.basin = ctx.basin;
|
||||||
|
this.config = ctx.config || {};
|
||||||
|
this.logger = ctx.logger || null;
|
||||||
|
this._interp = ctx.interpolation || new interpolation();
|
||||||
|
|
||||||
|
this.flowVariants = ctx.flowVariants || DEFAULT_FLOW_VARIANTS;
|
||||||
|
this.levelVariants = ctx.levelVariants || DEFAULT_LEVEL_VARIANTS;
|
||||||
|
this.flowPositions = ctx.flowPositions || DEFAULT_FLOW_POSITIONS;
|
||||||
|
|
||||||
|
const cfgThresh = Number(this.config?.general?.flowThreshold);
|
||||||
|
this.flowThreshold = Number.isFinite(ctx.flowThreshold)
|
||||||
|
? ctx.flowThreshold
|
||||||
|
: (Number.isFinite(cfgThresh) ? cfgThresh : DEFAULT_FLOW_THRESHOLD);
|
||||||
|
|
||||||
|
this._predictedFlowState = null;
|
||||||
|
this._lastNetFlow = { value: 0, source: null, direction: 'steady' };
|
||||||
|
this._lastRemaining = { seconds: null, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
resetState(timestamp = Date.now()) {
|
||||||
|
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const flowUnit = 'm3/s';
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||||
|
const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||||
|
|
||||||
|
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow, lastTimestamp: now };
|
||||||
|
|
||||||
|
const tPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||||||
|
const dt = Math.max((now - tPrev) / 1000, 0);
|
||||||
|
const dV = dt > 0 ? (inflow - outflow) * dt : 0;
|
||||||
|
|
||||||
|
const volSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||||
|
const currentVol = volSeries.getCurrentValue('m3');
|
||||||
|
const nextVol = (currentVol ?? this.basin.minVol ?? 0) + dV;
|
||||||
|
const writeTs = tPrev + dt * 1000;
|
||||||
|
|
||||||
|
volSeries.value(nextVol, writeTs, 'm3').unit('m3');
|
||||||
|
|
||||||
|
const surfaceArea = this.basin.surfaceArea;
|
||||||
|
const nextLevel = surfaceArea > 0 ? Math.max(nextVol, 0) / surfaceArea : 0;
|
||||||
|
this.measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(nextLevel, writeTs, 'm').unit('m');
|
||||||
|
|
||||||
|
const percent = this._interp.interpolate_lin_single_point(
|
||||||
|
nextVol, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||||
|
);
|
||||||
|
this.measurements.type('volumePercent').variant('predicted').position('atequipment')
|
||||||
|
.value(percent, writeTs, '%');
|
||||||
|
|
||||||
|
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTs };
|
||||||
|
}
|
||||||
|
|
||||||
|
selectBestNetFlow() {
|
||||||
|
const type = 'flow';
|
||||||
|
const unit = this.measurements.getUnit(type) || 'm3/s';
|
||||||
|
|
||||||
|
for (const variant of this.flowVariants) {
|
||||||
|
const bucket = this.measurements.measurements?.[type]?.[variant];
|
||||||
|
if (!bucket || Object.keys(bucket).length === 0) continue;
|
||||||
|
|
||||||
|
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
|
||||||
|
const outflow = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
|
||||||
|
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
|
||||||
|
|
||||||
|
const net = inflow - outflow;
|
||||||
|
this.measurements.type('netFlowRate').variant(variant).position('atequipment')
|
||||||
|
.value(net, Date.now(), unit);
|
||||||
|
const result = { value: net, source: variant, direction: this.deriveDirection(net) };
|
||||||
|
this._lastNetFlow = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of this.levelVariants) {
|
||||||
|
const rate = this._levelRate(variant);
|
||||||
|
if (!Number.isFinite(rate)) continue;
|
||||||
|
const net = rate * this.basin.surfaceArea;
|
||||||
|
const result = { value: net, source: `level:${variant}`, direction: this.deriveDirection(net) };
|
||||||
|
this._lastNetFlow = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.logger) this.logger.warn('No usable measurements to compute net flow; assuming steady.');
|
||||||
|
const result = { value: 0, source: null, direction: 'steady' };
|
||||||
|
this._lastNetFlow = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeRemainingTime(netFlow) {
|
||||||
|
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) {
|
||||||
|
this._lastRemaining = { seconds: null, source: null };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
|
||||||
|
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) {
|
||||||
|
this._lastRemaining = { seconds: null, source: null };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of this.levelVariants) {
|
||||||
|
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
|
||||||
|
if (!Number.isFinite(lvl)) continue;
|
||||||
|
|
||||||
|
const remainingHeight = netFlow.value > 0
|
||||||
|
? Math.max(overflowLevel - lvl, 0)
|
||||||
|
: Math.max(lvl - outflowLevel, 0);
|
||||||
|
const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value);
|
||||||
|
if (!Number.isFinite(seconds)) continue;
|
||||||
|
|
||||||
|
this._lastRemaining = { seconds, source: `${netFlow.source}/${variant}` };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastRemaining = { seconds: null, source: netFlow.source };
|
||||||
|
return this._lastRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
deriveDirection(netFlow) {
|
||||||
|
if (netFlow > this.flowThreshold) return 'filling';
|
||||||
|
if (netFlow < -this.flowThreshold) return 'draining';
|
||||||
|
return 'steady';
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
this.update();
|
||||||
|
const netFlow = this.selectBestNetFlow();
|
||||||
|
const remaining = this.computeRemainingTime(netFlow);
|
||||||
|
return { netFlow, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
direction: this._lastNetFlow.direction,
|
||||||
|
netFlow: this._lastNetFlow.value,
|
||||||
|
flowSource: this._lastNetFlow.source,
|
||||||
|
secondsRemaining: this._lastRemaining.seconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_levelRate(variant) {
|
||||||
|
const m = this.measurements.type('level').variant(variant).position('atequipment').get();
|
||||||
|
if (!m || !m.values || m.values.length < 2) return null;
|
||||||
|
const current = m.getLaggedSample?.(0);
|
||||||
|
const previous = m.getLaggedSample?.(1);
|
||||||
|
if (!current || !previous || previous.timestamp == null) return null;
|
||||||
|
const dt = (current.timestamp - previous.timestamp) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) return null;
|
||||||
|
return (current.value - previous.value) / dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FlowAggregator;
|
||||||
82
src/measurement/measurementRouter.js
Normal file
82
src/measurement/measurementRouter.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// MeasurementRouter — dispatches incoming measurement updates by type and
|
||||||
|
// derives downstream measurements (volume from level, predicted level from
|
||||||
|
// pressure). Pure domain over a context bag; no Node-RED dependency.
|
||||||
|
|
||||||
|
const { coolprop, interpolation } = require('generalFunctions');
|
||||||
|
|
||||||
|
const G = 9.80665;
|
||||||
|
const ASSUMED_TEMPERATURE_C = 15;
|
||||||
|
const ATMOSPHERIC_PRESSURE_PA = 101325;
|
||||||
|
|
||||||
|
class MeasurementRouter {
|
||||||
|
constructor(ctx = {}) {
|
||||||
|
if (!ctx.measurements) throw new Error('MeasurementRouter: ctx.measurements is required');
|
||||||
|
if (!ctx.basin) throw new Error('MeasurementRouter: ctx.basin is required');
|
||||||
|
|
||||||
|
this.measurements = ctx.measurements;
|
||||||
|
this.basin = ctx.basin;
|
||||||
|
this.logger = ctx.logger || null;
|
||||||
|
this._interp = ctx.interpolation || new interpolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
route(measurementType, value, position, eventData = {}) {
|
||||||
|
switch (measurementType) {
|
||||||
|
case 'level':
|
||||||
|
this.onLevelMeasurement(position, value, eventData);
|
||||||
|
return true;
|
||||||
|
case 'pressure':
|
||||||
|
this.onPressureMeasurement(position, value, eventData);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLevelMeasurement(position, value, context = {}) {
|
||||||
|
this.measurements.type('level').variant('measured').position(position)
|
||||||
|
.value(value).unit(context.unit);
|
||||||
|
|
||||||
|
const series = this.measurements.type('level').variant('measured').position(position);
|
||||||
|
const levelMeters = series.getCurrentValue('m');
|
||||||
|
if (levelMeters == null) return;
|
||||||
|
|
||||||
|
const surfaceArea = this.basin.surfaceArea;
|
||||||
|
const volume = surfaceArea > 0 ? Math.max(levelMeters, 0) * surfaceArea : 0;
|
||||||
|
const percent = this._interp.interpolate_lin_single_point(
|
||||||
|
volume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||||
|
);
|
||||||
|
|
||||||
|
this.measurements.type('volume').variant('measured').position('atequipment')
|
||||||
|
.value(volume, context.timestamp, 'm3');
|
||||||
|
this.measurements.type('volumePercent').variant('measured').position('atequipment')
|
||||||
|
.value(percent, context.timestamp, '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressureMeasurement(position, value, context = {}) {
|
||||||
|
let kelvin = this.measurements
|
||||||
|
.type('temperature').variant('measured').position('atequipment')
|
||||||
|
.getCurrentValue('K') ?? null;
|
||||||
|
|
||||||
|
if (kelvin === null) {
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
|
||||||
|
}
|
||||||
|
this.measurements.type('temperature').variant('assumed').position('atequipment')
|
||||||
|
.value(ASSUMED_TEMPERATURE_C, Date.now(), 'C');
|
||||||
|
kelvin = this.measurements.type('temperature').variant('assumed').position('atequipment')
|
||||||
|
.getCurrentValue('K');
|
||||||
|
}
|
||||||
|
if (kelvin == null) return;
|
||||||
|
|
||||||
|
const density = coolprop.PropsSI('D', 'T', kelvin, 'P', ATMOSPHERIC_PRESSURE_PA, 'Water');
|
||||||
|
const pressurePa = this.measurements.type('pressure').variant('measured').position(position)
|
||||||
|
.getCurrentValue('Pa');
|
||||||
|
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
|
||||||
|
|
||||||
|
const level = pressurePa / (density * G);
|
||||||
|
this.measurements.type('level').variant('predicted').position(position)
|
||||||
|
.value(level, context.timestamp, 'm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MeasurementRouter;
|
||||||
257
src/nodeClass.js
257
src/nodeClass.js
@@ -1,46 +1,16 @@
|
|||||||
|
const { BaseNodeAdapter } = require('generalFunctions');
|
||||||
|
const PumpingStation = require('./specificClass');
|
||||||
|
const commands = require('./commands');
|
||||||
|
|
||||||
const { outputUtils, configManager } = require('generalFunctions');
|
class nodeClass extends BaseNodeAdapter {
|
||||||
const Specific = require("./specificClass");
|
static DomainClass = PumpingStation;
|
||||||
|
static commands = commands;
|
||||||
|
// Tick-driven: predicted-volume integrator needs delta-time per second.
|
||||||
|
static tickInterval = 1000;
|
||||||
|
static statusInterval = 1000;
|
||||||
|
|
||||||
class nodeClass {
|
buildDomainConfig(uiConfig) {
|
||||||
/**
|
return {
|
||||||
* 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: {
|
basin: {
|
||||||
volume: uiConfig.basinVolume,
|
volume: uiConfig.basinVolume,
|
||||||
height: uiConfig.basinHeight,
|
height: uiConfig.basinHeight,
|
||||||
@@ -53,209 +23,22 @@ class nodeClass {
|
|||||||
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
minHeightBasedOn: uiConfig.minHeightBasedOn,
|
||||||
basinBottomRef: uiConfig.basinBottomRef,
|
basinBottomRef: uiConfig.basinBottomRef,
|
||||||
},
|
},
|
||||||
control:{
|
control: {
|
||||||
mode: uiConfig.controlMode,
|
mode: uiConfig.controlMode,
|
||||||
levelbased:{
|
levelbased: {
|
||||||
minLevel:uiConfig.minLevel,
|
minLevel: uiConfig.minLevel,
|
||||||
startLevel:uiConfig.startLevel,
|
startLevel: uiConfig.startLevel,
|
||||||
maxLevel:uiConfig.maxLevel
|
maxLevel: uiConfig.maxLevel,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
safety:{
|
safety: {
|
||||||
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
enableDryRunProtection: uiConfig.enableDryRunProtection,
|
||||||
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
dryRunThresholdPercent: uiConfig.dryRunThresholdPercent,
|
||||||
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
enableOverfillProtection: uiConfig.enableOverfillProtection,
|
||||||
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
overfillThresholdPercent: uiConfig.overfillThresholdPercent,
|
||||||
timeleftToFullOrEmptyThresholdSeconds: uiConfig.timeleftToFullOrEmptyThresholdSeconds
|
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?.maxVolAtOverflow ?? 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
this.node.status({}); // clear node status badge
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
153
src/safety/safetyController.js
Normal file
153
src/safety/safetyController.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
// Safety controller for the pumping-station basin.
|
||||||
|
//
|
||||||
|
// Two hard rules, applied independently every tick:
|
||||||
|
//
|
||||||
|
// 1. DRY-RUN (volume below minVol while draining): pumps must stop.
|
||||||
|
// Shuts down all DOWNSTREAM machines + machine groups + child
|
||||||
|
// stations. Sets blocked=true so the orchestrator skips control
|
||||||
|
// logic — only a manual override or estop can restart pumps.
|
||||||
|
//
|
||||||
|
// 2. OVERFILL (volume above overflow level while filling): pumps must
|
||||||
|
// keep running. Shuts down UPSTREAM equipment only (stop more water
|
||||||
|
// coming in) and child stations. Does NOT touch machine groups or
|
||||||
|
// downstream pumps — they must keep draining. blocked stays false
|
||||||
|
// so level-based control keeps demanding maximum throughput.
|
||||||
|
//
|
||||||
|
// A third path: if no volume reading is available, panic — shut down
|
||||||
|
// every machine and block control.
|
||||||
|
|
||||||
|
function pickVariant(measurements, type, variants, position, unit) {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const v = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
|
||||||
|
if (Number.isFinite(v)) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SafetyController {
|
||||||
|
/**
|
||||||
|
* @param {object} ctx
|
||||||
|
* @param {object} ctx.measurements MeasurementContainer-like instance
|
||||||
|
* @param {object} ctx.basin BasinGeometry snapshot ({maxVolAtOverflow, minVol, ...})
|
||||||
|
* @param {object} ctx.config pumpingStation config (uses .safety subtree)
|
||||||
|
* @param {object} ctx.logger generalFunctions logger
|
||||||
|
* @param {object} ctx.machines map of childId → rotatingMachine
|
||||||
|
* @param {object} ctx.stations map of childId → child pumpingStation
|
||||||
|
* @param {object} ctx.machineGroups map of childId → machineGroupControl
|
||||||
|
* @param {string[]} [ctx.volVariants] order of volume variants to try
|
||||||
|
*/
|
||||||
|
constructor(ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.volVariants = ctx.volVariants || ['measured', 'predicted'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the dry-run + overfill rules against the current measurement state.
|
||||||
|
*
|
||||||
|
* @param {object} flowSnapshot { direction: 'filling'|'draining'|'steady',
|
||||||
|
* secondsRemaining: number|null }
|
||||||
|
* @returns {{blocked:boolean, reason:string|null, triggered:string[]}}
|
||||||
|
*/
|
||||||
|
evaluate(flowSnapshot) {
|
||||||
|
const { measurements, basin, config, logger, machines } = this.ctx;
|
||||||
|
const direction = flowSnapshot?.direction ?? 'steady';
|
||||||
|
const secondsRemaining = flowSnapshot?.secondsRemaining ?? null;
|
||||||
|
|
||||||
|
const volUnit = measurements.getUnit('volume');
|
||||||
|
const vol = pickVariant(measurements, 'volume', this.volVariants, 'atequipment', volUnit);
|
||||||
|
|
||||||
|
if (vol == null) {
|
||||||
|
Object.values(machines).forEach((m) => m.handleInput('parent', 'execSequence', 'shutdown'));
|
||||||
|
logger.warn('No volume data available to safe guard system; shutting down all machines.');
|
||||||
|
return { blocked: true, reason: 'no-volume-data', triggered: ['no-volume-data'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggered = [];
|
||||||
|
let blocked = false;
|
||||||
|
let reason = null;
|
||||||
|
|
||||||
|
const dry = this._dryRunRule(vol, direction, secondsRemaining);
|
||||||
|
if (dry.triggered) {
|
||||||
|
this._shutdownDownstream(vol, secondsRemaining);
|
||||||
|
blocked = true;
|
||||||
|
reason = 'dry-run';
|
||||||
|
triggered.push(...dry.flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
const over = this._overfillRule(vol, direction, secondsRemaining);
|
||||||
|
if (over.triggered) {
|
||||||
|
this._shutdownUpstream(vol, secondsRemaining);
|
||||||
|
// Overfill never sets blocked — control keeps running.
|
||||||
|
if (reason == null) reason = 'overfill';
|
||||||
|
triggered.push(...over.flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocked, reason, triggered };
|
||||||
|
}
|
||||||
|
|
||||||
|
_safetyConfig() {
|
||||||
|
return this.ctx.config.safety || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
_dryRunRule(vol, direction, secondsRemaining) {
|
||||||
|
if (direction !== 'draining') return { triggered: false, flags: [] };
|
||||||
|
const s = this._safetyConfig();
|
||||||
|
const dryRunEnabled = Boolean(s.enableDryRunProtection);
|
||||||
|
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||||
|
const triggerLowVol = this.ctx.basin.minVol * (1 + ((Number(s.dryRunThresholdPercent) || 0) / 100));
|
||||||
|
|
||||||
|
const flags = [];
|
||||||
|
if (dryRunEnabled && vol < triggerLowVol) flags.push('dry-run-volume');
|
||||||
|
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||||
|
flags.push('time-remaining');
|
||||||
|
}
|
||||||
|
return { triggered: flags.length > 0, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
_overfillRule(vol, direction, secondsRemaining) {
|
||||||
|
if (direction !== 'filling') return { triggered: false, flags: [] };
|
||||||
|
const s = this._safetyConfig();
|
||||||
|
const overfillEnabled = Boolean(s.enableOverfillProtection);
|
||||||
|
const timeProtectionEnabled = s.timeleftToFullOrEmptyThresholdSeconds > 0;
|
||||||
|
const triggerHighVol = this.ctx.basin.maxVolAtOverflow * ((Number(s.overfillThresholdPercent) || 0) / 100);
|
||||||
|
|
||||||
|
const flags = [];
|
||||||
|
if (overfillEnabled && vol > triggerHighVol) flags.push('overfill-volume');
|
||||||
|
if (timeProtectionEnabled && secondsRemaining != null && secondsRemaining < s.timeleftToFullOrEmptyThresholdSeconds) {
|
||||||
|
flags.push('time-remaining');
|
||||||
|
}
|
||||||
|
return { triggered: flags.length > 0, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
_shutdownDownstream(vol, secondsRemaining) {
|
||||||
|
const { machines, machineGroups, stations, logger } = this.ctx;
|
||||||
|
Object.values(machines).forEach((machine) => {
|
||||||
|
const pos = machine?.config?.functionality?.positionVsParent;
|
||||||
|
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
|
||||||
|
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
|
||||||
|
Object.values(machineGroups).forEach((g) => g.turnOffAllMachines());
|
||||||
|
logger.warn(
|
||||||
|
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_shutdownUpstream(vol, secondsRemaining) {
|
||||||
|
const { machines, stations, logger } = this.ctx;
|
||||||
|
Object.values(machines).forEach((machine) => {
|
||||||
|
const pos = machine?.config?.functionality?.positionVsParent;
|
||||||
|
if (pos === 'upstream' && machine._isOperationalState()) {
|
||||||
|
machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.values(stations).forEach((st) => st.handleInput('parent', 'execSequence', 'shutdown'));
|
||||||
|
// Machine groups intentionally NOT shut down — they must keep draining.
|
||||||
|
logger.warn(
|
||||||
|
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${secondsRemaining ? secondsRemaining.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SafetyController;
|
||||||
1184
src/specificClass.js
1184
src/specificClass.js
File diff suppressed because it is too large
Load Diff
106
test/basic/BasinGeometry.basic.test.js
Normal file
106
test/basic/BasinGeometry.basic.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Basic unit tests for BasinGeometry.
|
||||||
|
// Run with: node --test test/basic/BasinGeometry.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const BasinGeometry = require('../../src/basin/BasinGeometry');
|
||||||
|
|
||||||
|
function makeBasin(overrides = {}) {
|
||||||
|
const basin = {
|
||||||
|
volume: 50,
|
||||||
|
height: 5,
|
||||||
|
inflowLevel: 3,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
...overrides.basin,
|
||||||
|
};
|
||||||
|
const hydraulics = {
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
...overrides.hydraulics,
|
||||||
|
};
|
||||||
|
return new BasinGeometry(basin, hydraulics);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('constructor produces correct surfaceArea = volume / height', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.surfaceArea, 10); // 50 / 5
|
||||||
|
assert.equal(g.heightBasin, 5);
|
||||||
|
assert.equal(g.volEmptyBasin, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxVolAtOverflow equals overflowLevel × surfaceArea', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.maxVolAtOverflow, 4.5 * 10); // 45
|
||||||
|
assert.equal(g.minVolAtInflow, 3 * 10); // 30
|
||||||
|
assert.equal(g.minVolAtOutflow, 0.2 * 10); // 2
|
||||||
|
assert.equal(g.maxVol, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minVol selects outlet-based when minHeightBasedOn = 'outlet'", () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.minVol, g.minVolAtOutflow);
|
||||||
|
assert.equal(g.minHeightBasedOn, 'outlet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minVol selects inlet-based when minHeightBasedOn = 'inlet'", () => {
|
||||||
|
const g = makeBasin({ hydraulics: { minHeightBasedOn: 'inlet' } });
|
||||||
|
assert.equal(g.minVol, g.minVolAtInflow);
|
||||||
|
assert.equal(g.minHeightBasedOn, 'inlet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('volumeFromLevel(0) returns 0; negative level clamps to 0', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.volumeFromLevel(0), 0);
|
||||||
|
assert.equal(g.volumeFromLevel(-1), 0);
|
||||||
|
assert.equal(g.volumeFromLevel(-1e9), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('volumeFromLevel(positive) is level × surfaceArea', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.volumeFromLevel(2.5), 25);
|
||||||
|
assert.equal(g.volumeFromLevel(5), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelFromVolume(maxVol) returns heightBasin', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.levelFromVolume(g.maxVol), g.heightBasin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelFromVolume(0) returns 0; negative volume clamps to 0', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
assert.equal(g.levelFromVolume(0), 0);
|
||||||
|
assert.equal(g.levelFromVolume(-10), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip: volumeFromLevel(levelFromVolume(v)) ≈ v for v in range', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
for (const v of [0, 0.001, 1, 12.34, 25, 49.999, 50]) {
|
||||||
|
const back = g.volumeFromLevel(g.levelFromVolume(v));
|
||||||
|
assert.ok(Math.abs(back - v) < 1e-9, `round-trip failed for v=${v}, got ${back}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip: levelFromVolume(volumeFromLevel(L)) ≈ L for L in range', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
for (const L of [0, 0.05, 1, 2.5, 4.5, 5]) {
|
||||||
|
const back = g.levelFromVolume(g.volumeFromLevel(L));
|
||||||
|
assert.ok(Math.abs(back - L) < 1e-9, `round-trip failed for L=${L}, got ${back}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot() exposes legacy this.basin field names', () => {
|
||||||
|
const g = makeBasin();
|
||||||
|
const s = g.snapshot();
|
||||||
|
const expectedKeys = [
|
||||||
|
'volEmptyBasin', 'heightBasin', 'inflowLevel', 'outflowLevel',
|
||||||
|
'overflowLevel', 'surfaceArea', 'maxVol', 'maxVolAtOverflow',
|
||||||
|
'minVolAtInflow', 'minVolAtOutflow', 'minVol', 'minHeightBasedOn',
|
||||||
|
];
|
||||||
|
for (const k of expectedKeys) {
|
||||||
|
assert.ok(k in s, `snapshot missing key: ${k}`);
|
||||||
|
}
|
||||||
|
assert.equal(s.volEmptyBasin, 50);
|
||||||
|
assert.equal(s.surfaceArea, 10);
|
||||||
|
assert.equal(s.minHeightBasedOn, 'outlet');
|
||||||
|
});
|
||||||
106
test/basic/calibration.basic.test.js
Normal file
106
test/basic/calibration.basic.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Basic tests for the calibration helpers.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
const {
|
||||||
|
calibratePredictedVolume,
|
||||||
|
calibratePredictedLevel,
|
||||||
|
setManualInflow,
|
||||||
|
} = require('../../src/measurement/calibration');
|
||||||
|
|
||||||
|
function makeBasin() {
|
||||||
|
return {
|
||||||
|
surfaceArea: 10,
|
||||||
|
minVol: 2,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
inflowLevel: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(seedVolume = null) {
|
||||||
|
const measurements = new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
|
||||||
|
});
|
||||||
|
const basin = makeBasin();
|
||||||
|
if (seedVolume != null) {
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(seedVolume, Date.now() - 5_000, 'm3').unit('m3');
|
||||||
|
}
|
||||||
|
const ctx = { measurements, basin };
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('calibratePredictedVolume clears prior series and writes new value', async () => {
|
||||||
|
const ctx = makeCtx(12);
|
||||||
|
const before = ctx.measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(before - 12) < 1e-9);
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
calibratePredictedVolume(ctx, 30, ts);
|
||||||
|
|
||||||
|
const m = ctx.measurements.type('volume').variant('predicted').position('atequipment').get();
|
||||||
|
assert.equal(m.values.length, 1, 'series should hold exactly the calibration point');
|
||||||
|
assert.ok(Math.abs(m.getCurrentValue() - 30) < 1e-9);
|
||||||
|
|
||||||
|
// Level was derived: 30 / 10 = 3 m.
|
||||||
|
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 3) < 1e-9, `derived level was ${lvl}`);
|
||||||
|
|
||||||
|
assert.equal(ctx._predictedFlowState.lastTimestamp, ts);
|
||||||
|
assert.equal(ctx._predictedFlowState.inflow, 0);
|
||||||
|
assert.equal(ctx._predictedFlowState.outflow, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibratePredictedLevel writes both level and derived volume', async () => {
|
||||||
|
const ctx = makeCtx(2);
|
||||||
|
calibratePredictedLevel(ctx, 4.0, Date.now(), 'm');
|
||||||
|
|
||||||
|
const lvl = ctx.measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 4.0) < 1e-9);
|
||||||
|
|
||||||
|
const vol = ctx.measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(vol - 40) < 1e-9, `derived volume was ${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setManualInflow writes to flow.predicted.in.manual-qin', async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const ts = Date.now();
|
||||||
|
setManualInflow(ctx, 0.025, ts, 'm3/s');
|
||||||
|
|
||||||
|
const series = ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin');
|
||||||
|
const val = series.getCurrentValue('m3/s');
|
||||||
|
assert.ok(Math.abs(val - 0.025) < 1e-9, `manual-qin value was ${val}`);
|
||||||
|
|
||||||
|
// It must NOT collide with the default child bucket.
|
||||||
|
const defaultBucket = ctx.measurements.measurements?.flow?.predicted?.in?.default;
|
||||||
|
assert.equal(defaultBucket, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibration uses ctx.flowAggregator.resetState when present', async () => {
|
||||||
|
const ctx = makeCtx(5);
|
||||||
|
let resetCalled = null;
|
||||||
|
ctx.flowAggregator = { resetState: (ts) => { resetCalled = ts; } };
|
||||||
|
|
||||||
|
const ts = 1234567890;
|
||||||
|
calibratePredictedVolume(ctx, 20, ts);
|
||||||
|
|
||||||
|
assert.equal(resetCalled, ts);
|
||||||
|
// The plain bag should NOT be touched when the aggregator hook is present.
|
||||||
|
assert.equal(ctx._predictedFlowState, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calibratePredictedVolume rejects bad context', async () => {
|
||||||
|
assert.throws(() => calibratePredictedVolume({}, 10));
|
||||||
|
assert.throws(() => calibratePredictedLevel({}, 1.0));
|
||||||
|
assert.throws(() => setManualInflow({}, 0.01));
|
||||||
|
});
|
||||||
178
test/basic/commands.basic.test.js
Normal file
178
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// Basic tests for the pumpingStation commands registry.
|
||||||
|
// Run with: node --test test/basic/commands.basic.test.js
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createRegistry } = require('generalFunctions');
|
||||||
|
const commands = require('../../src/commands');
|
||||||
|
|
||||||
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
warn: (m) => calls.warn.push(String(m)),
|
||||||
|
error: (m) => calls.error.push(String(m)),
|
||||||
|
info: (m) => calls.info.push(String(m)),
|
||||||
|
debug: (m) => calls.debug.push(String(m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSource({ mode = 'manual' } = {}) {
|
||||||
|
const calls = {
|
||||||
|
changeMode: [],
|
||||||
|
calibratePredictedVolume: [],
|
||||||
|
calibratePredictedLevel: [],
|
||||||
|
setManualInflow: [],
|
||||||
|
forwardDemandToChildren: [],
|
||||||
|
registerChild: [],
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
mode,
|
||||||
|
logger: makeLogger(),
|
||||||
|
changeMode: (m) => calls.changeMode.push(m),
|
||||||
|
calibratePredictedVolume: (v) => calls.calibratePredictedVolume.push(v),
|
||||||
|
calibratePredictedLevel: (v) => calls.calibratePredictedLevel.push(v),
|
||||||
|
setManualInflow: (v, ts, u) => calls.setManualInflow.push({ v, ts, u }),
|
||||||
|
forwardDemandToChildren: async (d) => { calls.forwardDemandToChildren.push(d); },
|
||||||
|
childRegistrationUtils: {
|
||||||
|
registerChild: (childSource, position) =>
|
||||||
|
calls.registerChild.push({ childSource, position }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { source, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx({ child = null, logger = makeLogger() } = {}) {
|
||||||
|
return {
|
||||||
|
logger,
|
||||||
|
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
|
||||||
|
node: {},
|
||||||
|
send: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRegistry(logger) {
|
||||||
|
return createRegistry(commands, { logger });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
test('canonical topics dispatch to their handlers', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.mode', payload: 'levelbased' }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.changeMode, ['levelbased']);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate.volume', payload: '12.5' }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.calibratePredictedVolume, [12.5]);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate.level', payload: 1.25 }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.calibratePredictedLevel, [1.25]);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s' }, source, makeCtx());
|
||||||
|
assert.equal(calls.setManualInflow.length, 1);
|
||||||
|
assert.equal(calls.setManualInflow[0].v, 0.5);
|
||||||
|
assert.equal(calls.setManualInflow[0].u, 'm3/s');
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 100 }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.forwardDemandToChildren, [100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const child = { id: 'child-1', source: { tag: 'child-domain' } };
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
|
||||||
|
source,
|
||||||
|
makeCtx({ child })
|
||||||
|
);
|
||||||
|
assert.equal(calls.registerChild.length, 1);
|
||||||
|
assert.equal(calls.registerChild[0].childSource, child.source);
|
||||||
|
assert.equal(calls.registerChild[0].position, 'upstream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(ctxLogger);
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
|
||||||
|
assert.deepEqual(calls.changeMode, ['manual', 'manual']);
|
||||||
|
const deprecWarns = ctxLogger.calls.warn.filter((m) => m.includes("'changemode' is deprecated"));
|
||||||
|
assert.equal(deprecWarns.length, 1, 'deprecation warning should log exactly once');
|
||||||
|
assert.equal(reg.deprecationStats().changemode, 2);
|
||||||
|
|
||||||
|
// q_in alias also routes to setInflow.
|
||||||
|
await reg.dispatch({ topic: 'q_in', payload: 0.25, unit: 'm3/s' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
assert.equal(calls.setManualInflow.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child.register with unknown child id logs warn and does not throw', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await assert.doesNotReject(() =>
|
||||||
|
reg.dispatch(
|
||||||
|
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
|
||||||
|
source,
|
||||||
|
makeCtx({ logger: ctxLogger })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal(calls.registerChild.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
|
||||||
|
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set.inflow accepts number payload and { value, unit, timestamp } object payload', async () => {
|
||||||
|
const { source, calls } = makeSource();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx());
|
||||||
|
assert.deepEqual(calls.setManualInflow[0], { v: 0.5, ts: 1000, u: 'm3/s' });
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'set.inflow', payload: { value: 2, unit: 'm3/h', timestamp: 2000 } },
|
||||||
|
source,
|
||||||
|
makeCtx()
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls.setManualInflow[1], { v: 2, ts: 2000, u: 'm3/h' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set.demand in non-manual mode logs debug and does not call forwardDemandToChildren', async () => {
|
||||||
|
const { source, calls } = makeSource({ mode: 'levelbased' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
assert.equal(calls.forwardDemandToChildren.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.debug.some((m) => m.includes('set.demand') && m.includes('levelbased')),
|
||||||
|
`expected debug about ignoring demand, got: ${JSON.stringify(ctxLogger.calls.debug)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set.demand with non-numeric payload logs warn and does not call', async () => {
|
||||||
|
const { source, calls } = makeSource({ mode: 'manual' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
|
||||||
|
assert.equal(calls.forwardDemandToChildren.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes('set.demand') && m.includes('oops')),
|
||||||
|
`expected warn about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
126
test/basic/control-levelBased.basic.test.js
Normal file
126
test/basic/control-levelBased.basic.test.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Unit tests for the level-based control strategy.
|
||||||
|
// Run with: node --test test/basic/control-levelBased.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const levelBased = require('../../src/control/levelBased');
|
||||||
|
|
||||||
|
function makeMeasurements(levelMeters) {
|
||||||
|
// Minimal MeasurementContainer stand-in. The strategy only calls
|
||||||
|
// getUnit('level') and a chain ending in getCurrentValue(unit).
|
||||||
|
const chain = {
|
||||||
|
type() { return chain; },
|
||||||
|
variant() { return chain; },
|
||||||
|
position() { return chain; },
|
||||||
|
getCurrentValue() {
|
||||||
|
return Number.isFinite(levelMeters) ? levelMeters : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getUnit: () => 'm',
|
||||||
|
type: () => chain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroup(name) {
|
||||||
|
const calls = { handleInput: [], turnOff: 0 };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
|
turnOffAllMachines: () => { calls.turnOff += 1; },
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(levelMeters, opts = {}) {
|
||||||
|
const groups = {
|
||||||
|
a: makeGroup('A'),
|
||||||
|
b: makeGroup('B'),
|
||||||
|
c: makeGroup('C'),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
measurements: makeMeasurements(levelMeters),
|
||||||
|
config: {
|
||||||
|
control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } },
|
||||||
|
},
|
||||||
|
logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} },
|
||||||
|
machineGroups: groups,
|
||||||
|
machines: {},
|
||||||
|
levelVariants: ['measured', 'predicted'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => {
|
||||||
|
const ctx = makeCtx(0.5);
|
||||||
|
const state = { percControl: 42 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 0);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
|
||||||
|
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minLevel ≤ level < startLevel → DEAD ZONE: no calls, percControl unchanged', async () => {
|
||||||
|
const ctx = makeCtx(1.5);
|
||||||
|
const state = { percControl: 17 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 17, 'percControl untouched in dead zone');
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.equal(g._calls.handleInput.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
|
||||||
|
const ctx = makeCtx(2);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
|
||||||
|
const ctx = makeCtx(4);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
assert.equal(state.percControl, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => {
|
||||||
|
const ctx = makeCtx(10);
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
// interpolate_lin_single_point clamps via limit_input(o_min, o_max).
|
||||||
|
assert.equal(state.percControl, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
|
||||||
|
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
|
||||||
|
const state = { percControl: null };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(state.percControl, 50);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
|
||||||
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
|
||||||
|
const ctx = makeCtx(NaN);
|
||||||
|
let warned = false;
|
||||||
|
ctx.logger.warn = () => { warned = true; };
|
||||||
|
const state = { percControl: 7 };
|
||||||
|
await levelBased.run(ctx, state);
|
||||||
|
|
||||||
|
assert.equal(warned, true);
|
||||||
|
assert.equal(state.percControl, 7);
|
||||||
|
for (const g of Object.values(ctx.machineGroups)) {
|
||||||
|
assert.equal(g._calls.turnOff, 0);
|
||||||
|
assert.equal(g._calls.handleInput.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
64
test/basic/control-manual.basic.test.js
Normal file
64
test/basic/control-manual.basic.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Unit tests for the manual control strategy.
|
||||||
|
// Run with: node --test test/basic/control-manual.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const manual = require('../../src/control/manual');
|
||||||
|
|
||||||
|
function makeGroup(name) {
|
||||||
|
const calls = { handleInput: [] };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMachine(name) {
|
||||||
|
const calls = { handleInput: [] };
|
||||||
|
return {
|
||||||
|
config: { general: { name } },
|
||||||
|
handleInput: async (...args) => { calls.handleInput.push(args); },
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
return { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('forwardDemand calls handleInput("parent", demand) on every machine group', async () => {
|
||||||
|
const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C') };
|
||||||
|
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
||||||
|
|
||||||
|
await manual.forwardDemand(ctx, 50);
|
||||||
|
|
||||||
|
for (const g of Object.values(groups)) {
|
||||||
|
assert.equal(g._calls.handleInput.length, 1);
|
||||||
|
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwardDemand with no machineGroups but direct machines splits demand evenly', async () => {
|
||||||
|
const machines = { m1: makeMachine('M1'), m2: makeMachine('M2'), m3: makeMachine('M3'), m4: makeMachine('M4') };
|
||||||
|
const ctx = { machineGroups: {}, machines, logger: makeLogger() };
|
||||||
|
|
||||||
|
await manual.forwardDemand(ctx, 80);
|
||||||
|
|
||||||
|
for (const m of Object.values(machines)) {
|
||||||
|
assert.equal(m._calls.handleInput.length, 1);
|
||||||
|
assert.deepEqual(m._calls.handleInput[0], ['parent', 'execMovement', 20]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run() is a no-op (manual mode is event-driven)', async () => {
|
||||||
|
const groups = { a: makeGroup('A') };
|
||||||
|
const ctx = { machineGroups: groups, machines: {}, logger: makeLogger() };
|
||||||
|
await manual.run(ctx, { percControl: 0 });
|
||||||
|
assert.equal(groups.a._calls.handleInput.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual exports name === "manual"', () => {
|
||||||
|
assert.equal(manual.name, 'manual');
|
||||||
|
});
|
||||||
141
test/basic/flowAggregator.basic.test.js
Normal file
141
test/basic/flowAggregator.basic.test.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Basic tests for FlowAggregator. Pure node:test, no Node-RED runtime.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer } = require('generalFunctions');
|
||||||
|
const FlowAggregator = require('../../src/measurement/flowAggregator');
|
||||||
|
|
||||||
|
function makeBasin() {
|
||||||
|
// Constant-cross-section basin: 50 m3 / 5 m height ⇒ surfaceArea = 10 m2.
|
||||||
|
const surfaceArea = 10;
|
||||||
|
return {
|
||||||
|
surfaceArea,
|
||||||
|
minVol: 2,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45, // overflow at 4.5 m
|
||||||
|
minVolAtOutflow: 2,
|
||||||
|
minVolAtInflow: 30,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
inflowLevel: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMeasurements() {
|
||||||
|
return new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAggregator(overrides = {}) {
|
||||||
|
const measurements = overrides.measurements || makeMeasurements();
|
||||||
|
const basin = overrides.basin || makeBasin();
|
||||||
|
// Seed predicted volume at minVol so update() has a starting point.
|
||||||
|
measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.value(basin.minVol).unit('m3');
|
||||||
|
const fa = new FlowAggregator({ measurements, basin, flowThreshold: 1e-4 });
|
||||||
|
return { fa, measurements, basin };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('FlowAggregator.update integrates inflow-outflow over delta-t', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
// Net flow = 0.01 m3/s (in) - 0.005 m3/s (out) = 0.005 m3/s.
|
||||||
|
const t0 = Date.now() - 10_000; // 10 s ago
|
||||||
|
measurements.type('flow').variant('predicted').position('in').child('src')
|
||||||
|
.value(0.01, t0, 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('out').child('snk')
|
||||||
|
.value(0.005, t0, 'm3/s');
|
||||||
|
|
||||||
|
// Force the integrator to know we are starting 10 s in the past.
|
||||||
|
fa._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||||
|
fa.update();
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m3');
|
||||||
|
// Expect minVol(2) + 0.005 * ~10 ≈ 2.05 m3. Allow slack for clock jitter.
|
||||||
|
assert.ok(vol > 2.04 && vol < 2.06, `volume after integration was ${vol}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.selectBestNetFlow prefers measured over predicted', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
measurements.type('flow').variant('measured').position('in').child('m')
|
||||||
|
.value(0.02, Date.now(), 'm3/s');
|
||||||
|
measurements.type('flow').variant('measured').position('out').child('m')
|
||||||
|
.value(0.01, Date.now(), 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('in').child('p')
|
||||||
|
.value(0.5, Date.now(), 'm3/s');
|
||||||
|
measurements.type('flow').variant('predicted').position('out').child('p')
|
||||||
|
.value(0.0, Date.now(), 'm3/s');
|
||||||
|
|
||||||
|
const r = fa.selectBestNetFlow();
|
||||||
|
assert.equal(r.source, 'measured');
|
||||||
|
assert.ok(Math.abs(r.value - 0.01) < 1e-9);
|
||||||
|
assert.equal(r.direction, 'filling');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.selectBestNetFlow falls back to level rate when no flow', async () => {
|
||||||
|
const { fa, measurements, basin } = makeAggregator();
|
||||||
|
// Seed two level samples 2 s apart, rising 0.1 m → rate 0.05 m/s
|
||||||
|
// → net flow = 0.05 * 10 m2 = 0.5 m3/s (filling).
|
||||||
|
const t0 = Date.now() - 2_000;
|
||||||
|
const t1 = Date.now();
|
||||||
|
measurements.type('level').variant('measured').position('atequipment').child('default')
|
||||||
|
.value(1.0, t0, 'm');
|
||||||
|
measurements.type('level').variant('measured').position('atequipment').child('default')
|
||||||
|
.value(1.1, t1, 'm');
|
||||||
|
|
||||||
|
const r = fa.selectBestNetFlow();
|
||||||
|
assert.ok(r.source.startsWith('level:'), `source was ${r.source}`);
|
||||||
|
assert.equal(r.direction, 'filling');
|
||||||
|
assert.ok(Math.abs(r.value - basin.surfaceArea * 0.05) < 1e-3, `net flow was ${r.value}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.deriveDirection threshold semantics', async () => {
|
||||||
|
const { fa } = makeAggregator();
|
||||||
|
assert.equal(fa.deriveDirection(0), 'steady');
|
||||||
|
assert.equal(fa.deriveDirection(fa.flowThreshold * 2), 'filling');
|
||||||
|
assert.equal(fa.deriveDirection(-fa.flowThreshold * 2), 'draining');
|
||||||
|
assert.equal(fa.deriveDirection(fa.flowThreshold * 0.5), 'steady');
|
||||||
|
assert.equal(fa.deriveDirection(-fa.flowThreshold * 0.5), 'steady');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.computeRemainingTime — filling uses overflow ceiling', async () => {
|
||||||
|
const { fa, measurements, basin } = makeAggregator();
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(2.0, Date.now(), 'm');
|
||||||
|
// Net 0.05 m3/s upward; remaining height = 4.5 - 2.0 = 2.5 m.
|
||||||
|
// seconds = 2.5 * 10 / 0.05 = 500 s.
|
||||||
|
const r = fa.computeRemainingTime({ value: 0.05, source: 'measured', direction: 'filling' });
|
||||||
|
assert.ok(Math.abs(r.seconds - 500) < 1e-6, `seconds was ${r.seconds}`);
|
||||||
|
assert.equal(typeof r.source, 'string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.computeRemainingTime — draining uses outflow floor', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.value(1.0, Date.now(), 'm');
|
||||||
|
// Net -0.05 m3/s; remaining height = 1.0 - 0.2 = 0.8 m.
|
||||||
|
// seconds = 0.8 * 10 / 0.05 = 160 s.
|
||||||
|
const r = fa.computeRemainingTime({ value: -0.05, source: 'measured', direction: 'draining' });
|
||||||
|
assert.ok(Math.abs(r.seconds - 160) < 1e-6, `seconds was ${r.seconds}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.snapshot exposes the expected shape', async () => {
|
||||||
|
const { fa, measurements } = makeAggregator();
|
||||||
|
measurements.type('flow').variant('measured').position('in').child('m')
|
||||||
|
.value(0.02, Date.now(), 'm3/s');
|
||||||
|
fa.tick();
|
||||||
|
const snap = fa.snapshot();
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'direction'));
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'netFlow'));
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'flowSource'));
|
||||||
|
assert.ok(Object.prototype.hasOwnProperty.call(snap, 'secondsRemaining'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlowAggregator.computeRemainingTime — below threshold returns null seconds', async () => {
|
||||||
|
const { fa } = makeAggregator();
|
||||||
|
const r = fa.computeRemainingTime({ value: 0, source: null, direction: 'steady' });
|
||||||
|
assert.equal(r.seconds, null);
|
||||||
|
});
|
||||||
106
test/basic/measurementRouter.basic.test.js
Normal file
106
test/basic/measurementRouter.basic.test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Basic tests for MeasurementRouter.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { MeasurementContainer, coolprop } = require('generalFunctions');
|
||||||
|
const MeasurementRouter = require('../../src/measurement/measurementRouter');
|
||||||
|
|
||||||
|
// CoolProp is async-init; ensure it's warm before any pressure-conversion
|
||||||
|
// test runs.
|
||||||
|
test.before(async () => {
|
||||||
|
await coolprop.init({ refrigerant: 'Water' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeBasin() {
|
||||||
|
return {
|
||||||
|
surfaceArea: 10,
|
||||||
|
minVol: 2,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
inflowLevel: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMeasurements() {
|
||||||
|
return new MeasurementContainer({
|
||||||
|
autoConvert: true,
|
||||||
|
preferredUnits: { flow: 'm3/s', level: 'm', volume: 'm3' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLogger() {
|
||||||
|
const calls = { warn: [], info: [], error: [], debug: [] };
|
||||||
|
return {
|
||||||
|
warn: (m) => calls.warn.push(m),
|
||||||
|
info: (m) => calls.info.push(m),
|
||||||
|
error: (m) => calls.error.push(m),
|
||||||
|
debug: (m) => calls.debug.push(m),
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('onLevelMeasurement writes volume + percent', async () => {
|
||||||
|
const measurements = makeMeasurements();
|
||||||
|
const basin = makeBasin();
|
||||||
|
const router = new MeasurementRouter({ measurements, basin });
|
||||||
|
|
||||||
|
router.onLevelMeasurement('atequipment', 2.5, { unit: 'm', timestamp: Date.now() });
|
||||||
|
|
||||||
|
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||||
|
|
||||||
|
const vol = measurements.type('volume').variant('measured').position('atequipment').getCurrentValue('m3');
|
||||||
|
// 2.5 m * 10 m² = 25 m3.
|
||||||
|
assert.ok(Math.abs(vol - 25) < 1e-9, `volume was ${vol}`);
|
||||||
|
|
||||||
|
const pct = measurements.type('volumePercent').variant('measured').position('atequipment').getCurrentValue('%');
|
||||||
|
// (25 - 2) / (45 - 2) * 100 ≈ 53.488...
|
||||||
|
assert.ok(pct > 53 && pct < 54, `percent was ${pct}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onPressureMeasurement falls back to assumed temperature and warns', async () => {
|
||||||
|
const measurements = makeMeasurements();
|
||||||
|
const basin = makeBasin();
|
||||||
|
const logger = fakeLogger();
|
||||||
|
const router = new MeasurementRouter({ measurements, basin, logger });
|
||||||
|
|
||||||
|
// No temperature seeded — must fall back to assumed 15C.
|
||||||
|
measurements.type('pressure').variant('measured').position('atequipment')
|
||||||
|
.value(20000, Date.now(), 'Pa');
|
||||||
|
router.onPressureMeasurement('atequipment', 20000, { unit: 'Pa', timestamp: Date.now() });
|
||||||
|
|
||||||
|
const warned = logger._calls.warn.some((m) => /assuming 15C|temperature/i.test(m));
|
||||||
|
assert.ok(warned, 'expected a warn about missing temperature');
|
||||||
|
|
||||||
|
const assumedT = measurements.type('temperature').variant('assumed').position('atequipment')
|
||||||
|
.getCurrentValue('K');
|
||||||
|
assert.ok(Number.isFinite(assumedT), 'assumed temperature was not stored');
|
||||||
|
|
||||||
|
const lvl = measurements.type('level').variant('predicted').position('atequipment')
|
||||||
|
.getCurrentValue('m');
|
||||||
|
// 20000 Pa / (~999 kg/m³ * 9.80665) ≈ 2.04 m.
|
||||||
|
assert.ok(lvl > 1.9 && lvl < 2.2, `derived level was ${lvl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route() dispatches by measurement type', async () => {
|
||||||
|
const measurements = makeMeasurements();
|
||||||
|
const basin = makeBasin();
|
||||||
|
const router = new MeasurementRouter({ measurements, basin });
|
||||||
|
|
||||||
|
const handledLevel = router.route('level', 1.5, 'atequipment', { unit: 'm' });
|
||||||
|
assert.equal(handledLevel, true);
|
||||||
|
const lvl = measurements.type('level').variant('measured').position('atequipment').getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 1.5) < 1e-9);
|
||||||
|
|
||||||
|
// Unknown type returns false (no dispatch).
|
||||||
|
const handledOther = router.route('flow', 0.1, 'in', {});
|
||||||
|
assert.equal(handledOther, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor rejects missing context fields', async () => {
|
||||||
|
assert.throws(() => new MeasurementRouter({}));
|
||||||
|
assert.throws(() => new MeasurementRouter({ measurements: makeMeasurements() }));
|
||||||
|
});
|
||||||
230
test/basic/safetyController.basic.test.js
Normal file
230
test/basic/safetyController.basic.test.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const SafetyController = require('../../src/safety/safetyController');
|
||||||
|
|
||||||
|
// --------------------------- fakes ---------------------------
|
||||||
|
|
||||||
|
function fakeMeasurements(values) {
|
||||||
|
// values keyed by `${type}.${variant}.${position}` → number|null
|
||||||
|
return {
|
||||||
|
getUnit: (_type) => 'm3',
|
||||||
|
type(t) {
|
||||||
|
return {
|
||||||
|
variant(v) {
|
||||||
|
return {
|
||||||
|
position(p) {
|
||||||
|
return {
|
||||||
|
getCurrentValue() {
|
||||||
|
const k = `${t}.${v}.${p}`;
|
||||||
|
return values[k];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMachine(positionVsParent, operational = true) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
config: { functionality: { positionVsParent } },
|
||||||
|
_isOperationalState: () => operational,
|
||||||
|
handleInput: (...args) => calls.push(args),
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStation() {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
handleInput: (...args) => calls.push(args),
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroup() {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
turnOffAllMachines: () => calls.push(['turnOffAllMachines']),
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const warns = [];
|
||||||
|
return {
|
||||||
|
warn: (msg) => warns.push(msg),
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
warns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx({
|
||||||
|
vol = 50,
|
||||||
|
basin = { minVol: 10, maxVolAtOverflow: 90 },
|
||||||
|
safety = {
|
||||||
|
enableDryRunProtection: true,
|
||||||
|
enableOverfillProtection: true,
|
||||||
|
dryRunThresholdPercent: 10,
|
||||||
|
overfillThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
machines = {},
|
||||||
|
stations = {},
|
||||||
|
machineGroups = {},
|
||||||
|
} = {}) {
|
||||||
|
const measurements = fakeMeasurements({
|
||||||
|
'volume.measured.atequipment': vol,
|
||||||
|
'volume.predicted.atequipment': vol,
|
||||||
|
});
|
||||||
|
const logger = makeLogger();
|
||||||
|
return {
|
||||||
|
ctx: { measurements, basin, config: { safety }, logger, machines, stations, machineGroups },
|
||||||
|
logger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------- tests ---------------------------
|
||||||
|
|
||||||
|
test('normal volume + filling → not blocked, no shutdowns', () => {
|
||||||
|
const m = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({ vol: 50, machines: { m } });
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
|
||||||
|
assert.deepStrictEqual(r, { blocked: false, reason: null, triggered: [] });
|
||||||
|
assert.strictEqual(m.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dry-run trigger: low volume + draining → blocked, downstream shut down', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const at = makeMachine('atequipment');
|
||||||
|
const up = makeMachine('upstream');
|
||||||
|
const station = makeStation();
|
||||||
|
const group = makeGroup();
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 5, // below 10 * (1 + 10/100) = 11
|
||||||
|
machines: { down, at, up },
|
||||||
|
stations: { station },
|
||||||
|
machineGroups: { group },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
|
||||||
|
assert.strictEqual(r.blocked, true);
|
||||||
|
assert.strictEqual(r.reason, 'dry-run');
|
||||||
|
assert.ok(r.triggered.includes('dry-run-volume'));
|
||||||
|
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(at.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.strictEqual(up.calls.length, 0, 'upstream untouched in dry-run');
|
||||||
|
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(group.calls[0], ['turnOffAllMachines']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dry-run does NOT trigger when filling', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({ vol: 5, machines: { down } });
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
|
||||||
|
// Filling at vol=5 (below overfill threshold 85.5) → no trigger at all.
|
||||||
|
assert.strictEqual(r.blocked, false);
|
||||||
|
assert.strictEqual(r.reason, null);
|
||||||
|
assert.strictEqual(down.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overfill trigger: high volume + filling → not blocked, only upstream + station shut down', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const at = makeMachine('atequipment');
|
||||||
|
const up = makeMachine('upstream');
|
||||||
|
const station = makeStation();
|
||||||
|
const group = makeGroup();
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 88, // above 90 * 0.95 = 85.5
|
||||||
|
machines: { down, at, up },
|
||||||
|
stations: { station },
|
||||||
|
machineGroups: { group },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
|
||||||
|
assert.strictEqual(r.blocked, false, 'overfill must NOT block control');
|
||||||
|
assert.strictEqual(r.reason, 'overfill');
|
||||||
|
assert.ok(r.triggered.includes('overfill-volume'));
|
||||||
|
assert.deepStrictEqual(up.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.strictEqual(down.calls.length, 0, 'downstream must keep running');
|
||||||
|
assert.strictEqual(at.calls.length, 0, 'atequipment must keep running');
|
||||||
|
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.strictEqual(group.calls.length, 0, 'machine groups must keep draining');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no volume data → blocked, all machines shut down (panic)', () => {
|
||||||
|
const a = makeMachine('downstream');
|
||||||
|
const b = makeMachine('upstream');
|
||||||
|
const c = makeMachine('atequipment');
|
||||||
|
// override measurements to return null
|
||||||
|
const measurements = {
|
||||||
|
getUnit: () => 'm3',
|
||||||
|
type: () => ({ variant: () => ({ position: () => ({ getCurrentValue: () => null }) }) }),
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
measurements,
|
||||||
|
basin: { minVol: 10, maxVolAtOverflow: 90 },
|
||||||
|
config: { safety: { enableDryRunProtection: true, enableOverfillProtection: true, dryRunThresholdPercent: 10, overfillThresholdPercent: 95 } },
|
||||||
|
logger: makeLogger(),
|
||||||
|
machines: { a, b, c },
|
||||||
|
stations: {},
|
||||||
|
machineGroups: {},
|
||||||
|
};
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'steady', secondsRemaining: null });
|
||||||
|
assert.strictEqual(r.blocked, true);
|
||||||
|
assert.strictEqual(r.reason, 'no-volume-data');
|
||||||
|
assert.deepStrictEqual(a.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(b.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
assert.deepStrictEqual(c.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time-based protection: short remainingTime while draining triggers dry-run shutdowns', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 50, // well above dry-run vol threshold
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false, // volume rule disabled
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 10,
|
||||||
|
overfillThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 60,
|
||||||
|
},
|
||||||
|
machines: { down },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 30 });
|
||||||
|
assert.strictEqual(r.blocked, true);
|
||||||
|
assert.strictEqual(r.reason, 'dry-run');
|
||||||
|
assert.ok(r.triggered.includes('time-remaining'));
|
||||||
|
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled rules: enableDryRunProtection=false + draining low → no trigger', () => {
|
||||||
|
const down = makeMachine('downstream');
|
||||||
|
const { ctx } = makeCtx({
|
||||||
|
vol: 5, // would normally trigger dry-run
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false,
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 10,
|
||||||
|
overfillThresholdPercent: 95,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
machines: { down },
|
||||||
|
});
|
||||||
|
const sc = new SafetyController(ctx);
|
||||||
|
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
|
||||||
|
assert.strictEqual(r.blocked, false);
|
||||||
|
assert.strictEqual(r.reason, null);
|
||||||
|
assert.strictEqual(down.calls.length, 0);
|
||||||
|
});
|
||||||
123
test/basic/thresholdValidator.basic.test.js
Normal file
123
test/basic/thresholdValidator.basic.test.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Basic unit tests for thresholdValidator.
|
||||||
|
// Run with: node --test test/basic/thresholdValidator.basic.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { validateThresholdOrdering } = require('../../src/basin/thresholdValidator');
|
||||||
|
const BasinGeometry = require('../../src/basin/BasinGeometry');
|
||||||
|
|
||||||
|
// A valid baseline: outlet 0.2 < inflow 3 < overflow 4.5 ≤ height 5,
|
||||||
|
// dryRun = 0.2 * 1.10 = 0.22 ≤ minLevel 1 ≤ start 2 < max 4 ≤ overfill 4.275.
|
||||||
|
function validBasinAndCfg() {
|
||||||
|
const basin = new BasinGeometry(
|
||||||
|
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
|
||||||
|
{ minHeightBasedOn: 'outlet' }
|
||||||
|
);
|
||||||
|
const levelbased = { minLevel: 1, startLevel: 2, maxLevel: 4 };
|
||||||
|
const safety = { dryRunThresholdPercent: 10, overfillThresholdPercent: 95 };
|
||||||
|
return { basin, levelbased, safety };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('valid ordering returns empty array', () => {
|
||||||
|
const { basin, levelbased, safety } = validBasinAndCfg();
|
||||||
|
const issues = validateThresholdOrdering(basin, levelbased, safety);
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outflowLevel >= inflowLevel triggers issue with correct shape', () => {
|
||||||
|
const basin = new BasinGeometry(
|
||||||
|
// outflow 3.5 > inflow 3 — invariant broken.
|
||||||
|
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 3.5, overflowLevel: 4.5 },
|
||||||
|
{ minHeightBasedOn: 'outlet' }
|
||||||
|
);
|
||||||
|
const issues = validateThresholdOrdering(basin, { minLevel: 1, startLevel: 2, maxLevel: 4 }, { dryRunThresholdPercent: 0, overfillThresholdPercent: 100 });
|
||||||
|
const hit = issues.find((i) => i.aName === 'outflowLevel' && i.bName === 'inflowLevel');
|
||||||
|
assert.ok(hit, 'expected an outflowLevel < inflowLevel issue');
|
||||||
|
assert.equal(hit.op, '<');
|
||||||
|
assert.equal(hit.a, 3.5);
|
||||||
|
assert.equal(hit.b, 3);
|
||||||
|
assert.match(hit.msg, /outflowLevel.*<.*inflowLevel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxLevel >= overfillLevel triggers issue', () => {
|
||||||
|
const { basin } = validBasinAndCfg();
|
||||||
|
// overfillLevel = overflowLevel × overfillPct/100 = 4.5 × 0.80 = 3.6.
|
||||||
|
// maxLevel 4 > 3.6 → expect a `maxLevel <= overfillLevel` issue.
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
basin,
|
||||||
|
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 80 }
|
||||||
|
);
|
||||||
|
const hit = issues.find((i) => i.aName === 'maxLevel' && i.bName === 'overfillLevel');
|
||||||
|
assert.ok(hit, 'expected a maxLevel <= overfillLevel issue');
|
||||||
|
assert.equal(hit.op, '<=');
|
||||||
|
assert.equal(hit.a, 4);
|
||||||
|
assert.ok(Math.abs(hit.b - 3.6) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NaN / undefined values are skipped, not flagged as issues', () => {
|
||||||
|
const { basin } = validBasinAndCfg();
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
basin,
|
||||||
|
{ minLevel: undefined, startLevel: NaN, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
|
||||||
|
);
|
||||||
|
// dryRunLevel <= minLevel skipped (minLevel undefined → NaN)
|
||||||
|
// minLevel <= startLevel skipped (both NaN-ish)
|
||||||
|
// startLevel < maxLevel skipped (startLevel NaN)
|
||||||
|
// maxLevel <= overfillLevel still checked → 4 ≤ 4.275 OK.
|
||||||
|
// Geometry checks also OK.
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple violations produce multiple issues in stable order', () => {
|
||||||
|
// Build a basin with two geometry violations.
|
||||||
|
const basin = new BasinGeometry(
|
||||||
|
// outflow 4 > inflow 3 (broken) AND overflow 6 > height 5 (broken)
|
||||||
|
{ volume: 50, height: 5, inflowLevel: 3, outflowLevel: 4, overflowLevel: 6 },
|
||||||
|
{ minHeightBasedOn: 'outlet' }
|
||||||
|
);
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
basin,
|
||||||
|
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 0, overfillThresholdPercent: 100 }
|
||||||
|
);
|
||||||
|
// Expect at least the two geometry issues, in declaration order:
|
||||||
|
// outflowLevel < inflowLevel comes before overflowLevel <= basinHeight.
|
||||||
|
const idxOutflow = issues.findIndex((i) => i.aName === 'outflowLevel');
|
||||||
|
const idxOverflow = issues.findIndex((i) => i.aName === 'overflowLevel' && i.bName === 'basinHeight');
|
||||||
|
assert.ok(idxOutflow >= 0, 'expected outflowLevel issue');
|
||||||
|
assert.ok(idxOverflow >= 0, 'expected overflowLevel <= basinHeight issue');
|
||||||
|
assert.ok(idxOutflow < idxOverflow, 'issues should be in check-declaration order');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a plain basin object (duck-typed via getters)', () => {
|
||||||
|
const plainBasin = {
|
||||||
|
volEmptyBasin: 50,
|
||||||
|
heightBasin: 5,
|
||||||
|
inflowLevel: 3,
|
||||||
|
outflowLevel: 0.2,
|
||||||
|
overflowLevel: 4.5,
|
||||||
|
surfaceArea: 10,
|
||||||
|
maxVol: 50,
|
||||||
|
maxVolAtOverflow: 45,
|
||||||
|
minVolAtInflow: 30,
|
||||||
|
minVolAtOutflow: 2,
|
||||||
|
minVol: 2,
|
||||||
|
minHeightBasedOn: 'outlet',
|
||||||
|
};
|
||||||
|
const issues = validateThresholdOrdering(
|
||||||
|
plainBasin,
|
||||||
|
{ minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
{ dryRunThresholdPercent: 10, overfillThresholdPercent: 95 }
|
||||||
|
);
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitted levelbased / safety objects are tolerated', () => {
|
||||||
|
const { basin } = validBasinAndCfg();
|
||||||
|
// No control or safety supplied → only geometry checks run; valid basin geometry → []
|
||||||
|
const issues = validateThresholdOrdering(basin, undefined, undefined);
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user