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;
|
||||
}
|
||||
|
||||
@@ -1283,7 +1401,7 @@ class MachineGroup {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_canonicalToOutputFlow(value) {
|
||||
const from = this.unitPolicy.canonical.flow;
|
||||
const to = this.unitPolicy.output.flow;
|
||||
|
||||
Reference in New Issue
Block a user