Compare commits
13 Commits
ffb2072baa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2857fb25 | ||
|
|
2651aaf409 | ||
|
|
df74ea0fac | ||
|
|
96b84d3124 | ||
|
|
a14aa0dab8 | ||
|
|
69bdf11fc4 | ||
|
|
dc27a569d9 | ||
|
|
b7c40b0ddc | ||
|
|
8e684203a8 | ||
|
|
9916527790 | ||
|
|
9c79dac4e3 | ||
|
|
7eafd89f4e | ||
|
|
d55f401ab3 |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# machineGroupControl — Claude Code context
|
||||
|
||||
Coordinates multiple rotatingMachine or valve children.
|
||||
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||
|
||||
## S88 classification
|
||||
|
||||
| Level | Colour | Placement lane |
|
||||
|---|---|---|
|
||||
| **Unit** | `#50a8d9` | L4 |
|
||||
|
||||
## Flow layout rules
|
||||
|
||||
When wiring this node into a multi-node demo or production flow, follow the
|
||||
placement rule set in the **EVOLV superproject**:
|
||||
|
||||
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||
|
||||
Key points for this node:
|
||||
- Place on lane **L4** (x-position per the lane table in the rule).
|
||||
- Stack same-level siblings vertically.
|
||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit).
|
||||
8
mgc.html
8
mgc.html
@@ -21,6 +21,14 @@
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
//define asset properties
|
||||
uuid: { value: "" },
|
||||
supplier: { value: "" },
|
||||
category: { value: "" },
|
||||
assetType: { value: "" },
|
||||
model: { value: "" },
|
||||
unit: { value: "" },
|
||||
|
||||
// Logger properties
|
||||
enableLog: { value: false },
|
||||
logLevel: { value: "error" },
|
||||
|
||||
@@ -271,6 +271,7 @@ class nodeClass {
|
||||
this.node.on("close", (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.node.status({}); // clear node status badge
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +74,16 @@ class MachineGroup {
|
||||
// Combination curve data
|
||||
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } , NCog : 0};
|
||||
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }};
|
||||
|
||||
// Dispatch serialization. PS ticks demand into MGC at 1 Hz, but
|
||||
// a real pump ramp takes several seconds — without this gate
|
||||
// every PS tick aborts the in-flight dispatch and starts a new
|
||||
// one, so pumps never reach their setpoint. Mirrors
|
||||
// rotatingMachine state.delayedMove: while a dispatch is in
|
||||
// flight the latest demand is parked here for pickup when the
|
||||
// current dispatch settles. Latest-wins.
|
||||
this._dispatchInFlight = false;
|
||||
this._delayedCall = null;
|
||||
//this always last in the constructor
|
||||
this.childRegistrationUtils = new childRegistrationUtils(this);
|
||||
|
||||
@@ -113,6 +123,31 @@ class MachineGroup {
|
||||
});
|
||||
|
||||
|
||||
} else if (softwareType === "measurement") {
|
||||
// Header-side measurement (e.g. discharge-manifold pressure
|
||||
// sensor at MGC's downstream, suction-manifold sensor at
|
||||
// upstream). Subscribed at the group level so optimalControl
|
||||
// can use ONE header operating point for all pumps instead of
|
||||
// each pump's individual reading. Without this, small per-pump
|
||||
// pressure differences make the BEP-Gravitation optimum flip
|
||||
// between near-equivalent combinations every tick → flap.
|
||||
const measurementType = child.config?.asset?.type;
|
||||
if (!measurementType || !position) {
|
||||
this.logger.warn(`Measurement child ${child.config?.general?.id} missing asset.type or positionVsParent — skipping`);
|
||||
return;
|
||||
}
|
||||
const eventName = `${measurementType}.measured.${position}`;
|
||||
this.logger.debug(`Listening for ${eventName} from measurement ${child.config.general.id}`);
|
||||
child.measurements.emitter.on(eventName, (eventData = {}) => {
|
||||
this.measurements
|
||||
.type(measurementType)
|
||||
.variant("measured")
|
||||
.position(position)
|
||||
.value(eventData.value, eventData.timestamp, eventData.unit);
|
||||
// Header pressure changes are operating-point inputs to
|
||||
// optimalControl — recompute combinations.
|
||||
if (measurementType === "pressure") this.handlePressureChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,18 +223,20 @@ class MachineGroup {
|
||||
}
|
||||
|
||||
this.logger.debug(`Processing machine with id: ${machine.config.general.id}`);
|
||||
this.logger.debug(`Current pressure settings: ${JSON.stringify(machine.predictFlow.currentF)}`);
|
||||
const gpf = this._groupFlow(machine);
|
||||
const gpp = this._groupPower(machine);
|
||||
this.logger.debug(`Group operating point: ${JSON.stringify(gpf.currentF)}`);
|
||||
|
||||
//fetch min flow ever seen over all machines
|
||||
const minFlow = machine.predictFlow.currentFxyYMin;
|
||||
const maxFlow = machine.predictFlow.currentFxyYMax;
|
||||
const minPower = machine.predictPower.currentFxyYMin;
|
||||
const maxPower = machine.predictPower.currentFxyYMax;
|
||||
//fetch min flow ever seen over all machines (at the group operating point)
|
||||
const minFlow = gpf.currentFxyYMin;
|
||||
const maxFlow = gpf.currentFxyYMax;
|
||||
const minPower = gpp.currentFxyYMin;
|
||||
const maxPower = gpp.currentFxyYMax;
|
||||
|
||||
const actFlow = this._readChildMeasurement(machine, "flow", "predicted", POSITIONS.DOWNSTREAM, this.unitPolicy.canonical.flow) || 0;
|
||||
const actPower = this._readChildMeasurement(machine, "power", "predicted", POSITIONS.AT_EQUIPMENT, this.unitPolicy.canonical.power) || 0;
|
||||
|
||||
this.logger.debug(`Machine ${machine.config.general.id} - Min Flow: ${minFlow}, Max Flow: ${maxFlow}, Min Power: ${minPower}, Max Power: ${maxPower}, NCog: ${machine.NCog}`);
|
||||
this.logger.debug(`Machine ${machine.config.general.id} - Min Flow: ${minFlow}, Max Flow: ${maxFlow}, Min Power: ${minPower}, Max Power: ${maxPower}, NCog: ${this._groupNCog(machine)}`);
|
||||
|
||||
if( minFlow < dynamicTotals.flow.min ){ dynamicTotals.flow.min = minFlow; }
|
||||
if( minPower < dynamicTotals.power.min ){ dynamicTotals.power.min = minPower; }
|
||||
@@ -209,8 +246,8 @@ class MachineGroup {
|
||||
dynamicTotals.flow.act += actFlow;
|
||||
dynamicTotals.power.act += actPower;
|
||||
|
||||
//fetch total Normalized Cog over all machines
|
||||
dynamicTotals.NCog += machine.NCog;
|
||||
//fetch total Normalized Cog over all machines (group operating point)
|
||||
dynamicTotals.NCog += this._groupNCog(machine);
|
||||
|
||||
});
|
||||
|
||||
@@ -226,11 +263,11 @@ class MachineGroup {
|
||||
Object.entries(this.machines).forEach(([id, machine]) => {
|
||||
this.logger.debug(`Processing machine with id: ${id}`);
|
||||
if(this.isMachineActive(id)){
|
||||
//fetch min flow ever seen over all machines
|
||||
const minFlow = machine.predictFlow.currentFxyYMin;
|
||||
const maxFlow = machine.predictFlow.currentFxyYMax;
|
||||
const minPower = machine.predictPower.currentFxyYMin;
|
||||
const maxPower = machine.predictPower.currentFxyYMax;
|
||||
//fetch min flow ever seen over all machines (group operating point)
|
||||
const minFlow = this._groupFlow(machine).currentFxyYMin;
|
||||
const maxFlow = this._groupFlow(machine).currentFxyYMax;
|
||||
const minPower = this._groupPower(machine).currentFxyYMin;
|
||||
const maxPower = this._groupPower(machine).currentFxyYMax;
|
||||
|
||||
|
||||
totals.flow.min += minFlow;
|
||||
@@ -247,11 +284,27 @@ class MachineGroup {
|
||||
|
||||
handlePressureChange() {
|
||||
this.logger.debug("Pressure change detected.");
|
||||
// Equalize before computing dynamicTotals so the cached value (read
|
||||
// by optimalControl) reflects the consistent header operating point,
|
||||
// not whichever per-pump sensor fired last.
|
||||
this._equalizeOperatingPoint();
|
||||
// Recalculate totals
|
||||
const { flow, power } = this.calcDynamicTotals();
|
||||
|
||||
this.logger.debug(`Dynamic Totals after pressure change - Flow: Min ${flow.min}, Max ${flow.max}, Act ${flow.act} | Power: Min ${power.min}, Max ${power.max}, Act ${power.act}`);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.AT_EQUIPMENT, flow.act, this.unitPolicy.canonical.flow);
|
||||
// Mirror the aggregate flow onto DOWNSTREAM as well. PS subscribes to
|
||||
// flow.predicted.downstream from MGC and uses it as the outflow
|
||||
// estimate for net-flow computation. Without this mirror, the only
|
||||
// place DOWNSTREAM gets written is optimalControl's bestFlow (the
|
||||
// optimizer's TARGET, not the achieved aggregate). During transients
|
||||
// — e.g. demand dropping to dead-band keep-alive while pumps are
|
||||
// still ramping down from full throttle — PS would see a stale
|
||||
// 25 m³/h target while pumps are physically delivering 500+ m³/h,
|
||||
// making netFlow look small and stable when the basin is actually
|
||||
// draining fast. flow.act here is the sum of every pump's current
|
||||
// predicted output, so it IS the achieved aggregate.
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.DOWNSTREAM, flow.act, this.unitPolicy.canonical.flow);
|
||||
this._writeMeasurement("power", "predicted", POSITIONS.AT_EQUIPMENT, power.act, this.unitPolicy.canonical.power);
|
||||
|
||||
const { maxEfficiency, lowestEfficiency } = this.calcGroupEfficiency(this.machines);
|
||||
@@ -265,7 +318,7 @@ class MachineGroup {
|
||||
|
||||
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
|
||||
let distance = 1;
|
||||
if(currentEfficiency != null){
|
||||
if(currentEfficiency != null && maxEfficiency !== minEfficiency){
|
||||
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
|
||||
}
|
||||
return distance;
|
||||
@@ -340,12 +393,13 @@ class MachineGroup {
|
||||
if (subset.length === 0) return false;
|
||||
|
||||
// Calculate total and minimum flow for the subset in one pass
|
||||
// (uses group operating point — see _groupFlow/_groupPower)
|
||||
const { maxFlow, minFlow, maxPower } = subset.reduce(
|
||||
(acc, machineId) => {
|
||||
const machine = machines[machineId];
|
||||
const minFlow = machine.predictFlow.currentFxyYMin;
|
||||
const maxFlow = machine.predictFlow.currentFxyYMax;
|
||||
const maxPower = machine.predictPower.currentFxyYMax;
|
||||
const minFlow = this._groupFlow(machine).currentFxyYMin;
|
||||
const maxFlow = this._groupFlow(machine).currentFxyYMax;
|
||||
const maxPower = this._groupPower(machine).currentFxyYMax;
|
||||
|
||||
return {
|
||||
maxFlow: acc.maxFlow + maxFlow,
|
||||
@@ -380,9 +434,9 @@ class MachineGroup {
|
||||
let totalCoG = 0;
|
||||
let totalPower = 0;
|
||||
|
||||
// Sum normalized CoG for the combination
|
||||
// Sum normalized CoG for the combination (group operating point)
|
||||
combination.forEach(machineId => {
|
||||
totalCoG += Math.round((this.machines[machineId].NCog || 0) * 100) / 100;
|
||||
totalCoG += Math.round((this._groupNCog(this.machines[machineId]) || 0) * 100) / 100;
|
||||
});
|
||||
|
||||
// Initial CoG-based distribution
|
||||
@@ -392,18 +446,18 @@ class MachineGroup {
|
||||
if (totalCoG === 0) {
|
||||
flow = Qd / combination.length;
|
||||
} else {
|
||||
flow = ((this.machines[machineId].NCog || 0) / totalCoG) * Qd;
|
||||
flow = ((this._groupNCog(this.machines[machineId]) || 0) / totalCoG) * Qd;
|
||||
this.logger.debug(`Machine Normalized CoG-based distribution ${machineId} flow: ${flow}`);
|
||||
}
|
||||
|
||||
flowDistribution.push({ machineId, flow });
|
||||
});
|
||||
|
||||
// Clamp to min/max and spill leftover once
|
||||
// Clamp to min/max and spill leftover once (group operating point)
|
||||
const clamped = flowDistribution.map(entry => {
|
||||
const machine = this.machines[entry.machineId];
|
||||
const min = machine.predictFlow.currentFxyYMin;
|
||||
const max = machine.predictFlow.currentFxyYMax;
|
||||
const min = this._groupFlow(machine).currentFxyYMin;
|
||||
const max = this._groupFlow(machine).currentFxyYMax;
|
||||
const clampedFlow = Math.min(max, Math.max(min, entry.flow));
|
||||
return { ...entry, flow: clampedFlow, min, max, desired: entry.flow };
|
||||
});
|
||||
@@ -433,7 +487,7 @@ class MachineGroup {
|
||||
let totalFlow = 0;
|
||||
flowDistribution.forEach(({ machineId, flow }) => {
|
||||
totalFlow += flow;
|
||||
totalPower += this.machines[machineId].inputFlowCalcPower(flow);
|
||||
totalPower += this._groupCalcPower(this.machines[machineId], flow);
|
||||
});
|
||||
|
||||
if (totalPower < bestPower) {
|
||||
@@ -460,17 +514,20 @@ class MachineGroup {
|
||||
P_BEP: 0
|
||||
};
|
||||
|
||||
const minFlow = machine.predictFlow.currentFxyYMin;
|
||||
const maxFlow = machine.predictFlow.currentFxyYMax;
|
||||
// Group operating point — slopes around BEP must use the same op-point
|
||||
// the optimizer evaluates at, otherwise gravitation pulls toward an
|
||||
// off-by-one BEP target.
|
||||
const minFlow = this._groupFlow(machine).currentFxyYMin;
|
||||
const maxFlow = this._groupFlow(machine).currentFxyYMax;
|
||||
const span = Math.max(0, maxFlow - minFlow);
|
||||
const normalizedCog = Math.max(0, Math.min(1, machine.NCog || 0));
|
||||
const normalizedCog = Math.max(0, Math.min(1, this._groupNCog(machine) || 0));
|
||||
const targetBEP = Q_BEP ?? (minFlow + span * normalizedCog);
|
||||
const clampFlow = (flow) => Math.min(maxFlow, Math.max(minFlow, flow)); // ensure within bounds using small helper function
|
||||
const center = clampFlow(targetBEP);
|
||||
const deltaSafe = Math.max(delta, 0.01);
|
||||
const leftFlow = clampFlow(center - deltaSafe);
|
||||
const rightFlow = clampFlow(center + deltaSafe);
|
||||
const powerAt = (flow) => machine.inputFlowCalcPower(flow); // helper to get power at a given flow
|
||||
const powerAt = (flow) => this._groupCalcPower(machine, flow); // helper to get power at a given flow
|
||||
const P_center = powerAt(center);
|
||||
const P_left = powerAt(leftFlow);
|
||||
const P_right = powerAt(rightFlow);
|
||||
@@ -548,10 +605,12 @@ class MachineGroup {
|
||||
combinations.forEach(combination => {
|
||||
const pumpInfos = combination.map(machineId => {
|
||||
const machine = this.machines[machineId];
|
||||
const minFlow = machine.predictFlow.currentFxyYMin;
|
||||
const maxFlow = machine.predictFlow.currentFxyYMax;
|
||||
// Group operating point — BEP and curve envelope must come
|
||||
// from the same view the optimizer evaluates power on.
|
||||
const minFlow = this._groupFlow(machine).currentFxyYMin;
|
||||
const maxFlow = this._groupFlow(machine).currentFxyYMax;
|
||||
const span = Math.max(0, maxFlow - minFlow);
|
||||
const NCog = Math.max(0, Math.min(1, machine.NCog || 0));
|
||||
const NCog = Math.max(0, Math.min(1, this._groupNCog(machine) || 0));
|
||||
const estimatedBEP = minFlow + span * NCog; // Estimated BEP flow based on current curve
|
||||
const slopes = this.estimateSlopesAtBEP(machine, estimatedBEP);
|
||||
return {
|
||||
@@ -580,14 +639,41 @@ class MachineGroup {
|
||||
this.redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional);
|
||||
}
|
||||
|
||||
// Clamp and compute initial power
|
||||
flowDistribution.forEach(entry => {
|
||||
const info = pumpInfos.find(info => info.id === entry.machineId);
|
||||
entry.flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow));
|
||||
});
|
||||
|
||||
// Marginal-cost refinement: shift flow from most expensive to cheapest
|
||||
// pump using actual power evaluations on the group operating
|
||||
// point. Converges regardless of curve convexity.
|
||||
const mcDelta = Math.max(1e-6, (Qd / pumpInfos.length) * 0.005);
|
||||
for (let refineIter = 0; refineIter < 50; refineIter++) {
|
||||
const mcEntries = flowDistribution.map(entry => {
|
||||
const info = pumpInfos.find(i => i.id === entry.machineId);
|
||||
const pNow = this._groupCalcPower(info.machine, entry.flow);
|
||||
const pUp = this._groupCalcPower(info.machine, Math.min(info.maxFlow, entry.flow + mcDelta));
|
||||
return { entry, info, mc: (pUp - pNow) / mcDelta };
|
||||
});
|
||||
let expensive = null, cheap = null;
|
||||
for (const e of mcEntries) {
|
||||
if (e.entry.flow > e.info.minFlow + mcDelta) { if (!expensive || e.mc > expensive.mc) expensive = e; }
|
||||
if (e.entry.flow < e.info.maxFlow - mcDelta) { if (!cheap || e.mc < cheap.mc) cheap = e; }
|
||||
}
|
||||
if (!expensive || !cheap || expensive === cheap) break;
|
||||
if (expensive.mc - cheap.mc < expensive.mc * 0.001) break;
|
||||
const before = this._groupCalcPower(expensive.info.machine, expensive.entry.flow) + this._groupCalcPower(cheap.info.machine, cheap.entry.flow);
|
||||
const after = this._groupCalcPower(expensive.info.machine, expensive.entry.flow - mcDelta) + this._groupCalcPower(cheap.info.machine, cheap.entry.flow + mcDelta);
|
||||
if (after < before) { expensive.entry.flow -= mcDelta; cheap.entry.flow += mcDelta; } else { break; }
|
||||
}
|
||||
|
||||
let totalPower = 0;
|
||||
totalFlow = 0;
|
||||
flowDistribution.forEach(entry => {
|
||||
const info = pumpInfos.find(info => info.id === entry.machineId);
|
||||
const flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow));
|
||||
entry.flow = flow;
|
||||
totalFlow += flow;
|
||||
totalPower += info.machine.inputFlowCalcPower(flow);
|
||||
totalFlow += entry.flow;
|
||||
const info = pumpInfos.find(i => i.id === entry.machineId);
|
||||
totalPower += this._groupCalcPower(info.machine, entry.flow);
|
||||
});
|
||||
|
||||
const totalCog = pumpInfos.reduce((sum, info) => sum + info.NCog, 0);
|
||||
@@ -633,8 +719,17 @@ class MachineGroup {
|
||||
}
|
||||
|
||||
async abortActiveMovements(reason = "new demand") {
|
||||
// Safety net: in normal operation the _dispatchInFlight gate
|
||||
// (handleInput) ensures no pump movement is in flight when a
|
||||
// new dispatch starts, so this is a no-op. If a pump IS still
|
||||
// moving here, the gate was bypassed (direct call to
|
||||
// abortActiveMovements, mode change racing a dispatch, etc.) —
|
||||
// surface that loudly so the bypass can be diagnosed.
|
||||
const movementStates = new Set(['accelerating', 'decelerating']);
|
||||
await Promise.all(Object.values(this.machines).map(async machine => {
|
||||
this.logger.warn(`Aborting active movements for machine ${machine.config.general.id} due to: ${reason}`);
|
||||
const state = machine.state?.getCurrentState?.();
|
||||
if (!movementStates.has(state)) return;
|
||||
this.logger.warn(`Force-aborting in-flight movement on ${machine.config.general.id} (state=${state}) due to: ${reason} — _dispatchInFlight gate bypassed.`);
|
||||
if (typeof machine.abortMovement === "function") {
|
||||
await machine.abortMovement(reason);
|
||||
}
|
||||
@@ -645,33 +740,12 @@ class MachineGroup {
|
||||
async optimalControl(Qd, powerCap = Infinity) {
|
||||
|
||||
try{
|
||||
//we need to force the pressures of all machines to be equal to the highest pressure measured in the group
|
||||
// this is to ensure a correct evaluation of the flow and power consumption
|
||||
const pressures = Object.entries(this.machines).map(([_machineId, machine]) => {
|
||||
return {
|
||||
downstream: this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.DOWNSTREAM, this.unitPolicy.canonical.pressure),
|
||||
upstream: this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, this.unitPolicy.canonical.pressure)
|
||||
};
|
||||
});
|
||||
|
||||
const maxDownstream = Math.max(...pressures.map(p => p.downstream));
|
||||
const minUpstream = Math.min(...pressures.map(p => p.upstream));
|
||||
|
||||
this.logger.debug(`Max downstream pressure: ${maxDownstream}, Min upstream pressure: ${minUpstream}`);
|
||||
|
||||
//set the pressures
|
||||
Object.entries(this.machines).forEach(([_machineId, machine]) => {
|
||||
if(machine.state.getCurrentState() !== "operational" && machine.state.getCurrentState() !== "accelerating" && machine.state.getCurrentState() !== "decelerating"){
|
||||
|
||||
//Equilize pressures over all machines so we can make a proper calculation
|
||||
this._writeChildMeasurement(machine, "pressure", "measured", POSITIONS.DOWNSTREAM, maxDownstream, this.unitPolicy.canonical.pressure);
|
||||
this._writeChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, minUpstream, this.unitPolicy.canonical.pressure);
|
||||
|
||||
// after updating the measurement directly we need to force the update of the value OLIFANT this is not so clear now in the code
|
||||
// we need to find a better way to do this but for now it works
|
||||
machine.getMeasuredPressure();
|
||||
if (Object.keys(this.machines).length === 0) {
|
||||
this.logger.warn("No machines registered. Cannot execute optimal control.");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this._equalizeOperatingPoint();
|
||||
|
||||
//fetch dynamic totals
|
||||
const dynamicTotals = this.dynamicTotals;
|
||||
@@ -682,7 +756,9 @@ class MachineGroup {
|
||||
}, {});
|
||||
|
||||
if( Qd <= 0 ) {
|
||||
//if Qd is 0 turn all machines off and exit early
|
||||
this.logger.debug("Flow demand <= 0, turning all machines off.");
|
||||
await this.turnOffAllMachines();
|
||||
return;
|
||||
}
|
||||
|
||||
if( Qd < dynamicTotals.flow.min && Qd > 0 ){
|
||||
@@ -727,9 +803,15 @@ class MachineGroup {
|
||||
const debugInfo = bestResult.bestCombination.map(({ machineId, flow }) => `${machineId}: ${flow.toFixed(2)} units`).join(" | ");
|
||||
this.logger.debug(`Moving to demand: ${Qd.toFixed(2)} -> Pumps: [${debugInfo}] => Total Power: ${bestResult.bestPower.toFixed(2)}`);
|
||||
|
||||
//store the total delivered power
|
||||
// Store the optimizer's INTENT on AT_EQUIPMENT (what we
|
||||
// commanded). DOWNSTREAM is reserved for the live aggregate
|
||||
// written by handlePressureChange — PS subscribes to that
|
||||
// for net-flow computation and must see what pumps are
|
||||
// actually delivering, not the planned target. Writing
|
||||
// bestFlow to DOWNSTREAM here would clobber the live value
|
||||
// every handleInput tick (see ps-mgc-flow-contract test).
|
||||
this._writeMeasurement("power", "predicted", POSITIONS.AT_EQUIPMENT, bestResult.bestPower, this.unitPolicy.canonical.power);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.DOWNSTREAM, bestResult.bestFlow, this.unitPolicy.canonical.flow);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.AT_EQUIPMENT, bestResult.bestFlow, this.unitPolicy.canonical.flow);
|
||||
this.measurements.type("efficiency").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestFlow / bestResult.bestPower);
|
||||
this.measurements.type("Ncog").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(bestResult.bestCog);
|
||||
|
||||
@@ -745,18 +827,50 @@ class MachineGroup {
|
||||
flow = 0;
|
||||
}
|
||||
|
||||
if( (flow <= 0 ) && ( machineStates[machineId] === "operational" || machineStates[machineId] === "accelerating" || machineStates[machineId] === "decelerating" ) ){
|
||||
// Dispatch policy: send the setpoint to ANY pump that
|
||||
// should be running (flow > 0), not just operational
|
||||
// ones. rotatingMachine.state.moveTo handles queueing:
|
||||
// - operational → execute immediately
|
||||
// - accelerating /
|
||||
// decelerating → unpark post-abort residue
|
||||
// and execute (state.js fix)
|
||||
// - idle / starting /
|
||||
// warmingup / stopping /
|
||||
// coolingdown → save as delayedMove,
|
||||
// auto-fires on next
|
||||
// transition to operational
|
||||
//
|
||||
// CRUCIAL ORDERING: flowmovement BEFORE execsequence
|
||||
// startup. If we awaited startup first (~3 s), other
|
||||
// concurrent MGC.handleInput calls would update this
|
||||
// pump's delayedMove during the startup window. When
|
||||
// startup completes, transitionToState('operational')
|
||||
// correctly fires the LATEST delayedMove. But then this
|
||||
// call's chained `await flowmovement(stale)` would run
|
||||
// on an already-operational pump and overwrite the
|
||||
// correct position with the stale snapshot value.
|
||||
//
|
||||
// By sending flowmovement first, the setpoint lands in
|
||||
// delayedMove while the pump is still idle. Concurrent
|
||||
// calls overwrite delayedMove with newer setpoints. The
|
||||
// final transitionToState('operational') at the end of
|
||||
// startup fires whichever delayedMove is current — the
|
||||
// genuinely latest demand wins.
|
||||
//
|
||||
// See test/integration/idle-startup-deadlock.integration.test.js
|
||||
// Scenario 4 for the deterministic reproducer.
|
||||
const state = machineStates[machineId];
|
||||
if (flow > 0) {
|
||||
await machine.handleInput("parent", "flowmovement", this._canonicalToOutputFlow(flow));
|
||||
if (state === "idle") {
|
||||
await machine.handleInput("parent", "execsequence", "startup");
|
||||
}
|
||||
} else if (state === "operational" || state === "accelerating" || state === "decelerating") {
|
||||
await machine.handleInput("parent", "execsequence", "shutdown");
|
||||
}
|
||||
|
||||
if(machineStates[machineId] === "idle" && flow > 0){
|
||||
await machine.handleInput("parent", "execsequence", "startup");
|
||||
await machine.handleInput("parent", "flowmovement", flow);
|
||||
}
|
||||
|
||||
if(machineStates[machineId] === "operational" && flow > 0 ){
|
||||
await machine.handleInput("parent", "flowmovement", flow);
|
||||
}
|
||||
// flow ≤ 0 AND state already in shutdown chain (idle/
|
||||
// stopping/coolingdown/off/emergencystop) → nothing
|
||||
// to do, preserve previous behaviour.
|
||||
}));
|
||||
}
|
||||
catch(err){
|
||||
@@ -764,32 +878,104 @@ class MachineGroup {
|
||||
}
|
||||
}
|
||||
|
||||
// Equalize pressure across all machines for machines that are not running. This is needed to ensure accurate flow and power predictions.
|
||||
// Equalize all machines (running + idle) to the group's header
|
||||
// operating point so dynamicTotals + combination optimization see one
|
||||
// consistent operating point. See _equalizeOperatingPoint for the
|
||||
// implementation rationale.
|
||||
equalizePressure(){
|
||||
// Get current pressures from all machines
|
||||
const pressures = Object.entries(this.machines).map(([_machineId, machine]) => {
|
||||
return {
|
||||
downstream: this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.DOWNSTREAM, this.unitPolicy.canonical.pressure),
|
||||
upstream: this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, this.unitPolicy.canonical.pressure)
|
||||
};
|
||||
this._equalizeOperatingPoint();
|
||||
}
|
||||
|
||||
// Force every machine's predict-curve interpolators to use the same
|
||||
// (header) differential pressure for the duration of MGC's optimization.
|
||||
//
|
||||
// Why direct fDimension assignment, not measurement writes:
|
||||
// rotatingMachine._getPreferredPressureValue reads from each pressure
|
||||
// sensor child (keyed by child id) BEFORE falling back to the position-
|
||||
// level measurement. MGC has no way to know which child id a pump's
|
||||
// sensor uses, so writes via _writeChildMeasurement land at the
|
||||
// "default" child key and are never consulted by getMeasuredPressure().
|
||||
// Setting fDimension directly is the same effect getMeasuredPressure()
|
||||
// would have produced if its read had succeeded.
|
||||
//
|
||||
// Per-pump diagnostics are unaffected: this only mutates the predict
|
||||
// objects' interpolation parameter, NOT the pump's measurement container.
|
||||
// The pump's own emitted upstream/downstream measurements (and the
|
||||
// differential they imply) keep their real sensor values.
|
||||
//
|
||||
// Header source order:
|
||||
// 1. MGC's own header measurement (a measurement child registered at
|
||||
// DOWNSTREAM / UPSTREAM with MGC as parent). Authoritative manifold
|
||||
// reading when present.
|
||||
// 2. Worst-case envelope across pump-side sensors —
|
||||
// downstream = max (highest discharge load),
|
||||
// upstream = min of POSITIVE values (lowest suction = highest
|
||||
// required head). Zeros are filtered to skip pumps
|
||||
// that haven't emitted yet.
|
||||
_equalizeOperatingPoint(){
|
||||
if (Object.keys(this.machines).length === 0) return;
|
||||
|
||||
const groupHeaderDown = this.measurements
|
||||
.type("pressure").variant("measured").position(POSITIONS.DOWNSTREAM)
|
||||
.getCurrentValue(this.unitPolicy.canonical.pressure);
|
||||
const groupHeaderUp = this.measurements
|
||||
.type("pressure").variant("measured").position(POSITIONS.UPSTREAM)
|
||||
.getCurrentValue(this.unitPolicy.canonical.pressure);
|
||||
|
||||
const childDown = [];
|
||||
const childUp = [];
|
||||
Object.values(this.machines).forEach(machine => {
|
||||
const d = this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.DOWNSTREAM, this.unitPolicy.canonical.pressure);
|
||||
const u = this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, this.unitPolicy.canonical.pressure);
|
||||
if (Number.isFinite(d) && d > 0) childDown.push(d);
|
||||
if (Number.isFinite(u) && u > 0) childUp.push(u);
|
||||
});
|
||||
|
||||
// Find the highest downstream and lowest upstream pressure
|
||||
const maxDownstream = Math.max(...pressures.map(p => p.downstream));
|
||||
const minUpstream = Math.min(...pressures.map(p => p.upstream));
|
||||
const headerDownSrc = Number.isFinite(groupHeaderDown) && groupHeaderDown > 0 ? "header" : "max-child";
|
||||
const headerUpSrc = Number.isFinite(groupHeaderUp) && groupHeaderUp > 0 ? "header" : "min-child";
|
||||
const headerDownstream = headerDownSrc === "header" ? groupHeaderDown : (childDown.length ? Math.max(...childDown) : 0);
|
||||
const headerUpstream = headerUpSrc === "header" ? groupHeaderUp : (childUp.length ? Math.min(...childUp) : 0);
|
||||
|
||||
// Set consistent pressures across machines
|
||||
Object.entries(this.machines).forEach(([machineId, machine]) => {
|
||||
if(!this.isMachineActive(machineId)){
|
||||
this._writeChildMeasurement(machine, "pressure", "measured", POSITIONS.DOWNSTREAM, maxDownstream, this.unitPolicy.canonical.pressure);
|
||||
this._writeChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, minUpstream, this.unitPolicy.canonical.pressure);
|
||||
// Update the measured pressure value
|
||||
const pressure = machine.getMeasuredPressure();
|
||||
this.logger.debug(`Setting pressure for machine ${machineId} to ${pressure}`);
|
||||
const headerDiff = headerDownstream - headerUpstream;
|
||||
if (!Number.isFinite(headerDiff) || headerDiff <= 0) {
|
||||
this.logger.debug(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Equalizing operating point: down=${headerDownstream} (${headerDownSrc}), up=${headerUpstream} (${headerUpSrc}), diff=${headerDiff}`);
|
||||
|
||||
// Push the header operating point onto each pump's group-scope
|
||||
// predicts. The pump's individual predicts (driven by its own
|
||||
// sensors) are untouched; only the group view used by this MGC
|
||||
// is shifted. See rotatingMachine.setGroupOperatingPoint().
|
||||
Object.values(this.machines).forEach(machine => {
|
||||
if (typeof machine.setGroupOperatingPoint === "function") {
|
||||
machine.setGroupOperatingPoint(headerDownstream, headerUpstream);
|
||||
} else {
|
||||
// Older rotatingMachine without the group API — fall back
|
||||
// to direct fDimension write so the demo still works while
|
||||
// submodules are rolled forward.
|
||||
if (machine.predictFlow) machine.predictFlow.fDimension = headerDiff;
|
||||
if (machine.predictPower) machine.predictPower.fDimension = headerDiff;
|
||||
if (machine.predictCtrl) machine.predictCtrl.fDimension = headerDiff;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Group-scope read helpers ----------
|
||||
// Optimization paths read pump curves at the GROUP operating point,
|
||||
// not the pump's individual sensor-driven point. These helpers fall
|
||||
// back to the individual predicts if a pump hasn't been initialised
|
||||
// for group operation yet (first tick after registration).
|
||||
_groupFlow(machine) { return machine.groupPredictFlow ?? machine.predictFlow; }
|
||||
_groupPower(machine) { return machine.groupPredictPower ?? machine.predictPower; }
|
||||
_groupNCog(machine) { return machine.groupPredictFlow ? (machine.groupNCog ?? 0) : (machine.NCog ?? 0); }
|
||||
_groupCalcPower(machine, flow) {
|
||||
return typeof machine.groupCalcPower === "function"
|
||||
? machine.groupCalcPower(flow)
|
||||
: machine.inputFlowCalcPower(flow);
|
||||
}
|
||||
|
||||
isMachineActive(machineId){
|
||||
if(this.machines[machineId].state.getCurrentState() === "operational" || this.machines[machineId].state.getCurrentState() === "accelerating" || this.machines[machineId].state.getCurrentState() === "decelerating"){
|
||||
return true;
|
||||
@@ -890,7 +1076,7 @@ class MachineGroup {
|
||||
const machine = machinesInPriorityOrder[i];
|
||||
if (this.isMachineActive(machine.id)) {
|
||||
flowDistribution.push({ machineId: machine.id, flow: 0 });
|
||||
availableFlow -= machine.machine.predictFlow.currentFxyYMin;
|
||||
availableFlow -= this._groupFlow(machine.machine).currentFxyYMin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +1092,7 @@ class MachineGroup {
|
||||
for (let machine of remainingMachines) {
|
||||
flowDistribution.push({ machineId: machine.id, flow: distributedFlow });
|
||||
totalFlow += distributedFlow;
|
||||
totalPower += machine.machine.inputFlowCalcPower(distributedFlow);
|
||||
totalPower += this._groupCalcPower(machine.machine, distributedFlow);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -918,12 +1104,12 @@ class MachineGroup {
|
||||
while (totalFlow < Qd && i <= machinesInPriorityOrder.length) {
|
||||
Qd = Qd / i;
|
||||
|
||||
if(machinesInPriorityOrder[i-1].machine.predictFlow.currentFxyYMax >= Qd){
|
||||
if(this._groupFlow(machinesInPriorityOrder[i-1].machine).currentFxyYMax >= Qd){
|
||||
for ( let i2 = 0; i2 < i ; i2++){
|
||||
if(! this.isMachineActive(machinesInPriorityOrder[i2].id)){
|
||||
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
|
||||
totalFlow += Qd;
|
||||
totalPower += machinesInPriorityOrder[i2].machine.inputFlowCalcPower(Qd);
|
||||
totalPower += this._groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -944,7 +1130,7 @@ class MachineGroup {
|
||||
|
||||
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd});
|
||||
totalFlow += Qd ;
|
||||
totalPower += machinesInPriorityOrder[i].machine.inputFlowCalcPower(Qd);
|
||||
totalPower += this._groupCalcPower(machinesInPriorityOrder[i].machine, Qd);
|
||||
|
||||
}
|
||||
break;
|
||||
@@ -959,9 +1145,11 @@ class MachineGroup {
|
||||
|
||||
this.logger.debug(`Priority control for demand: ${totalFlow.toFixed(2)} -> Active pumps: [${debugInfo}] => Total Power: ${totalPower.toFixed(2)}`);
|
||||
|
||||
// Store measurements
|
||||
// Store the planned distribution as INTENT on AT_EQUIPMENT.
|
||||
// DOWNSTREAM (live aggregate) is owned by handlePressureChange.
|
||||
// Writing the plan here would clobber PS's outflow signal.
|
||||
this._writeMeasurement("power", "predicted", POSITIONS.AT_EQUIPMENT, totalPower, this.unitPolicy.canonical.power);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.DOWNSTREAM, totalFlow, this.unitPolicy.canonical.flow);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.AT_EQUIPMENT, totalFlow, this.unitPolicy.canonical.flow);
|
||||
this.measurements.type("efficiency").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(totalFlow / totalPower);
|
||||
this.measurements.type("Ncog").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(totalCog);
|
||||
|
||||
@@ -972,14 +1160,17 @@ class MachineGroup {
|
||||
this.logger.debug(this.machines[machineId].state);
|
||||
const currentState = this.machines[machineId].state.getCurrentState();
|
||||
|
||||
if (flow <= 0 && (currentState === "operational" || currentState === "accelerating" || currentState === "decelerating")) {
|
||||
await machine.handleInput("parent", "execsequence", "shutdown");
|
||||
}
|
||||
else if (currentState === "idle" && flow > 0) {
|
||||
// Same dispatch shape as optimalControl — see the comment
|
||||
// there for the rationale. flowmovement BEFORE startup so
|
||||
// concurrent retargets can update delayedMove without a
|
||||
// stale chained flowmovement overwriting it after startup.
|
||||
if (flow > 0) {
|
||||
await machine.handleInput("parent", "flowmovement", this._canonicalToOutputFlow(flow));
|
||||
if (currentState === "idle") {
|
||||
await machine.handleInput("parent", "execsequence", "startup");
|
||||
}
|
||||
else if (currentState === "operational" && flow > 0) {
|
||||
await machine.handleInput("parent", "flowmovement", flow);
|
||||
} else if (currentState === "operational" || currentState === "accelerating" || currentState === "decelerating") {
|
||||
await machine.handleInput("parent", "execsequence", "shutdown");
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -993,10 +1184,7 @@ class MachineGroup {
|
||||
try{
|
||||
// stop all machines if input is negative
|
||||
if(input < 0 ){
|
||||
//turn all machines off
|
||||
await Promise.all(Object.entries(this.machines).map(async ([machineId, machine]) => {
|
||||
if (this.isMachineActive(machineId)) { await machine.handleInput("parent", "execsequence", "shutdown"); }
|
||||
}));
|
||||
await this.turnOffAllMachines();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1086,8 +1274,12 @@ class MachineGroup {
|
||||
}
|
||||
});
|
||||
|
||||
// Write to AT_EQUIPMENT not DOWNSTREAM. handlePressureChange
|
||||
// is the canonical writer of DOWNSTREAM (the live aggregate
|
||||
// that PS subscribes to for outflow). See optimalControl
|
||||
// comment above.
|
||||
this._writeMeasurement("power", "predicted", POSITIONS.AT_EQUIPMENT, totalPower.reduce((a, b) => a + b, 0), this.unitPolicy.canonical.power);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.DOWNSTREAM, totalFlow.reduce((a, b) => a + b, 0), this.unitPolicy.canonical.flow);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.AT_EQUIPMENT, totalFlow.reduce((a, b) => a + b, 0), this.unitPolicy.canonical.flow);
|
||||
|
||||
if(totalPower.reduce((a, b) => a + b, 0) > 0){
|
||||
this.measurements.type("efficiency").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(totalFlow.reduce((a, b) => a + b, 0) / totalPower.reduce((a, b) => a + b, 0));
|
||||
@@ -1101,6 +1293,37 @@ class MachineGroup {
|
||||
|
||||
async handleInput(source, demand, powerCap = Infinity, priorityList = null) {
|
||||
|
||||
// Serialize dispatches: if a previous handleInput is still
|
||||
// awaiting pump movements, park the latest demand and return.
|
||||
// The in-flight dispatch's `finally` block will pick it up.
|
||||
// See rotatingMachine state.delayedMove for the analogous
|
||||
// pattern at the pump level.
|
||||
if (this._dispatchInFlight) {
|
||||
this._delayedCall = { source, demand, powerCap, priorityList };
|
||||
this.logger.debug(`Dispatch in flight; deferring demand=${demand} until current pump moves complete.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._dispatchInFlight = true;
|
||||
try {
|
||||
return await this._runDispatch(source, demand, powerCap, priorityList);
|
||||
} finally {
|
||||
this._dispatchInFlight = false;
|
||||
// Pick up the latest deferred call (intermediate values were
|
||||
// stomped while we were busy — only the last one matters).
|
||||
if (this._delayedCall) {
|
||||
const next = this._delayedCall;
|
||||
this._delayedCall = null;
|
||||
this.logger.debug(`Dispatch finished; picking up deferred demand=${next.demand}.`);
|
||||
// Recursive call re-enters the gate; safe because
|
||||
// _dispatchInFlight has been reset to false above.
|
||||
await this.handleInput(next.source, next.demand, next.powerCap, next.priorityList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _runDispatch(source, demand, powerCap = Infinity, priorityList = null) {
|
||||
|
||||
const demandQ = parseFloat(demand);
|
||||
|
||||
if(!Number.isFinite(demandQ)){
|
||||
@@ -1126,35 +1349,40 @@ class MachineGroup {
|
||||
return;
|
||||
}
|
||||
|
||||
if (demandQ < this.absoluteTotals.flow.min) {
|
||||
this.logger.warn(`Flow demand ${demandQ} is below minimum possible flow ${this.absoluteTotals.flow.min}. Capping to minimum flow.`);
|
||||
demandQout = this.absoluteTotals.flow.min;
|
||||
} else if (demandQout > this.absoluteTotals.flow.max) {
|
||||
this.logger.warn(`Flow demand ${demandQ} is above maximum possible flow ${this.absoluteTotals.flow.max}. Capping to maximum flow.`);
|
||||
demandQout = this.absoluteTotals.flow.max;
|
||||
}else if(demandQout <= 0){
|
||||
if (demandQ <= 0) {
|
||||
this.logger.debug(`Turning machines off`);
|
||||
demandQout = 0;
|
||||
//return early and turn all machines off
|
||||
this.turnOffAllMachines();
|
||||
await this.turnOffAllMachines();
|
||||
return;
|
||||
} else if (demandQ < this.absoluteTotals.flow.min) {
|
||||
this.logger.warn(`Flow demand ${demandQ} is below minimum possible flow ${this.absoluteTotals.flow.min}. Capping to minimum flow.`);
|
||||
demandQout = this.absoluteTotals.flow.min;
|
||||
} else if (demandQ > this.absoluteTotals.flow.max) {
|
||||
this.logger.warn(`Flow demand ${demandQ} is above maximum possible flow ${this.absoluteTotals.flow.max}. Capping to maximum flow.`);
|
||||
demandQout = this.absoluteTotals.flow.max;
|
||||
} else {
|
||||
demandQout = demandQ;
|
||||
}
|
||||
break;
|
||||
|
||||
case "normalized":
|
||||
this.logger.debug(`Normalizing flow demand: ${demandQ} with min: ${dynamicTotals.flow.min} and max: ${dynamicTotals.flow.max}`);
|
||||
if(demand < 0){
|
||||
this.logger.debug(`Turning machines off`);
|
||||
// demand <= 0 → off. Previously only `< 0` triggered off,
|
||||
// so demand=0 fell through to interpolate(0, 0..100, min..max)
|
||||
// which returns flow.min — i.e., a pumpingStation dead-zone
|
||||
// (level in [stopLevel, startLevel] sending percControl=0)
|
||||
// would silently keep a pump running at min flow,
|
||||
// balancing inflow and pinning the basin in the dead band.
|
||||
if (demandQ <= 0) {
|
||||
this.logger.debug(`Demand ≤ 0 — turning all machines off`);
|
||||
demandQout = 0;
|
||||
//return early and turn all machines off
|
||||
this.turnOffAllMachines();
|
||||
await this.turnOffAllMachines();
|
||||
return;
|
||||
}
|
||||
else{
|
||||
// Scale demand to 0-100% linear between min and max flow this is auto capped
|
||||
// Scale demand to flow range. interpolate_lin_single_point
|
||||
// maps demandQ (0..100) onto (flow.min..flow.max) linearly.
|
||||
demandQout = this.interpolation.interpolate_lin_single_point(demandQ, 0, 100, dynamicTotals.flow.min, dynamicTotals.flow.max );
|
||||
this.logger.debug(`Normalized flow demand ${demandQ}% to: ${demandQout} Q units`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1193,9 +1421,35 @@ class MachineGroup {
|
||||
}
|
||||
|
||||
async turnOffAllMachines(){
|
||||
// Cancel any deferred dispatch — turnOff is the latest user intent,
|
||||
// a stale 1% keep-alive must not re-engage a pump after we shut down.
|
||||
this._delayedCall = null;
|
||||
// Per-pump shutdown serialization: PS calls turnOffAllMachines on
|
||||
// every tick (every 2 s) once level < stopLevel. Without this guard,
|
||||
// each new shutdown call hits the still-running prior shutdown's
|
||||
// movement transitions and triggers abortCurrentMovement, which
|
||||
// bounces the pump back to 'operational' before the sequence loop
|
||||
// can reach stopping/coolingdown/idle. Net effect: pump never
|
||||
// actually shuts down. Track shutdown-in-flight per pump and skip
|
||||
// if already underway.
|
||||
if (!this._shutdownInFlight) this._shutdownInFlight = new Set();
|
||||
await Promise.all(Object.entries(this.machines).map(async ([machineId, machine]) => {
|
||||
if (this.isMachineActive(machineId)) { await machine.handleInput("parent", "execsequence", "shutdown"); }
|
||||
if (this._shutdownInFlight.has(machineId)) return;
|
||||
if (this.isMachineActive(machineId)) {
|
||||
this._shutdownInFlight.add(machineId);
|
||||
try {
|
||||
await machine.handleInput("parent", "execsequence", "shutdown");
|
||||
} finally {
|
||||
this._shutdownInFlight.delete(machineId);
|
||||
}
|
||||
}
|
||||
}));
|
||||
// Update measurements to zero so the parent (PS) sees the
|
||||
// outflow drop immediately — without this the PS keeps the
|
||||
// last active flow value cached and computes wrong net flow.
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.DOWNSTREAM, 0, this.unitPolicy.canonical.flow);
|
||||
this._writeMeasurement("flow", "predicted", POSITIONS.AT_EQUIPMENT, 0, this.unitPolicy.canonical.flow);
|
||||
this._writeMeasurement("power", "predicted", POSITIONS.AT_EQUIPMENT, 0, this.unitPolicy.canonical.power);
|
||||
}
|
||||
|
||||
_buildUnitPolicy(config = {}) {
|
||||
@@ -1244,6 +1498,13 @@ class MachineGroup {
|
||||
}
|
||||
}
|
||||
|
||||
_canonicalToOutputFlow(value) {
|
||||
const from = this.unitPolicy.canonical.flow;
|
||||
const to = this.unitPolicy.output.flow;
|
||||
if (!from || !to || from === to) return value;
|
||||
return convert(value).from(from).to(to);
|
||||
}
|
||||
|
||||
_outputUnitForType(type) {
|
||||
switch (String(type || '').toLowerCase()) {
|
||||
case 'flow':
|
||||
|
||||
211
test/integration/demand-cycle-walkthrough.integration.test.js
Normal file
211
test/integration/demand-cycle-walkthrough.integration.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// MGC demand-cycle walkthrough — drive the machine group through a
|
||||
// configurable demand sweep and print a clean per-step snapshot of every
|
||||
// pump's state, ctrl%, flow and power. This is a diagnostic test, not a
|
||||
// strict invariant guard: it asserts only the basics (no stuck states,
|
||||
// total flow tracks demand) and prints a readable table for visual
|
||||
// inspection.
|
||||
//
|
||||
// Knobs (env vars):
|
||||
// STEP_PERCENT — demand step in percent (default 10)
|
||||
// DWELL_MS — wait per step for movement (default 800)
|
||||
// HEAD_MBAR — pump head in mbar (default 1100)
|
||||
// N_PUMPS — number of identical pumps (default 3)
|
||||
// LOG_DEBUG=1 — enable verbose domain logging (default off)
|
||||
//
|
||||
// Run:
|
||||
// node --test nodes/machineGroupControl/test/integration/demand-cycle-walkthrough.integration.test.js
|
||||
// STEP_PERCENT=5 DWELL_MS=400 node --test ...
|
||||
// LOG_DEBUG=1 node --test ... # firehose mode
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const STEP_PERCENT = parseFloat(process.env.STEP_PERCENT || '10');
|
||||
const DWELL_MS = parseInt(process.env.DWELL_MS || '800', 10);
|
||||
const HEAD_MBAR = parseFloat(process.env.HEAD_MBAR || '1100');
|
||||
const N_PUMPS = parseInt(process.env.N_PUMPS || '3', 10);
|
||||
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
|
||||
|
||||
const HEAD_MBAR_UP = 0;
|
||||
const HEAD_MBAR_DOWN = HEAD_MBAR;
|
||||
|
||||
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
|
||||
|
||||
const stateConfig = {
|
||||
general: { logging: logCfg },
|
||||
state: { current: 'idle' },
|
||||
// Fast ramp so each step settles within DWELL_MS.
|
||||
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
|
||||
// Zero sequence-step durations — startup/shutdown are instantaneous so
|
||||
// the per-step delta is purely the optimizer's response, not waiting
|
||||
// for the FSM.
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
};
|
||||
|
||||
function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' }, // demand expressed as 0..100 %
|
||||
mode: { current: 'optimalcontrol' }, // production mode
|
||||
};
|
||||
}
|
||||
|
||||
function buildGroup() {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
||||
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
|
||||
for (const m of pumps) {
|
||||
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
mgc.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
}
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// States where the pump is not actually producing flow/power. When the FSM
|
||||
// is parked in any of these, predictFlow.outputY / predictPower.outputY
|
||||
// still reflect the curve floor at the current operating point — that is
|
||||
// useful for the optimizer but misleading in this walkthrough table. Show
|
||||
// zeros instead so each row's per-pump column matches the optimizer's
|
||||
// chosen split and ΣQ matches Qd.
|
||||
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
|
||||
|
||||
function snapshot(pump) {
|
||||
const state = pump.state.getCurrentState();
|
||||
const ctrl = Number(pump.state.getCurrentPosition?.() ?? 0);
|
||||
const running = !NON_RUNNING.has(state);
|
||||
const flow = running ? Number(pump.predictFlow?.outputY ?? 0) * 3600 : 0; // m³/s → m³/h
|
||||
const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0; // W → kW
|
||||
return { state, ctrl, flow, power };
|
||||
}
|
||||
|
||||
function fmt(x, w, d = 1) { return Number.isFinite(x) ? x.toFixed(d).padStart(w) : ' n/a'.padStart(w); }
|
||||
|
||||
function printHeader(pumps) {
|
||||
const head = ['cmd%'.padStart(5), 'Qd m³/h'.padStart(9)];
|
||||
for (const p of pumps) {
|
||||
head.push('|', `${p.config.general.id}`.padEnd(8), 'state'.padEnd(13), 'ctrl%'.padStart(6),
|
||||
'Q m³/h'.padStart(7), 'kW'.padStart(6));
|
||||
}
|
||||
head.push('|', 'ΣQ m³/h'.padStart(8), 'ΣkW'.padStart(6));
|
||||
const line = head.join(' ');
|
||||
console.log(line);
|
||||
console.log('─'.repeat(line.length));
|
||||
}
|
||||
|
||||
function printRow(pct, demandQout_m3h, pumps) {
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
const totalP = snaps.reduce((s, x) => s + x.power, 0);
|
||||
const cells = [fmt(pct, 5), fmt(demandQout_m3h, 9)];
|
||||
for (let i = 0; i < pumps.length; i++) {
|
||||
const s = snaps[i];
|
||||
cells.push('|', ''.padEnd(8), s.state.padEnd(13), fmt(s.ctrl, 6), fmt(s.flow, 7), fmt(s.power, 6));
|
||||
}
|
||||
cells.push('|', fmt(totalQ, 8), fmt(totalP, 6));
|
||||
console.log(cells.join(' '));
|
||||
return { totalQ, totalP, snaps };
|
||||
}
|
||||
|
||||
test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps, step=${STEP_PERCENT}%`, async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
|
||||
// Bring all pumps to operational up-front so the very first row of the
|
||||
// table reflects the optimizer's response, not "the FSM is still
|
||||
// booting".
|
||||
for (const m of pumps) await m.handleInput('parent', 'execsequence', 'startup');
|
||||
for (let i = 0; i < 50 && pumps.some(p => p.state.getCurrentState() !== 'operational'); i++) await sleep(20);
|
||||
for (const p of pumps) {
|
||||
assert.equal(p.state.getCurrentState(), 'operational',
|
||||
`pre-condition: pump ${p.config.general.id} should be operational; got ${p.state.getCurrentState()}`);
|
||||
}
|
||||
|
||||
const dyn = mgc.calcDynamicTotals();
|
||||
const flowMin_m3h = dyn.flow.min * 3600;
|
||||
const flowMax_m3h = dyn.flow.max * 3600;
|
||||
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
|
||||
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
|
||||
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
|
||||
|
||||
console.log('');
|
||||
console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`);
|
||||
console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` scaling=normalized: 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` (demand ≤ 0% turns ALL pumps off — see MGC handleInput)`);
|
||||
console.log('');
|
||||
printHeader(pumps);
|
||||
|
||||
// Build demand sweep: 0..100% up, then 100..0% down.
|
||||
const upSteps = [];
|
||||
for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100));
|
||||
const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100
|
||||
const sequence = [...upSteps, ...downSteps];
|
||||
|
||||
let stuckSeen = 0;
|
||||
for (const pct of sequence) {
|
||||
await mgc.handleInput('parent', pct);
|
||||
await sleep(DWELL_MS);
|
||||
|
||||
// Mirror MGC's normalized→absolute mapping for the printed Qd column.
|
||||
const demandQout_m3h = pct <= 0
|
||||
? 0
|
||||
: (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h;
|
||||
|
||||
const { totalQ, snaps } = printRow(pct, demandQout_m3h, pumps);
|
||||
|
||||
// Loose invariants:
|
||||
// - demand > 0% → station total flow within 10% of optimizer's chosen
|
||||
// Qout (allow slack: optimizer may pick a smaller combo for
|
||||
// efficiency, in which case totalQ falls below demand only inside
|
||||
// the per-pump curve envelope; we ONLY check above feasibility).
|
||||
// - no pump should sit in a residue state ('accelerating' /
|
||||
// 'decelerating') AFTER the dwell — that's the deadlock symptom
|
||||
// the abort-deadlock test guards against.
|
||||
for (const s of snaps) {
|
||||
if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
|
||||
}
|
||||
|
||||
if (pct === 0) {
|
||||
// Demand 0% must turn ALL pumps off (or to a non-running state).
|
||||
for (const s of snaps) {
|
||||
assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state),
|
||||
`demand 0% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Stuck-state observations across ${sequence.length} steps: ${stuckSeen}`);
|
||||
assert.equal(stuckSeen, 0,
|
||||
`${stuckSeen} pump×step observations parked in accelerating/decelerating after dwell — ` +
|
||||
`would indicate the abort-deadlock regression has returned (state.js post-abort residue).`);
|
||||
});
|
||||
227
test/integration/distribution-power-table.integration.test.js
Normal file
227
test/integration/distribution-power-table.integration.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* machineGroupControl vs naive strategies — real pump curves
|
||||
*
|
||||
* Station: 2× hidrostal H05K-S03R + 1× hidrostal C5-D03R-SHN1
|
||||
* ΔP = 2000 mbar
|
||||
*
|
||||
* Compares the ACTUAL machineGroupControl optimalControl algorithm against
|
||||
* naive baselines. All strategies must deliver exactly Qd.
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const DIFF_MBAR = 2000;
|
||||
const UP_MBAR = 500;
|
||||
const DOWN_MBAR = UP_MBAR + DIFF_MBAR;
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 }
|
||||
};
|
||||
|
||||
function machineConfig(id, model) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model, supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] }
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'absolute' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
|
||||
function injectPressure(m) {
|
||||
m.updateMeasuredPressure(UP_MBAR, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(DOWN_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
}
|
||||
|
||||
/* ---- naive baselines (pumps OFF = 0 flow, 0 power) ---- */
|
||||
|
||||
function distribute(machines, running, rawDist, Qd) {
|
||||
const dist = {};
|
||||
for (const id of Object.keys(machines)) dist[id] = 0;
|
||||
for (const id of running) {
|
||||
const m = machines[id];
|
||||
dist[id] = Math.min(m.predictFlow.currentFxyYMax, Math.max(m.predictFlow.currentFxyYMin, rawDist[id] || 0));
|
||||
}
|
||||
for (let pass = 0; pass < 20; pass++) {
|
||||
let rem = Qd - running.reduce((s, id) => s + dist[id], 0);
|
||||
if (Math.abs(rem) < 1e-9) break;
|
||||
for (const id of running) {
|
||||
if (Math.abs(rem) < 1e-9) break;
|
||||
const m = machines[id];
|
||||
const cap = rem > 0 ? m.predictFlow.currentFxyYMax - dist[id] : dist[id] - m.predictFlow.currentFxyYMin;
|
||||
if (cap > 1e-9) { const t = Math.min(Math.abs(rem), cap); dist[id] += rem > 0 ? t : -t; rem += rem > 0 ? -t : t; }
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
function spillover(machines, Qd) {
|
||||
const sorted = Object.keys(machines).sort((a, b) => machines[a].predictFlow.currentFxyYMax - machines[b].predictFlow.currentFxyYMax);
|
||||
let running = [], maxCap = 0;
|
||||
for (const id of sorted) { running.push(id); maxCap += machines[id].predictFlow.currentFxyYMax; if (maxCap >= Qd) break; }
|
||||
const raw = {}; let rem = Qd;
|
||||
for (const id of running) { raw[id] = rem; rem = Math.max(0, rem - machines[id].predictFlow.currentFxyYMax); }
|
||||
const dist = distribute(machines, running, raw, Qd);
|
||||
let p = 0, f = 0;
|
||||
for (const id of running) { p += machines[id].inputFlowCalcPower(dist[id]); f += dist[id]; }
|
||||
return { dist, power: p, flow: f, combo: running };
|
||||
}
|
||||
|
||||
function equalAllOn(machines, Qd) {
|
||||
const ids = Object.keys(machines);
|
||||
const raw = {}; for (const id of ids) raw[id] = Qd / ids.length;
|
||||
const dist = distribute(machines, ids, raw, Qd);
|
||||
let p = 0, f = 0;
|
||||
for (const id of ids) { p += machines[id].inputFlowCalcPower(dist[id]); f += dist[id]; }
|
||||
return { dist, power: p, flow: f, combo: ids };
|
||||
}
|
||||
|
||||
/* ---- test ---- */
|
||||
|
||||
test('machineGroupControl vs naive baselines — real curves, verified flow', async () => {
|
||||
const mg = new MachineGroup(groupConfig());
|
||||
const machines = {};
|
||||
|
||||
for (const [id, model] of [['H05K-1','hidrostal-H05K-S03R'],['H05K-2','hidrostal-H05K-S03R'],['C5','hidrostal-C5-D03R-SHN1']]) {
|
||||
const m = new Machine(machineConfig(id, model), stateConfig);
|
||||
injectPressure(m);
|
||||
mg.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
machines[id] = m;
|
||||
}
|
||||
|
||||
const toH = (v) => +(v * 3600).toFixed(1);
|
||||
const CANON_FLOW = 'm3/s';
|
||||
const CANON_POWER = 'W';
|
||||
|
||||
console.log(`\n=== STATION: 2×H05K + 1×C5 @ ΔP=${DIFF_MBAR} mbar ===`);
|
||||
console.table(Object.entries(machines).map(([id, m]) => ({
|
||||
id,
|
||||
'min (m³/h)': toH(m.predictFlow.currentFxyYMin),
|
||||
'max (m³/h)': toH(m.predictFlow.currentFxyYMax),
|
||||
'BEP (m³/h)': toH(m.predictFlow.currentFxyYMin + (m.predictFlow.currentFxyYMax - m.predictFlow.currentFxyYMin) * m.NCog),
|
||||
NCog: +m.NCog.toFixed(3),
|
||||
})));
|
||||
|
||||
const minQ = Math.max(...Object.values(machines).map(m => m.predictFlow.currentFxyYMin));
|
||||
const maxQ = Object.values(machines).reduce((s, m) => s + m.predictFlow.currentFxyYMax, 0);
|
||||
const demandPcts = [0.10, 0.25, 0.50, 0.75, 0.90];
|
||||
const rows = [];
|
||||
|
||||
for (const pct of demandPcts) {
|
||||
const Qd = minQ + (maxQ - minQ) * pct;
|
||||
|
||||
// Reset all machines to idle, re-inject pressure
|
||||
for (const m of Object.values(machines)) {
|
||||
if (m.state.getCurrentState() !== 'idle') await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
injectPressure(m);
|
||||
}
|
||||
|
||||
// Run machineGroupControl optimalControl with absolute scaling
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
|
||||
// Read ACTUAL per-pump state (not the MGC summary which may be stale)
|
||||
let mgcPower = 0, mgcFlow = 0;
|
||||
const mgcCombo = [];
|
||||
const mgcDist = {};
|
||||
for (const [id, m] of Object.entries(machines)) {
|
||||
const state = m.state.getCurrentState();
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(CANON_FLOW) || 0;
|
||||
const power = m.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue(CANON_POWER) || 0;
|
||||
mgcDist[id] = { flow, power, state };
|
||||
if (state === 'operational' || state === 'warmingup' || state === 'accelerating') {
|
||||
mgcCombo.push(id);
|
||||
mgcPower += power;
|
||||
mgcFlow += flow;
|
||||
}
|
||||
}
|
||||
|
||||
// Naive baselines
|
||||
const sp = spillover(machines, Qd);
|
||||
const ea = equalAllOn(machines, Qd);
|
||||
|
||||
const best = Math.min(mgcPower, sp.power, ea.power);
|
||||
const delta = (v) => best > 0 ? `${(((v - best) / best) * 100).toFixed(1)}%` : '';
|
||||
|
||||
rows.push({
|
||||
demand: `${(pct * 100)}%`,
|
||||
'Qd (m³/h)': toH(Qd),
|
||||
'MGC kW': +(mgcPower / 1000).toFixed(1),
|
||||
'MGC flow': toH(mgcFlow),
|
||||
'MGC pumps': mgcCombo.join('+') || 'none',
|
||||
'Spill kW': +(sp.power / 1000).toFixed(1),
|
||||
'Spill flow': toH(sp.flow),
|
||||
'Spill pumps': sp.combo.join('+'),
|
||||
'EqAll kW': +(ea.power / 1000).toFixed(1),
|
||||
'EqAll flow': toH(ea.flow),
|
||||
'MGC Δ': delta(mgcPower),
|
||||
'Spill Δ': delta(sp.power),
|
||||
'EqAll Δ': delta(ea.power),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== POWER + FLOW COMPARISON (★ = best, all must deliver Qd) ===');
|
||||
console.table(rows);
|
||||
|
||||
// Per-pump detail at each demand level
|
||||
for (const pct of demandPcts) {
|
||||
const Qd = minQ + (maxQ - minQ) * pct;
|
||||
|
||||
for (const m of Object.values(machines)) {
|
||||
if (m.state.getCurrentState() !== 'idle') await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
injectPressure(m);
|
||||
}
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
|
||||
const detail = Object.entries(machines).map(([id, m]) => {
|
||||
const state = m.state.getCurrentState();
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(CANON_FLOW) || 0;
|
||||
const power = m.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue(CANON_POWER) || 0;
|
||||
return {
|
||||
pump: id,
|
||||
state,
|
||||
'flow (m³/h)': toH(flow),
|
||||
'power (kW)': +(power / 1000).toFixed(1),
|
||||
};
|
||||
});
|
||||
console.log(`\n--- MGC per-pump @ ${(pct*100)}% (${toH(Qd)} m³/h) ---`);
|
||||
console.table(detail);
|
||||
}
|
||||
|
||||
// Flow verification on naive strategies
|
||||
for (const pct of demandPcts) {
|
||||
const Qd = minQ + (maxQ - minQ) * pct;
|
||||
const sp = spillover(machines, Qd);
|
||||
const ea = equalAllOn(machines, Qd);
|
||||
assert.ok(Math.abs(sp.flow - Qd) < Qd * 0.005, `Spillover flow mismatch at ${(pct*100)}%`);
|
||||
assert.ok(Math.abs(ea.flow - Qd) < Qd * 0.005, `Equal-all flow mismatch at ${(pct*100)}%`);
|
||||
}
|
||||
});
|
||||
354
test/integration/idle-startup-deadlock.integration.test.js
Normal file
354
test/integration/idle-startup-deadlock.integration.test.js
Normal file
@@ -0,0 +1,354 @@
|
||||
// MGC + idle pumps under realistic startup times — three scenarios that
|
||||
// pin down WHERE the live deadlock is happening when PS sends 100% but
|
||||
// pumps "show on" without adopting the control value.
|
||||
//
|
||||
// All three scenarios start with idle pumps (NOT pre-started) and use
|
||||
// non-zero state.time values so startup is observable. Each scenario
|
||||
// prints the per-pump snapshot at the end. The asserts state what we
|
||||
// EXPECT to happen — failures point at the exact codepath that breaks.
|
||||
//
|
||||
// Compare to demand-cycle-walkthrough.integration.test.js which
|
||||
// pre-starts every pump to 'operational' and therefore CANNOT exercise
|
||||
// the idle-during-rapid-retarget paths described here.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const HEAD_MBAR_UP = 0;
|
||||
const HEAD_MBAR_DOWN = 1100;
|
||||
const N_PUMPS = 3;
|
||||
|
||||
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
|
||||
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
|
||||
|
||||
// Production-realistic-but-shrunk: starting=1s, warmingup=2s. Total
|
||||
// startup ~3s. Long enough for rapid retargeting (every 200ms) to land
|
||||
// 10+ extra calls during the transient, short enough to keep the test
|
||||
// well under 30s.
|
||||
const stateConfig = {
|
||||
general: { logging: logCfg },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
|
||||
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
|
||||
};
|
||||
|
||||
function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function buildGroup({ withPressure = true } = {}) {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
||||
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
|
||||
for (const m of pumps) {
|
||||
if (withPressure) {
|
||||
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
}
|
||||
mgc.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
}
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
|
||||
function snapshot(pump) {
|
||||
const state = pump.state.getCurrentState();
|
||||
const ctrl = Number(pump.state.getCurrentPosition?.() ?? 0);
|
||||
const running = !NON_RUNNING.has(state);
|
||||
const flow = running ? Number(pump.predictFlow?.outputY ?? 0) * 3600 : 0;
|
||||
const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0;
|
||||
return { state, ctrl, flow, power, delayedMove: pump.state.delayedMove };
|
||||
}
|
||||
|
||||
function printSnapshots(label, pumps) {
|
||||
console.log(`\n --- ${label} ---`);
|
||||
console.log(' ' + ['id'.padEnd(8), 'state'.padEnd(14), 'ctrl%'.padStart(6), 'Q m³/h'.padStart(8), 'kW'.padStart(6), 'delayedMove'.padStart(12)].join(' '));
|
||||
console.log(' ' + '-'.repeat(60));
|
||||
for (const p of pumps) {
|
||||
const s = snapshot(p);
|
||||
console.log(' ' + [
|
||||
p.config.general.id.padEnd(8),
|
||||
s.state.padEnd(14),
|
||||
s.ctrl.toFixed(1).padStart(6),
|
||||
s.flow.toFixed(1).padStart(8),
|
||||
s.power.toFixed(1).padStart(6),
|
||||
String(s.delayedMove).padStart(12),
|
||||
].join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
function expectAllRunningAt100(pumps, label) {
|
||||
// After settle every pump should be operational with high ctrl% and
|
||||
// measurable flow. "high" is conservative — at 100% normalized demand,
|
||||
// 3-pump split puts each pump near 100% ctrl. Allow >70% as the floor
|
||||
// (accommodates BEP-Gravitation's slight asymmetry at the curve edges).
|
||||
for (const p of pumps) {
|
||||
const s = snapshot(p);
|
||||
assert.equal(s.state, 'operational',
|
||||
`${label}: pump ${p.config.general.id} expected operational, got '${s.state}' (ctrl=${s.ctrl.toFixed(1)}, delayedMove=${s.delayedMove})`);
|
||||
assert.ok(s.ctrl > 70,
|
||||
`${label}: pump ${p.config.general.id} expected ctrl% > 70 at 100% demand, got ${s.ctrl.toFixed(2)} (state=${s.state}, delayedMove=${s.delayedMove})`);
|
||||
assert.ok(s.flow > 100,
|
||||
`${label}: pump ${p.config.general.id} expected flow > 100 m³/h, got ${s.flow.toFixed(2)} (state=${s.state}, ctrl=${s.ctrl.toFixed(1)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 1 — single-shot 100% demand to idle pumps', async () => {
|
||||
// Hypothesis A: a SINGLE handleInput call to MGC with all pumps idle is
|
||||
// enough to surface the bug. If pumps end up at 100% ctrl, the bug is
|
||||
// elsewhere (rapid retargeting OR pressure plumbing). If pumps stay at
|
||||
// 0%, the dispatch loop itself doesn't follow through on
|
||||
// execsequence-startup → flowmovement.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`);
|
||||
printSnapshots('before handleInput', pumps);
|
||||
|
||||
await mgc.handleInput('parent', 100);
|
||||
printSnapshots('immediately after handleInput returns', pumps);
|
||||
|
||||
// Wait for full startup (3s) + movement (~0.5s) + slack
|
||||
await sleep(6000);
|
||||
printSnapshots('after 6s settle', pumps);
|
||||
|
||||
expectAllRunningAt100(pumps, 'Scenario 1');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 2 — rapid 100% retargeting during startup window', async () => {
|
||||
// Hypothesis B: PS fires _applyMachineGroupLevelControl on every level
|
||||
// tick (every few hundred ms). While pumps are in 'starting' /
|
||||
// 'warmingup', MGC's optimalControl loop snapshots them, hits NONE of
|
||||
// its three branches (idle / operational / flow<=0), and dispatches
|
||||
// nothing. The only reason pumps eventually move is the FIRST call's
|
||||
// queued `await flowmovement` after `await execsequence startup` —
|
||||
// unless a subsequent call's abortActiveMovements aborts that move
|
||||
// mid-flight, parking it in 'accelerating'/'decelerating'.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', 100) every 200ms for 5s`);
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// First call (kicks off startup); not awaited so retargets can layer on.
|
||||
mgc.handleInput('parent', 100).catch(e => console.log(`first call rejected: ${e.message}`));
|
||||
|
||||
// Spam additional retargets every 200ms for 5s — covers the 3s startup
|
||||
// window with 25 extra retargeting calls.
|
||||
const interval = setInterval(() => {
|
||||
mgc.handleInput('parent', 100).catch(e => console.log(`retarget rejected: ${e.message}`));
|
||||
}, 200);
|
||||
await sleep(5000);
|
||||
clearInterval(interval);
|
||||
|
||||
printSnapshots('right after retarget barrage stops', pumps);
|
||||
|
||||
// Drain: let any pending moves finish and let the FSM settle.
|
||||
await sleep(3000);
|
||||
printSnapshots('after 3s drain', pumps);
|
||||
|
||||
expectAllRunningAt100(pumps, 'Scenario 2');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 3 — pumps with NO pressure measurements injected', async () => {
|
||||
// Hypothesis C: in production, MGC may receive a demand BEFORE the
|
||||
// first pressure measurement has propagated. Without head, the curve's
|
||||
// operating point is at fDimension=defaults, and currentFxyYMin/Max
|
||||
// may not correspond to a usable envelope. If MGC's distributor then
|
||||
// hands every pump flow≤0, the dispatch loop falls into the 'flow<=0
|
||||
// → shutdown' branch and pumps go straight to idle.
|
||||
|
||||
const { mgc, pumps } = buildGroup({ withPressure: false });
|
||||
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
|
||||
const minQ = sample.currentFxyYMin * 3600;
|
||||
const maxQ = sample.currentFxyYMax * 3600;
|
||||
const dyn = mgc.calcDynamicTotals();
|
||||
console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`);
|
||||
printSnapshots('before handleInput', pumps);
|
||||
|
||||
await mgc.handleInput('parent', 100);
|
||||
await sleep(6000);
|
||||
printSnapshots('after 6s settle (no pressure)', pumps);
|
||||
|
||||
// We don't assert success here — this scenario is exploratory. Just
|
||||
// log what happens. If pumps DO ramp despite no pressure, MGC is
|
||||
// resilient. If they stay idle, that's a meaningful failure mode for
|
||||
// the live system because a redeploy may rebuild the world before
|
||||
// sensors republish.
|
||||
console.log(' (Scenario 3 is exploratory — no asserts; review the snapshot above.)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
// Hypothesis E: when demand goes 100% → 0% → 100% (basin fills, drains
|
||||
// past stopLevel, then refills), pumps pass through stopping →
|
||||
// coolingdown → idle. If a fresh flow>0 demand arrives while a pump is
|
||||
// mid-shutdown, the current MGC dispatch saves flowmovement to
|
||||
// delayedMove (good) but doesn't issue execsequence-startup because
|
||||
// state !== 'idle' (bug). The pump completes shutdown, reaches 'idle',
|
||||
// and stays there because transitionToState('idle') doesn't fire
|
||||
// delayedMove — only the transition INTO 'operational' does. Pump is
|
||||
// stuck with delayedMove orphaned.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log('\n[Scenario 5] cycle: 100% → 0% → 100% with mid-shutdown re-engage');
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// Phase 1: drive up to 100% from idle.
|
||||
await mgc.handleInput('parent', 100);
|
||||
await sleep(5000); // full startup + ramp
|
||||
printSnapshots('after settle at 100%', pumps);
|
||||
for (const p of pumps) {
|
||||
assert.equal(p.state.getCurrentState(), 'operational',
|
||||
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
|
||||
}
|
||||
|
||||
// Phase 2: demand drops to 0% — pumps begin shutdown sequence.
|
||||
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which
|
||||
// awaits the full per-pump shutdown sequence. We need the next 100%
|
||||
// demand to arrive WHILE pumps are still in stopping/coolingdown,
|
||||
// not after they've reached idle.
|
||||
mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`));
|
||||
// Wait briefly so the shutdown sequence enters but does NOT complete.
|
||||
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
|
||||
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
|
||||
await sleep(500);
|
||||
printSnapshots('mid-shutdown (pumps should be in stopping/coolingdown)', pumps);
|
||||
|
||||
const midShutdownStates = pumps.map(p => p.state.getCurrentState());
|
||||
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
|
||||
|
||||
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
|
||||
await mgc.handleInput('parent', 100);
|
||||
// Generous: full coolingdown remaining + full startup + ramp.
|
||||
await sleep(8000);
|
||||
printSnapshots('after re-engage to 100%', pumps);
|
||||
|
||||
expectAllRunningAt100(pumps, 'Scenario 5');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 6 — full up sweep then full down sweep', async () => {
|
||||
// Hypothesis F: the user observed "going up stuck ~60%, going down
|
||||
// not reacting". Mirror that with an explicit up-then-down monotonic
|
||||
// sweep, every step holding 600 ms (slightly longer than DWELL on
|
||||
// production basin model). After the sweep, we expect the LATEST
|
||||
// demand (the final value of the down-sweep, which is 10%) to be
|
||||
// honoured: pumps either at 1-pump combo's split or all idle if that
|
||||
// demand falls below the per-pump minimum.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log('\n[Scenario 6] up-sweep 10%→100% then down-sweep 100%→10%, each step 600 ms');
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
const upSteps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
||||
const downSteps = [90, 80, 70, 60, 50, 40, 30, 20, 10];
|
||||
|
||||
console.log(' --- up sweep ---');
|
||||
for (const pct of upSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
|
||||
}
|
||||
printSnapshots('top of up-sweep (cmd=100%) after full settle', pumps);
|
||||
await sleep(2000);
|
||||
printSnapshots('top of up-sweep + 2s drain', pumps);
|
||||
|
||||
console.log(' --- down sweep ---');
|
||||
for (const pct of downSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
|
||||
}
|
||||
printSnapshots('bottom of down-sweep (cmd=10%) after sequence', pumps);
|
||||
await sleep(3000);
|
||||
printSnapshots('bottom of down-sweep + 3s drain', pumps);
|
||||
|
||||
// Final demand was 10% (≈ 148 m³/h). At head 1100 mbar with per-pump
|
||||
// min ≈ 89.5, this is solvable by a 1-pump combo near 148 m³/h.
|
||||
// Optimizer typically picks the 1-pump combo. Either way, pumps are
|
||||
// NOT supposed to be stuck at the prior up-sweep's 100% setpoint.
|
||||
const flowMin_m3h = mgc.calcDynamicTotals().flow.min * 3600;
|
||||
const flowMax_m3h = mgc.calcDynamicTotals().flow.max * 3600;
|
||||
const expectedQ_m3h = flowMin_m3h + (flowMax_m3h - flowMin_m3h) * 0.10; // 10% scaled
|
||||
console.log(` expected total flow at 10%: ~${expectedQ_m3h.toFixed(1)} m³/h`);
|
||||
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
// Loose: total within 30 m³/h of expectation. Catches the obvious
|
||||
// stuck-at-old-position regression.
|
||||
assert.ok(Math.abs(totalQ - expectedQ_m3h) < 30,
|
||||
`Scenario 6: total flow ${totalQ.toFixed(1)} m³/h diverged from expected ${expectedQ_m3h.toFixed(1)} after down-sweep — pumps did not adopt latest demand. Per-pump: ${snaps.map(s => `${s.state}@${s.ctrl.toFixed(0)}%`).join(', ')}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 4 — varying demand during startup (combo flips)', async () => {
|
||||
// Hypothesis D: in production the demand is NOT constant — as basin
|
||||
// level rises, percControl ramps from startLevel→maxLevel over the
|
||||
// basin model. Demand can flip between 1-pump / 2-pump / 3-pump
|
||||
// combinations every PS tick. Each flip in optimalControl tells some
|
||||
// pumps to start, others to shutdown, others nothing. If a pump that
|
||||
// was just told "startup" is told "shutdown" 1s later (still in
|
||||
// 'starting' state — neither idle nor operational), nothing happens
|
||||
// for that pump in this snapshot. The execsequence shutdown branch
|
||||
// requires state to be operational/accelerating/decelerating — a
|
||||
// 'starting'/'warmingup' pump is silently passed over for shutdown
|
||||
// too. The pump then proceeds to operational AND obeys its queued
|
||||
// flowmovement, even though MGC's intent has since changed.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const sequence = [25, 75, 50, 100, 30, 90, 60, 100];
|
||||
console.log(`\n[Scenario 4] varying demand sequence: ${sequence.join(' → ')} (each held 400ms)`);
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
for (const pct of sequence) {
|
||||
console.log(` → demand ${pct}%`);
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
printSnapshots('right after sequence ends', pumps);
|
||||
|
||||
// Final demand was 100% — drain and verify pumps converged.
|
||||
await sleep(4000);
|
||||
printSnapshots('after 4s drain (demand was last set to 100%)', pumps);
|
||||
|
||||
expectAllRunningAt100(pumps, 'Scenario 4');
|
||||
});
|
||||
442
test/integration/ncog-distribution.integration.test.js
Normal file
442
test/integration/ncog-distribution.integration.test.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Group Distribution Strategy Comparison Test
|
||||
*
|
||||
* Compares three flow distribution strategies for a group of pumps:
|
||||
* 1. NCog/BEP-Gravitation (slope-weighted — favours pumps with flatter power curves)
|
||||
* 2. Equal distribution (same flow to every pump)
|
||||
* 3. Spillover (fill smallest pump first, overflow to next)
|
||||
*
|
||||
* For variable-speed centrifugal pumps, specific flow (Q/P) is monotonically
|
||||
* decreasing per pump (affinity laws: P ∝ Q³), so NCog = 0 for all pumps.
|
||||
* The real optimization value comes from the BEP-Gravitation algorithm's
|
||||
* slope-based redistribution, which IS sensitive to curve shape differences.
|
||||
*
|
||||
* These tests verify that:
|
||||
* - Asymmetric pumps produce different power slopes (the basis for optimization)
|
||||
* - BEP-Gravitation uses less total power than naive strategies for mixed pumps
|
||||
* - Equal pumps receive equal treatment under all strategies
|
||||
* - Spillover creates a visibly different distribution than BEP-weighted
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function distortSeries(series, scale = 1, tilt = 0) {
|
||||
const last = series.length - 1;
|
||||
return series.map((v, i) => {
|
||||
const gradient = last === 0 ? 0 : i / last - 0.5;
|
||||
return Math.max(v * scale * (1 + tilt * gradient), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function createSyntheticCurve(mods) {
|
||||
const { flowScale = 1, powerScale = 1, flowTilt = 0, powerTilt = 0 } = mods;
|
||||
const curve = deepClone(baseCurve);
|
||||
Object.values(curve.nq).forEach(s => { s.y = distortSeries(s.y, flowScale, flowTilt); });
|
||||
Object.values(curve.np).forEach(s => { s.y = distortSeries(s.y, powerScale, powerTilt); });
|
||||
return curve;
|
||||
}
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 }
|
||||
};
|
||||
|
||||
function createMachineConfig(id, label) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] }
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupConfig(name) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap with differential pressure (upstream + downstream) so the predict
|
||||
* engine resolves a realistic fDimension and calcEfficiencyCurve produces
|
||||
* a proper BEP peak — not a monotonic Q/P curve.
|
||||
*/
|
||||
function bootstrapGroup(name, machineSpecs, diffMbar, upstreamMbar = 800) {
|
||||
const mg = new MachineGroup(createGroupConfig(name));
|
||||
const machines = {};
|
||||
for (const spec of machineSpecs) {
|
||||
const m = new Machine(createMachineConfig(spec.id, spec.label), stateConfig);
|
||||
if (spec.curveMods) m.updateCurve(createSyntheticCurve(spec.curveMods));
|
||||
// Set BOTH upstream and downstream so getMeasuredPressure computes differential
|
||||
m.updateMeasuredPressure(upstreamMbar, 'upstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: `pt-up-${spec.id}`, childId: `pt-up-${spec.id}`
|
||||
});
|
||||
m.updateMeasuredPressure(upstreamMbar + diffMbar, 'downstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: `pt-dn-${spec.id}`, childId: `pt-dn-${spec.id}`
|
||||
});
|
||||
mg.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
machines[spec.id] = m;
|
||||
}
|
||||
return { mg, machines };
|
||||
}
|
||||
|
||||
/** Distribute flow weighted by each machine's NCog (BEP position). */
|
||||
function distributeByNCog(machines, Qd) {
|
||||
const entries = Object.entries(machines);
|
||||
let totalNCog = entries.reduce((s, [, m]) => s + (m.NCog || 0), 0);
|
||||
|
||||
const distribution = {};
|
||||
for (const [id, m] of entries) {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const flow = totalNCog > 0
|
||||
? ((m.NCog || 0) / totalNCog) * Qd
|
||||
: Qd / entries.length;
|
||||
distribution[id] = Math.min(max, Math.max(min, flow));
|
||||
}
|
||||
|
||||
let totalPower = 0;
|
||||
for (const [id, m] of entries) {
|
||||
totalPower += m.inputFlowCalcPower(distribution[id]);
|
||||
}
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/** Compute power at a given flow for a machine using its inverse curve. */
|
||||
function powerAtFlow(machine, flow) {
|
||||
return machine.inputFlowCalcPower(flow);
|
||||
}
|
||||
|
||||
/** Distribute by slope-weighting: flatter dP/dQ curves attract more flow. */
|
||||
function distributeBySlopeWeight(machines, Qd) {
|
||||
const entries = Object.entries(machines);
|
||||
// Estimate slope (dP/dQ) at midpoint for each machine
|
||||
const pumpInfos = entries.map(([id, m]) => {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const mid = (min + max) / 2;
|
||||
const delta = Math.max((max - min) * 0.05, 0.001);
|
||||
const pMid = powerAtFlow(m, mid);
|
||||
const pRight = powerAtFlow(m, Math.min(max, mid + delta));
|
||||
const slope = Math.abs((pRight - pMid) / delta);
|
||||
return { id, m, min, max, slope: Math.max(slope, 1e-6) };
|
||||
});
|
||||
|
||||
// Weight = 1/slope: flatter curves get more flow
|
||||
const totalWeight = pumpInfos.reduce((s, p) => s + (1 / p.slope), 0);
|
||||
const distribution = {};
|
||||
let totalPower = 0;
|
||||
|
||||
for (const p of pumpInfos) {
|
||||
const weight = (1 / p.slope) / totalWeight;
|
||||
const flow = Math.min(p.max, Math.max(p.min, Qd * weight));
|
||||
distribution[p.id] = flow;
|
||||
totalPower += powerAtFlow(p.m, flow);
|
||||
}
|
||||
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/** Distribute equally. */
|
||||
function distributeEqual(machines, Qd) {
|
||||
const entries = Object.entries(machines);
|
||||
const flowEach = Qd / entries.length;
|
||||
const distribution = {};
|
||||
let totalPower = 0;
|
||||
for (const [id, m] of entries) {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const clamped = Math.min(max, Math.max(min, flowEach));
|
||||
distribution[id] = clamped;
|
||||
totalPower += powerAtFlow(m, clamped);
|
||||
}
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/** Spillover: fill smallest pump to max first, then overflow to next. */
|
||||
function distributeSpillover(machines, Qd) {
|
||||
const entries = Object.entries(machines)
|
||||
.sort(([, a], [, b]) => a.predictFlow.currentFxyYMax - b.predictFlow.currentFxyYMax);
|
||||
let remaining = Qd;
|
||||
const distribution = {};
|
||||
let totalPower = 0;
|
||||
for (const [id, m] of entries) {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const assigned = Math.min(max, Math.max(min, remaining));
|
||||
distribution[id] = assigned;
|
||||
remaining = Math.max(0, remaining - assigned);
|
||||
}
|
||||
for (const [id, m] of entries) {
|
||||
totalPower += powerAtFlow(m, distribution[id]);
|
||||
}
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/* ---- tests ---- */
|
||||
|
||||
test('NCog = 0 for centrifugal pumps (Q/P is monotonically decreasing with speed)', () => {
|
||||
// For variable-speed centrifugal pumps, P ∝ n³ and Q ∝ n, so Q/P ∝ 1/n²
|
||||
// which is always decreasing. Peak efficiency (Q/P) is always at index 0
|
||||
// (minimum speed), giving NCog = 0. This is physically correct — the MGC
|
||||
// compensates via slope-based redistribution instead.
|
||||
const { machines } = bootstrapGroup('ncog-basic', [
|
||||
{ id: 'A', label: 'pump-A', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
], 400); // 400 mbar differential
|
||||
|
||||
const m = machines['A'];
|
||||
assert.ok(Number.isFinite(m.NCog), `NCog should be finite, got ${m.NCog}`);
|
||||
assert.strictEqual(m.NCog, 0, `NCog should be 0 for centrifugal pump (Q/P monotonically decreasing)`);
|
||||
assert.ok(m.cog > 0, `cog (peak specific flow) should be positive, got ${m.cog}`);
|
||||
assert.strictEqual(m.cogIndex, 0, `Peak Q/P should be at index 0 (minimum speed)`);
|
||||
});
|
||||
|
||||
test('different curve shapes still yield NCog = 0 (Q/P limitation)', () => {
|
||||
// Even with powerTilt distortion, Q/P remains monotonically decreasing for
|
||||
// centrifugal pump curves because P grows much faster than Q with speed.
|
||||
// NCog = 0 for all shapes — the slope-based redistribution (tests 4-6)
|
||||
// is what actually differentiates asymmetric pumps.
|
||||
const { machines } = bootstrapGroup('ncog-shapes', [
|
||||
{ id: 'early', label: 'early-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: 0.4 } },
|
||||
{ id: 'late', label: 'late-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: -0.3 } },
|
||||
], 400);
|
||||
|
||||
const ncogEarly = machines['early'].NCog;
|
||||
const ncogLate = machines['late'].NCog;
|
||||
|
||||
assert.strictEqual(ncogEarly, 0, `Early BEP NCog should be 0 (Q/P monotonic), got ${ncogEarly.toFixed(4)}`);
|
||||
assert.strictEqual(ncogLate, 0, `Late BEP NCog should be 0 (Q/P monotonic), got ${ncogLate.toFixed(4)}`);
|
||||
// Both cog values should still be computable and positive (peak Q/P at min speed)
|
||||
assert.ok(machines['early'].cog > 0, 'early cog should be positive');
|
||||
assert.ok(machines['late'].cog > 0, 'late cog should be positive');
|
||||
});
|
||||
|
||||
test('NCog = 0 falls back to equal distribution (same as equal split)', () => {
|
||||
// When NCog = 0 for all pumps (centrifugal pump limitation), the
|
||||
// distributeByNCog helper falls back to equal distribution. This verifies
|
||||
// the fallback works correctly and produces the same result as explicit
|
||||
// equal distribution.
|
||||
const { machines } = bootstrapGroup('ncog-vs-equal', [
|
||||
{ id: 'early', label: 'early-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: 0.4 } },
|
||||
{ id: 'late', label: 'late-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: -0.3 } },
|
||||
], 400);
|
||||
|
||||
// Both NCog = 0 (confirmed by tests 1-2)
|
||||
assert.strictEqual(machines['early'].NCog, 0, 'early NCog should be 0');
|
||||
assert.strictEqual(machines['late'].NCog, 0, 'late NCog should be 0');
|
||||
|
||||
const totalMax = machines['early'].predictFlow.currentFxyYMax + machines['late'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.5;
|
||||
|
||||
const ncogResult = distributeByNCog(machines, Qd);
|
||||
const equalResult = distributeEqual(machines, Qd);
|
||||
|
||||
// With NCog = 0 for both, distributeByNCog falls back to equal split
|
||||
const ncogDiff = Math.abs(ncogResult.distribution['early'] - ncogResult.distribution['late']);
|
||||
const equalDiff = Math.abs(equalResult.distribution['early'] - equalResult.distribution['late']);
|
||||
assert.ok(
|
||||
Math.abs(ncogDiff - equalDiff) < Qd * 0.01,
|
||||
`NCog fallback should produce same distribution as equal split. ` +
|
||||
`ncogDiff=${ncogDiff.toFixed(4)}, equalDiff=${equalDiff.toFixed(4)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('asymmetric pumps have different power curve slopes', () => {
|
||||
// A pump with low powerScale has a flatter power curve
|
||||
const { machines } = bootstrapGroup('slope-check', [
|
||||
{ id: 'flat', label: 'flat-power', curveMods: { flowScale: 1.2, powerScale: 0.7, flowTilt: 0.1 } },
|
||||
{ id: 'steep', label: 'steep-power', curveMods: { flowScale: 0.8, powerScale: 1.4, flowTilt: -0.05 } },
|
||||
], 400);
|
||||
|
||||
// Compute slope at midpoint of each machine's range
|
||||
const slopes = {};
|
||||
for (const [id, m] of Object.entries(machines)) {
|
||||
const mid = (m.predictFlow.currentFxyYMin + m.predictFlow.currentFxyYMax) / 2;
|
||||
const delta = (m.predictFlow.currentFxyYMax - m.predictFlow.currentFxyYMin) * 0.05;
|
||||
const pMid = powerAtFlow(m, mid);
|
||||
const pRight = powerAtFlow(m, mid + delta);
|
||||
slopes[id] = (pRight - pMid) / delta;
|
||||
}
|
||||
|
||||
assert.ok(slopes['flat'] > 0 && slopes['steep'] > 0, 'Both slopes should be positive');
|
||||
assert.ok(
|
||||
slopes['steep'] > slopes['flat'] * 1.3,
|
||||
`Steep pump should have notably higher slope. flat=${slopes['flat'].toFixed(0)}, steep=${slopes['steep'].toFixed(0)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('slope-weighted distribution routes more flow to flatter pump', () => {
|
||||
const { machines } = bootstrapGroup('slope-routing', [
|
||||
{ id: 'flat', label: 'flat-power', curveMods: { flowScale: 1.2, powerScale: 0.7 } },
|
||||
{ id: 'steep', label: 'steep-power', curveMods: { flowScale: 0.8, powerScale: 1.4 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['flat'].predictFlow.currentFxyYMax + machines['steep'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.5;
|
||||
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
|
||||
assert.ok(
|
||||
slopeResult.distribution['flat'] > slopeResult.distribution['steep'],
|
||||
`Flat pump should get more flow. flat=${slopeResult.distribution['flat'].toFixed(2)}, steep=${slopeResult.distribution['steep'].toFixed(2)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('slope-weighted uses less power than equal split for asymmetric pumps', () => {
|
||||
const { machines } = bootstrapGroup('power-compare', [
|
||||
{ id: 'eff', label: 'efficient', curveMods: { flowScale: 1.2, powerScale: 0.7, flowTilt: 0.12 } },
|
||||
{ id: 'std', label: 'standard', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['eff'].predictFlow.currentFxyYMax + machines['std'].predictFlow.currentFxyYMax;
|
||||
const demandLevels = [0.3, 0.5, 0.7].map(p => {
|
||||
const min = Math.max(machines['eff'].predictFlow.currentFxyYMin, machines['std'].predictFlow.currentFxyYMin);
|
||||
return min + (totalMax - min) * p;
|
||||
});
|
||||
|
||||
let slopeWins = 0;
|
||||
const results = [];
|
||||
|
||||
for (const Qd of demandLevels) {
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
const equalResult = distributeEqual(machines, Qd);
|
||||
const spillResult = distributeSpillover(machines, Qd);
|
||||
|
||||
results.push({
|
||||
demand: Qd,
|
||||
slopePower: slopeResult.totalPower,
|
||||
equalPower: equalResult.totalPower,
|
||||
spillPower: spillResult.totalPower,
|
||||
});
|
||||
|
||||
if (slopeResult.totalPower <= equalResult.totalPower + 1) slopeWins++;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
slopeWins >= 2,
|
||||
`Slope-weighted should use ≤ power than equal in ≥ 2/3 cases.\n` +
|
||||
results.map(r =>
|
||||
` Qd=${r.demand.toFixed(1)}: slope=${r.slopePower.toFixed(1)}W, equal=${r.equalPower.toFixed(1)}W, spill=${r.spillPower.toFixed(1)}W`
|
||||
).join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('spillover produces visibly different distribution than slope-weighted for mixed sizes', () => {
|
||||
const { machines } = bootstrapGroup('spillover-vs-slope', [
|
||||
{ id: 'small', label: 'small-pump', curveMods: { flowScale: 0.6, powerScale: 0.55 } },
|
||||
{ id: 'large', label: 'large-pump', curveMods: { flowScale: 1.5, powerScale: 1.2 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['small'].predictFlow.currentFxyYMax + machines['large'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.5;
|
||||
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
const spillResult = distributeSpillover(machines, Qd);
|
||||
|
||||
// Spillover fills the small pump first, slope-weight distributes by curve shape
|
||||
const slopeDiff = Math.abs(slopeResult.distribution['small'] - spillResult.distribution['small']);
|
||||
const percentDiff = (slopeDiff / Qd) * 100;
|
||||
|
||||
assert.ok(
|
||||
percentDiff > 1,
|
||||
`Strategies should produce different distributions. ` +
|
||||
`Slope small=${slopeResult.distribution['small'].toFixed(2)}, ` +
|
||||
`Spill small=${spillResult.distribution['small'].toFixed(2)} (${percentDiff.toFixed(1)}% diff)`
|
||||
);
|
||||
});
|
||||
|
||||
test('equal pumps get equal flow under all strategies', () => {
|
||||
const { machines } = bootstrapGroup('equal-pumps', [
|
||||
{ id: 'A', label: 'pump-A', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
{ id: 'B', label: 'pump-B', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['A'].predictFlow.currentFxyYMax + machines['B'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.6;
|
||||
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
const equalResult = distributeEqual(machines, Qd);
|
||||
|
||||
const tolerance = Qd * 0.01;
|
||||
|
||||
assert.ok(
|
||||
Math.abs(slopeResult.distribution['A'] - slopeResult.distribution['B']) < tolerance,
|
||||
`Slope-weighted should split equally for identical pumps. A=${slopeResult.distribution['A'].toFixed(2)}, B=${slopeResult.distribution['B'].toFixed(2)}`
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(equalResult.distribution['A'] - equalResult.distribution['B']) < tolerance,
|
||||
`Equal should split equally. A=${equalResult.distribution['A'].toFixed(2)}, B=${equalResult.distribution['B'].toFixed(2)}`
|
||||
);
|
||||
|
||||
// Power should be identical too
|
||||
assert.ok(
|
||||
Math.abs(slopeResult.totalPower - equalResult.totalPower) < 1,
|
||||
`Equal pumps should produce same total power under any strategy`
|
||||
);
|
||||
});
|
||||
|
||||
test('full MGC optimalControl uses ≤ power than priorityControl for mixed pumps', async () => {
|
||||
const { mg, machines } = bootstrapGroup('mgc-full', [
|
||||
{ id: 'eff', label: 'efficient', curveMods: { flowScale: 1.2, powerScale: 0.7, flowTilt: 0.1 } },
|
||||
{ id: 'std', label: 'standard', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
{ id: 'weak', label: 'weak', curveMods: { flowScale: 0.8, powerScale: 1.3, flowTilt: -0.08 } },
|
||||
], 400);
|
||||
|
||||
for (const m of Object.values(machines)) {
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
|
||||
// Run optimalControl
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('normalized');
|
||||
await mg.handleInput('parent', 50, Infinity);
|
||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
// Reset machines
|
||||
for (const m of Object.values(machines)) {
|
||||
await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
|
||||
// Run priorityControl
|
||||
mg.setMode('prioritycontrol');
|
||||
await mg.handleInput('parent', 50, Infinity, ['eff', 'std', 'weak']);
|
||||
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
assert.ok(optFlow > 0, `Optimal should deliver flow, got ${optFlow}`);
|
||||
assert.ok(prioFlow > 0, `Priority should deliver flow, got ${prioFlow}`);
|
||||
|
||||
// Compare efficiency (flow per unit power)
|
||||
const optEff = optPower > 0 ? optFlow / optPower : 0;
|
||||
const prioEff = prioPower > 0 ? prioFlow / prioPower : 0;
|
||||
|
||||
assert.ok(
|
||||
optEff >= prioEff * 0.95,
|
||||
`Optimal efficiency should be ≥ priority (within 5% tolerance). ` +
|
||||
`Opt: ${optFlow.toFixed(1)}/${optPower.toFixed(1)}=${optEff.toFixed(6)} | ` +
|
||||
`Prio: ${prioFlow.toFixed(1)}/${prioPower.toFixed(1)}=${prioEff.toFixed(6)}`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// MGC optimizer combination choice — given a known operating point and
|
||||
// 3 identical pumps, walk demand from below per-pump min through to
|
||||
// full station capacity and assert the optimizer always returns a
|
||||
// combination whose per-pump split lies within each pump's curve.
|
||||
//
|
||||
// This is a regression test. Earlier traces showed per-pump flow values
|
||||
// that looked impossible (78 m³/h while we believed min was ~99). The
|
||||
// real explanation: the curve's currentFxyYMin shifts with head — at
|
||||
// 1652 mbar the per-pump min IS 49 m³/h. This test pins the optimizer's
|
||||
// behaviour at a single deterministic head so the asserted ranges are
|
||||
// stable.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const HEAD_MBAR_DOWN = 1100;
|
||||
const HEAD_MBAR_UP = 0;
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 },
|
||||
};
|
||||
|
||||
function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'absolute' }, // talk to MGC in m³/h directly
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function buildGroup() {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = ['pump_a', 'pump_b', 'pump_c'];
|
||||
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
|
||||
for (const m of pumps) {
|
||||
// Inject deterministic pressures so every pump sees the same head.
|
||||
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream',
|
||||
{ timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream',
|
||||
{ timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
mgc.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
}
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
test('optimizer always returns a physically valid split (head=1100 mbar)', () => {
|
||||
// The core invariant: whatever combination the optimizer picks, every
|
||||
// per-pump assignment must lie inside that pump's curve envelope at
|
||||
// the current operating point, and the total must equal the demand.
|
||||
// This is what makes a combo "physically valid". The optimizer is
|
||||
// free to pick fewer or more pumps based on efficiency — that is NOT
|
||||
// a violation.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
|
||||
const minPerPump = sample.currentFxyYMin * 3600;
|
||||
const maxPerPump = sample.currentFxyYMax * 3600;
|
||||
// Guard against a curve-data change silently invalidating the asserts.
|
||||
assert.ok(minPerPump > 80 && minPerPump < 100,
|
||||
`unexpected curve min ${minPerPump} at 1100 mbar`);
|
||||
assert.ok(maxPerPump > 220 && maxPerPump < 230,
|
||||
`unexpected curve max ${maxPerPump} at 1100 mbar`);
|
||||
|
||||
const stationMax = maxPerPump * pumps.length; // ≈ 681
|
||||
// Note: we deliberately stay 1 m³/h short of stationMax to avoid a
|
||||
// floating-point edge where validPumpCombinations rejects an exact
|
||||
// boundary demand. Real demand is never exactly station max anyway.
|
||||
const demands = [0, 50, minPerPump - 5, minPerPump, 150, 200, 230, 250, 300, 400, 500, 600, stationMax - 1];
|
||||
|
||||
const rows = [];
|
||||
for (const Qd_m3h of demands) {
|
||||
const Qd_m3s = Qd_m3h / 3600;
|
||||
const combos = mgc.validPumpCombinations(mgc.machines, Qd_m3s, Infinity);
|
||||
if (combos.length === 0) {
|
||||
rows.push({ Qd_m3h, picked: null, perPump: [], total: 0 });
|
||||
// The validity rule rejects a combo when Qd is outside its
|
||||
// [sum(min), sum(max)] envelope. With only 3 identical pumps at
|
||||
// this head, that means Qd < minPerPump (no combo's min envelope
|
||||
// contains it) or Qd > stationMax. Strict zero is also rejected.
|
||||
assert.ok(Qd_m3h <= 0 || Qd_m3h < minPerPump,
|
||||
`unexpected: no valid combo for Qd=${Qd_m3h} (per-pump ${minPerPump.toFixed(2)}..${maxPerPump.toFixed(2)}, station max ${stationMax.toFixed(2)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const best = mgc.calcBestCombinationBEPGravitation(combos, Qd_m3s, 'BEP-Gravitation-Directional');
|
||||
assert.ok(best.bestCombination, `no bestCombination for Qd=${Qd_m3h}`);
|
||||
const split = best.bestCombination.map(e => e.flow * 3600);
|
||||
const total = split.reduce((s, x) => s + x, 0);
|
||||
rows.push({ Qd_m3h, picked: best.bestCombination.length, perPump: split, total });
|
||||
|
||||
// Each per-pump split must lie in [minPerPump, maxPerPump].
|
||||
for (const f of split) {
|
||||
assert.ok(f >= minPerPump - 1e-3,
|
||||
`Qd=${Qd_m3h}: per-pump ${f.toFixed(2)} below min ${minPerPump.toFixed(2)}`);
|
||||
assert.ok(f <= maxPerPump + 1e-3,
|
||||
`Qd=${Qd_m3h}: per-pump ${f.toFixed(2)} above max ${maxPerPump.toFixed(2)}`);
|
||||
}
|
||||
assert.ok(Math.abs(total - Qd_m3h) < Math.max(1, Qd_m3h * 0.01),
|
||||
`Qd=${Qd_m3h}: total ${total.toFixed(2)} ≠ demand`);
|
||||
}
|
||||
|
||||
// Print the chosen combinations for inspection.
|
||||
console.log(`\nHead = ${HEAD_MBAR_DOWN - HEAD_MBAR_UP} mbar`);
|
||||
console.log(`Per-pump curve: min=${minPerPump.toFixed(2)} m³/h, max=${maxPerPump.toFixed(2)} m³/h`);
|
||||
console.log(`Station max (3 pumps × max): ${stationMax.toFixed(2)} m³/h\n`);
|
||||
console.log(' demand pumps per-pump split');
|
||||
console.log(' ────── ───── ─────────────────────────────');
|
||||
for (const r of rows) {
|
||||
if (r.picked == null) {
|
||||
console.log(` ${r.Qd_m3h.toFixed(1).padStart(6)} none no valid combo`);
|
||||
} else {
|
||||
console.log(` ${r.Qd_m3h.toFixed(1).padStart(6)} ${r.picked} [${r.perPump.map(f => f.toFixed(1)).join(', ')}] total=${r.total.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('feasibility floor and ceiling: only 1-pump combo serves demand below 2×min', () => {
|
||||
// The optimizer is allowed to pick larger combos for efficiency, but
|
||||
// it CANNOT pick a combo whose [sum(min), sum(max)] doesn't contain
|
||||
// the demand. This pins down the floor / ceiling rules.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
|
||||
const minPerPump = sample.currentFxyYMin * 3600;
|
||||
const maxPerPump = sample.currentFxyYMax * 3600;
|
||||
|
||||
// Demand below per-pump min → no combo at all. (sum(min) ≥ minPerPump
|
||||
// for every non-empty combo, and Qd < sum(min) ⇒ rejected.)
|
||||
let combos = mgc.validPumpCombinations(mgc.machines, (minPerPump - 5) / 3600, Infinity);
|
||||
assert.equal(combos.length, 0, `demand below per-pump min should yield 0 valid combos, got ${combos.length}`);
|
||||
|
||||
// Demand within [minPerPump, 2*minPerPump): only 1-pump combos pass.
|
||||
// (2-pump min envelope = 2×minPerPump > Qd.)
|
||||
const Qd1 = (minPerPump + 5) / 3600;
|
||||
combos = mgc.validPumpCombinations(mgc.machines, Qd1, Infinity);
|
||||
for (const c of combos) {
|
||||
assert.equal(c.length, 1,
|
||||
`demand ${minPerPump+5} m³/h: only 1-pump combos should be valid (got ${c.length}-pump)`);
|
||||
}
|
||||
|
||||
// Demand above station max → no valid combo.
|
||||
combos = mgc.validPumpCombinations(mgc.machines, (maxPerPump * 3 + 50) / 3600, Infinity);
|
||||
assert.equal(combos.length, 0, `demand above station max should yield 0 valid combos`);
|
||||
});
|
||||
131
test/integration/turnoff-deadlock.integration.test.js
Normal file
131
test/integration/turnoff-deadlock.integration.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Regression: pump A in pumpingstation-complete-example demo got stuck
|
||||
// running at minimum flow while basin level dropped past stopLevel and
|
||||
// kept dropping all the way to dry-run threshold.
|
||||
//
|
||||
// Root cause (two parts):
|
||||
//
|
||||
// 1. rotatingMachine.executeSequence on shutdown went through an
|
||||
// interruptible-abort path that returned the FSM to 'operational',
|
||||
// triggering state.transitionToState's auto-pickup of the queued
|
||||
// delayedMove — re-engaging the pump before the shutdown sequence
|
||||
// could reach stopping/coolingdown/idle. Fix: clear delayedMove at
|
||||
// the top of shutdown/emergencystop sequences.
|
||||
//
|
||||
// 2. PS calls turnOffAllMachines on every tick (every 2 s) while
|
||||
// level < stopLevel. Each call interrupted the still-running prior
|
||||
// shutdown's transitions, resetting the FSM to 'accelerating'. The
|
||||
// pump bounced accelerating ↔ decelerating forever and the actual
|
||||
// shutdown sequence transitions never ran. Fix: serialize per-pump
|
||||
// shutdown calls in turnOffAllMachines so concurrent invocations
|
||||
// are no-ops while a shutdown is already in flight.
|
||||
//
|
||||
// This test exercises part 2 — the per-pump serialization at the MGC
|
||||
// level — by hammering turnOffAllMachines from a tight loop, mirroring
|
||||
// the live tick cadence.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const logCfg = { enabled: false, logLevel: 'error' };
|
||||
|
||||
const stateConfig = {
|
||||
general: { logging: logCfg },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
|
||||
// Non-zero shutdown timing so a shutdown takes long enough that a
|
||||
// concurrent turnOff call lands mid-sequence — exactly the live race.
|
||||
time: { starting: 0, warmingup: 0, stopping: 1, coolingdown: 1 },
|
||||
};
|
||||
|
||||
function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function buildGroup() {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = ['pump_a', 'pump_b', 'pump_c'];
|
||||
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
|
||||
for (const m of pumps) {
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
mgc.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
}
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
test('repeated turnOffAllMachines reaches idle (serializes concurrent shutdowns)', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const pumpA = pumps[0];
|
||||
|
||||
// Start pump A and queue a delayedMove the way MGC's optimalControl
|
||||
// would when PS sends a 1% dead-zone keep-alive.
|
||||
await pumpA.handleInput('parent', 'execsequence', 'startup');
|
||||
assert.equal(pumpA.state.getCurrentState(), 'operational');
|
||||
pumpA.setpoint(80); // start a slow move (not awaited)
|
||||
await sleep(50);
|
||||
assert.equal(pumpA.state.getCurrentState(), 'accelerating');
|
||||
pumpA.state.delayedMove = 75;
|
||||
|
||||
// Mimic PS's tick loop: fire turnOffAllMachines on a tight cadence
|
||||
// without awaiting. Without the per-pump serialization in
|
||||
// turnOffAllMachines, each call hits the still-running prior shutdown
|
||||
// and bounces the pump back to accelerating — the live deadlock.
|
||||
const ticks = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ticks.push(mgc.turnOffAllMachines());
|
||||
await sleep(80); // half the realtime tick — tighter race
|
||||
}
|
||||
await Promise.all(ticks);
|
||||
// Allow the (single) in-flight shutdown to finish its 1+1 s timed
|
||||
// transitions through stopping → coolingdown → idle.
|
||||
await sleep(2500);
|
||||
|
||||
assert.equal(pumpA.state.getCurrentState(), 'idle',
|
||||
`pump must reach idle under repeated turnOff calls; got ${pumpA.state.getCurrentState()} (delayedMove=${pumpA.state.delayedMove})`);
|
||||
assert.equal(pumpA.state.delayedMove, null,
|
||||
'delayedMove must be cleared after shutdown');
|
||||
});
|
||||
|
||||
test('turnOffAllMachines clears MGC._delayedCall to cancel any deferred dispatch', async () => {
|
||||
// PS sends a 1% keep-alive while MGC is mid-dispatch. MGC parks it in
|
||||
// _delayedCall. PS then crosses stopLevel and calls turnOffAllMachines.
|
||||
// Without clearing _delayedCall, MGC's finally block fires the parked
|
||||
// 1% call AFTER the shutdown — re-engaging the pump.
|
||||
const { mgc } = buildGroup();
|
||||
mgc._delayedCall = { source: 'parent', demand: 1, powerCap: Infinity, priorityList: null };
|
||||
|
||||
await mgc.turnOffAllMachines();
|
||||
|
||||
assert.equal(mgc._delayedCall, null,
|
||||
'turnOff must cancel any deferred dispatch so it cannot re-engage pumps post-shutdown');
|
||||
});
|
||||
Reference in New Issue
Block a user