Compare commits
1 Commits
9c79dac4e3
...
9916527790
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9916527790 |
@@ -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
|
||||
// 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