diff --git a/src/specificClass.js b/src/specificClass.js index 7d45597..ed528f7 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -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; diff --git a/test/integration/demand-cycle-walkthrough.integration.test.js b/test/integration/demand-cycle-walkthrough.integration.test.js new file mode 100644 index 0000000..b87a15b --- /dev/null +++ b/test/integration/demand-cycle-walkthrough.integration.test.js @@ -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).`); +}); diff --git a/test/integration/idle-startup-deadlock.integration.test.js b/test/integration/idle-startup-deadlock.integration.test.js new file mode 100644 index 0000000..4793df0 --- /dev/null +++ b/test/integration/idle-startup-deadlock.integration.test.js @@ -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'); +}); diff --git a/test/integration/optimizer-combination-choice.integration.test.js b/test/integration/optimizer-combination-choice.integration.test.js new file mode 100644 index 0000000..3ad4e4d --- /dev/null +++ b/test/integration/optimizer-combination-choice.integration.test.js @@ -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`); +});