diff --git a/src/specificClass.js b/src/specificClass.js index e76c8d4..aef8475 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -411,6 +411,194 @@ class MachineGroup { return { bestCombination, bestPower, bestFlow, bestCog }; } + /** + * Estimate the local dP/dQ slopes around the BEP for the provided machine. + * A gentle +/- delta perturbation is used to keep calling code self-contained. + */ + estimateSlopesAtBEP(machine, Q_BEP, delta = 1.0) { + const fallback = { + slopeLeft: 0, + slopeRight: 0, + alpha: 1, + Q_BEP: Q_BEP || 0, + P_BEP: 0 + }; + + try { + if (!machine || !machine.hasCurve || !machine.predictFlow) { + this.logger.warn(`estimateSlopesAtBEP: invalid machine input provided.`); + return fallback; + } + + const minFlow = machine.predictFlow.currentFxyYMin; + const maxFlow = machine.predictFlow.currentFxyYMax; + const span = Math.max(0, maxFlow - minFlow); + const normalizedCog = Math.max(0, Math.min(1, machine.NCog || 0)); + const targetBEP = Q_BEP ?? (minFlow + span * normalizedCog); + const clampFlow = (flow) => Math.min(maxFlow, Math.max(minFlow, flow)); + const center = clampFlow(targetBEP); + const deltaSafe = Math.max(delta, 0.01); + const leftFlow = clampFlow(center - deltaSafe); + const rightFlow = clampFlow(center + deltaSafe); + const powerAt = (flow) => { + try { + return machine.inputFlowCalcPower(flow); + } catch (error) { + this.logger.warn(`estimateSlopesAtBEP: failed power calc for ${machine.config?.general?.id}: ${error.message}`); + return 0; + } + }; + + const P_center = powerAt(center); + const P_left = powerAt(leftFlow); + const P_right = powerAt(rightFlow); + const slopeLeft = (P_center - P_left) / Math.max(1e-6, center - leftFlow); + const slopeRight = (P_right - P_center) / Math.max(1e-6, rightFlow - center); + const alpha = Math.max(1e-6, (Math.abs(slopeLeft) + Math.abs(slopeRight)) / 2); + + return { + slopeLeft, + slopeRight, + alpha, + Q_BEP: center, + P_BEP: P_center + }; + } catch (err) { + this.logger.warn(`estimateSlopesAtBEP failed: ${err.message}`); + return fallback; + } + } + + /** + * Redistribute remaining demand using slope-based weights so flatter curves attract more flow. + */ + redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional = true) { + const tolerance = 1e-3; + let remaining = delta; + const entryMap = new Map(flowDistribution.map(entry => [entry.machineId, entry])); + + while (Math.abs(remaining) > tolerance) { + const increasing = remaining > 0; + const candidates = pumpInfos.map(info => { + const entry = entryMap.get(info.id); + if (!entry) { return null; } + const capacity = increasing ? info.maxFlow - entry.flow : entry.flow - info.minFlow; + if (capacity <= tolerance) { return null; } + + const slope = increasing + ? (directional ? info.slopes.slopeRight : info.slopes.alpha) + : (directional ? info.slopes.slopeLeft : info.slopes.alpha); + + const weight = 1 / Math.max(1e-6, Math.abs(slope) || info.slopes.alpha || 1); + return { entry, capacity, weight }; + }).filter(Boolean); + + if (!candidates.length) { break; } + + const weightSum = candidates.reduce((sum, candidate) => sum + candidate.weight * candidate.capacity, 0); + if (weightSum <= 0) { break; } + + let progress = 0; + candidates.forEach(candidate => { + let share = (candidate.weight * candidate.capacity / weightSum) * Math.abs(remaining); + share = Math.min(share, candidate.capacity); + if (share <= 0) { return; } + if (increasing) { + candidate.entry.flow += share; + } else { + candidate.entry.flow -= share; + } + progress += share; + }); + + if (progress <= tolerance) { break; } + remaining += increasing ? -progress : progress; + } + } + + /** + * BEP-gravitation based combination finder that biases allocation around each pump's BEP. + */ + calcBestCombinationBEPGravitation(combinations, Qd, method = "BEP-Gravitation-Directional") { + let bestCombination = null; + let bestPower = Infinity; + let bestFlow = 0; + let bestCog = 0; + let bestDeviation = Infinity; + const directional = method === "BEP-Gravitation-Directional"; + + combinations.forEach(combination => { + const pumpInfos = combination.map(machineId => { + const machine = this.machines[machineId]; + const minFlow = machine.predictFlow.currentFxyYMin; + const maxFlow = machine.predictFlow.currentFxyYMax; + const span = Math.max(0, maxFlow - minFlow); + const NCog = Math.max(0, Math.min(1, machine.NCog || 0)); + const estimatedBEP = minFlow + span * NCog; + const slopes = this.estimateSlopesAtBEP(machine, estimatedBEP); + return { + id: machineId, + machine, + minFlow, + maxFlow, + NCog, + Q_BEP: slopes.Q_BEP, + slopes + }; + }); + + if (pumpInfos.length === 0) { return; } + + const flowDistribution = pumpInfos.map(info => ({ + machineId: info.id, + flow: Math.min(info.maxFlow, Math.max(info.minFlow, info.Q_BEP)) + })); + + let totalFlow = flowDistribution.reduce((sum, entry) => sum + entry.flow, 0); + const delta = Qd - totalFlow; + if (Math.abs(delta) > 1e-6) { + this.redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional); + } + + let totalPower = 0; + totalFlow = 0; + flowDistribution.forEach(entry => { + const info = pumpInfos.find(info => info.id === entry.machineId); + const flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow)); + entry.flow = flow; + totalFlow += flow; + totalPower += info.machine.inputFlowCalcPower(flow); + }); + + const totalCog = pumpInfos.reduce((sum, info) => sum + info.NCog, 0); + const deviation = pumpInfos.reduce((sum, info) => { + const entry = flowDistribution.find(item => item.machineId === info.id); + const deltaFlow = entry ? (entry.flow - info.Q_BEP) : 0; + return sum + (deltaFlow * deltaFlow) * (info.slopes.alpha || 1); + }, 0); + + const shouldUpdate = totalPower < bestPower || + (totalPower === bestPower && deviation < bestDeviation); + + if (shouldUpdate) { + bestCombination = flowDistribution.map(entry => ({ ...entry })); + bestPower = totalPower; + bestFlow = totalFlow; + bestCog = totalCog; + bestDeviation = deviation; + } + }); + + return { + bestCombination, + bestPower, + bestFlow, + bestCog, + bestDeviation, + method + }; + } + // -------- Mode and Input Management -------- // isValidActionForMode(action, mode) { @@ -490,7 +678,26 @@ class MachineGroup { // fetch all valid combinations that meet expectations const combinations = this.validPumpCombinations(this.machines, Qd, powerCap); - const bestResult = this.calcBestCombination(combinations, Qd); + + if (!combinations || combinations.length === 0) { + this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found (empty set).`); + return; + } + + // Decide which optimization routine we run. Defaults to BEP-based gravitation with directionality. + const optimizationMethod = this.config.optimization?.method || "BEP-Gravitation-Directional"; + let bestResult; + if (optimizationMethod === "NCog") { + bestResult = this.calcBestCombination(combinations, Qd); + } else if ( + optimizationMethod === "BEP-Gravitation" || + optimizationMethod === "BEP-Gravitation-Directional" + ) { + bestResult = this.calcBestCombinationBEPGravitation(combinations, Qd, optimizationMethod); + } else { + this.logger.warn(`Unknown optimization method '${optimizationMethod}', falling back to BEP-Gravitation-Directional.`); + bestResult = this.calcBestCombinationBEPGravitation(combinations, Qd, "BEP-Gravitation-Directional"); + } if(bestResult.bestCombination === null){ this.logger.warn(`Demand: ${Qd.toFixed(2)} -> No valid combination found => not updating control `); @@ -1209,4 +1416,4 @@ async function makeMachines(){ makeMachines(); -//*/ \ No newline at end of file +//*/