optimalControl: dispatch setpoint to non-operational pumps too
Previously the dispatch loop only fired flowmovement for pumps in 'operational' or transitioned 'idle' pumps via execsequence-startup-then-flowmovement. Pumps mid-startup (starting/warmingup) were silently skipped. With PS sending demand every tick, intermediate setpoints during the startup window never reached the pump — it locked onto the very first snapshot's flowmovement and froze there. Now flowmovement is sent regardless of state and rotatingMachine's state.moveTo handles the queueing (delayedMove for transients, unpark for residue, immediate for operational). Crucially, flowmovement runs BEFORE execsequence-startup so the FIRST call's stale setpoint can't land on an already-operational pump and overwrite the latest delayedMove that fires at end of startup. Adds three integration tests: - demand-cycle-walkthrough: 0..100% sweep with clean per-step table - idle-startup-deadlock: four scenarios that pin the dispatch behaviour including the regression guard for varying-demand-during-startup - optimizer-combination-choice: physical-validity invariants Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,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 +213,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 +236,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 +253,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,6 +274,10 @@ 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();
|
||||
|
||||
@@ -340,12 +371,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 +412,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 +424,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 +465,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 +492,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 +583,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 {
|
||||
@@ -587,13 +624,14 @@ class MachineGroup {
|
||||
});
|
||||
|
||||
// Marginal-cost refinement: shift flow from most expensive to cheapest
|
||||
// pump using actual power evaluations. Converges regardless of curve convexity.
|
||||
// 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 = info.machine.inputFlowCalcPower(entry.flow);
|
||||
const pUp = info.machine.inputFlowCalcPower(Math.min(info.maxFlow, entry.flow + mcDelta));
|
||||
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;
|
||||
@@ -603,8 +641,8 @@ class MachineGroup {
|
||||
}
|
||||
if (!expensive || !cheap || expensive === cheap) break;
|
||||
if (expensive.mc - cheap.mc < expensive.mc * 0.001) break;
|
||||
const before = expensive.info.machine.inputFlowCalcPower(expensive.entry.flow) + cheap.info.machine.inputFlowCalcPower(cheap.entry.flow);
|
||||
const after = expensive.info.machine.inputFlowCalcPower(expensive.entry.flow - mcDelta) + cheap.info.machine.inputFlowCalcPower(cheap.entry.flow + mcDelta);
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -613,7 +651,7 @@ class MachineGroup {
|
||||
flowDistribution.forEach(entry => {
|
||||
totalFlow += entry.flow;
|
||||
const info = pumpInfos.find(i => i.id === entry.machineId);
|
||||
totalPower += info.machine.inputFlowCalcPower(entry.flow);
|
||||
totalPower += this._groupCalcPower(info.machine, entry.flow);
|
||||
});
|
||||
|
||||
const totalCog = pumpInfos.reduce((sum, info) => sum + info.NCog, 0);
|
||||
@@ -676,33 +714,7 @@ class MachineGroup {
|
||||
return;
|
||||
}
|
||||
|
||||
//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) || 0,
|
||||
upstream: this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, this.unitPolicy.canonical.pressure) || 0
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
this._equalizeOperatingPoint();
|
||||
|
||||
//fetch dynamic totals
|
||||
const dynamicTotals = this.dynamicTotals;
|
||||
@@ -778,18 +790,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", this._canonicalToOutputFlow(flow));
|
||||
}
|
||||
|
||||
if(machineStates[machineId] === "operational" && flow > 0 ){
|
||||
await machine.handleInput("parent", "flowmovement", this._canonicalToOutputFlow(flow));
|
||||
}
|
||||
// flow ≤ 0 AND state already in shutdown chain (idle/
|
||||
// stopping/coolingdown/off/emergencystop) → nothing
|
||||
// to do, preserve previous behaviour.
|
||||
}));
|
||||
}
|
||||
catch(err){
|
||||
@@ -797,34 +841,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(){
|
||||
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;
|
||||
|
||||
// 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) || 0,
|
||||
upstream: this._readChildMeasurement(machine, "pressure", "measured", POSITIONS.UPSTREAM, this.unitPolicy.canonical.pressure) || 0
|
||||
};
|
||||
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;
|
||||
@@ -925,7 +1039,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,7 +1055,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;
|
||||
}
|
||||
@@ -953,12 +1067,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -979,7 +1093,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;
|
||||
@@ -1177,18 +1291,22 @@ class MachineGroup {
|
||||
|
||||
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
|
||||
await this.turnOffAllMachines();
|
||||
return;
|
||||
}
|
||||
else{
|
||||
// Scale demand to 0-100% linear between min and max flow this is auto capped
|
||||
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`);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
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).`);
|
||||
});
|
||||
247
test/integration/idle-startup-deadlock.integration.test.js
Normal file
247
test/integration/idle-startup-deadlock.integration.test.js
Normal file
@@ -0,0 +1,247 @@
|
||||
// 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 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');
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user