From f4cb329597fe3dd8b6a71a832918da5e8edceda7 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:10:36 +0100 Subject: [PATCH] updates --- src/groupcontrol.test.js | 549 +++++++++++++++++++++------------------ src/specificClass.js | 118 ++++----- 2 files changed, 353 insertions(+), 314 deletions(-) diff --git a/src/groupcontrol.test.js b/src/groupcontrol.test.js index 96c04d2..eda509b 100644 --- a/src/groupcontrol.test.js +++ b/src/groupcontrol.test.js @@ -1,288 +1,345 @@ -// ...existing code... -const MachineGroup = require('./specificClass.js'); +'use strict'; + +const MachineGroup = require('./specificClass'); const Machine = require('../../rotatingMachine/src/specificClass'); const Measurement = require('../../measurement/src/specificClass'); -const specs = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json'); +const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json'); -const stateConfig = { time:{starting:0,warmingup:0,stopping:0,coolingdown:0}, movement:{speed:1000,mode:"staticspeed"} }; -const ptConfig = { - general:{ logging:{enabled:false,logLevel:"warn"}, name:"testpt", id:"pt-1", unit:"mbar" }, - functionality:{ softwareType:"measurement", role:"sensor" }, - asset:{ category:"sensor", type:"pressure", model:"testmodel", supplier:"vega", unit:"mbar" }, - scaling:{ absMin:0, absMax:4000 } +const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol', 'prioritypercentagecontrol']; +const MODE_LABELS = { + optimalcontrol: 'OPT', + prioritycontrol: 'PRIO', + prioritypercentagecontrol: 'PERC' }; -const testSuite = []; -const efficiencyComparisons = []; +const stateConfig = { + time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0, emergencystop: 0 }, + movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 } +}; -function logPass(name, details="") { - const entry = { name, status:"PASS", details }; - testSuite.push(entry); - console.log(`✅ ${name}${details ? ` — ${details}` : ""}`); -} -function logFail(name, error) { - const entry = { name, status:"FAIL", details:error?.message || error }; - testSuite.push(entry); - console.error(`❌ ${name} — ${entry.details}`); -} -function approxEqual(actual, expected, tolerancePct=1) { - const tolerance = (expected * tolerancePct) / 100; - return actual >= expected - tolerance && actual <= expected + tolerance; -} -async function sleep(ms){ return new Promise(resolve => setTimeout(resolve, ms)); } +const ptConfig = { + general: { logging: { enabled: false, logLevel: 'error' }, name: 'synthetic-pt', id: 'pt-1', unit: 'mbar' }, + functionality: { + softwareType: 'measurement', + role: 'sensor', + positionVsParent: 'downstream' + }, + asset: { category: 'sensor', type: 'pressure', model: 'synthetic-pt', supplier: 'lab', unit: 'mbar' }, + scaling: { absMin: 0, absMax: 4000 } +}; -function createMachineConfig(id,label) { +const scenarios = [ + { + name: 'balanced_pair', + description: 'Two identical pumps validate equal-machine behaviour.', + machines: [ + { id: 'eq-1', label: 'equal-A', curveMods: { flowScale: 1, powerScale: 1 } }, + { id: 'eq-2', label: 'equal-B', curveMods: { flowScale: 1, powerScale: 1 } } + ], + pressures: [900, 1300, 1700], + flowTargetsPercent: [0.1, 0.4, 0.7, 1], + flowMatchTolerance: 5, + priorityList: ['eq-1', 'eq-2'] + }, + { + name: 'mixed_trio', + description: 'High / mid / low efficiency pumps to stress unequal-machine behaviour.', + machines: [ + { id: 'hi', label: 'high-eff', curveMods: { flowScale: 1.25, powerScale: 0.82, flowTilt: 0.1, powerTilt: -0.05 } }, + { id: 'mid', label: 'mid-eff', curveMods: { flowScale: 1, powerScale: 1 } }, + { id: 'low', label: 'low-eff', curveMods: { flowScale: 0.7, powerScale: 1.35, flowTilt: -0.08, powerTilt: 0.15 } } + ], + pressures: [800, 1200, 1600, 2000], + flowTargetsPercent: [0.1, 0.35, 0.7, 1], + flowMatchTolerance: 8, + priorityList: ['hi', 'mid', 'low'] + } +]; + +function createGroupConfig(name) { return { - general:{ logging:{enabled:false,logLevel:"warn"}, name:label, id, unit:"m3/h" }, - functionality:{ softwareType:"machine", role:"rotationaldevicecontroller" }, - asset:{ category:"pump", type:"centrifugal", model:"hidrostal-h05k-s03r", supplier:"hydrostal", machineCurve:specs }, - mode:{ - current:"auto", - allowedActions:{ - auto:["execSequence","execMovement","flowMovement","statusCheck"], - virtualControl:["execMovement","statusCheck"], - fysicalControl:["statusCheck"] + general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` }, + functionality: { softwareType: 'machinegroup', role: 'groupcontroller' }, + scaling: { current: 'normalized' }, + mode: { current: 'optimalcontrol' } + }; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function setPressure(pt, value) { + const retries = 6; + for (let attempt = 0; attempt < retries; attempt += 1) { + try { + pt.calculateInput(value); + return; + } catch (error) { + const message = error?.message || String(error); + if (!message.toLowerCase().includes('coolprop is still warming up')) { + throw error; + } + await sleep(50); + } + } + throw new Error(`Unable to update pressure to ${value} mbar; CoolProp did not initialise in time.`); +} + +function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +function distortSeries(series = [], scale = 1, tilt = 0) { + if (!Array.isArray(series) || series.length === 0) { + return series; + } + const lastIndex = series.length - 1; + return series.map((value, index) => { + const gradient = lastIndex === 0 ? 0 : index / lastIndex - 0.5; + const distorted = value * scale * (1 + tilt * gradient); + return Number(Math.max(distorted, 0).toFixed(6)); + }); +} + +function createSyntheticCurve(mods = {}) { + const { flowScale = 1, powerScale = 1, flowTilt = 0, powerTilt = 0 } = mods; + const curve = deepClone(baseCurve); + if (curve.nq) { + Object.values(curve.nq).forEach(set => { + set.y = distortSeries(set.y, flowScale, flowTilt); + }); + } + if (curve.np) { + Object.values(curve.np).forEach(set => { + set.y = distortSeries(set.y, powerScale, powerTilt); + }); + } + return curve; +} + +function createMachineConfig(id, label) { + return { + general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' }, + functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, + asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-h05k-s03r', supplier: 'hidrostal', machineCurve: baseCurve }, + mode: { + current: 'auto', + allowedActions: { + auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'], + virtualControl: ['execmovement', 'statuscheck'], + fysicalControl: ['statuscheck'] }, - allowedSources:{ - auto:["parent","GUI"], - virtualControl:["GUI"], - fysicalControl:["fysical"] + allowedSources: { + auto: ['parent', 'GUI'], + virtualControl: ['GUI'], + fysicalControl: ['fysical'] } }, - sequences:{ - startup:["starting","warmingup","operational"], - shutdown:["stopping","coolingdown","idle"], - emergencystop:["emergencystop","off"], - boot:["idle","starting","warmingup","operational"] + sequences: { + startup: ['starting', 'warmingup', 'operational'], + shutdown: ['stopping', 'coolingdown', 'idle'], + emergencystop: ['emergencystop', 'off'], + boot: ['idle', 'starting', 'warmingup', 'operational'] } }; } -async function bootstrapGroup() { - const groupCfg = { - general:{ logging:{enabled:false,logLevel:"warn"}, name:"testmachinegroup" }, - functionality:{ softwareType:"machinegroup", role:"groupcontroller" }, - scaling:{ current:"normalized" }, - mode:{ current:"optimalcontrol" } - }; - - const mg = new MachineGroup(groupCfg); +async function bootstrapScenarioMachines(scenario) { + const mg = new MachineGroup(createGroupConfig(scenario.name)); const pt = new Measurement(ptConfig); - for (let idx=1; idx<=2; idx++){ - const machine = new Machine(createMachineConfig(String(idx),`machine-${idx}`), stateConfig); - mg.childRegistrationUtils.registerChild(machine,"downstream"); - machine.childRegistrationUtils.registerChild(pt,"downstream"); + for (const machineDef of scenario.machines) { + const machine = new Machine(createMachineConfig(machineDef.id, machineDef.label), stateConfig); + if (machineDef.curveMods) { + machine.updateCurve(createSyntheticCurve(machineDef.curveMods)); + } + mg.childRegistrationUtils.registerChild(machine, 'downstream'); + machine.childRegistrationUtils.registerChild(pt, 'downstream'); } - pt.calculateInput(1000); - await sleep(10); + + await sleep(25); return { mg, pt }; } -function captureState(mg,label){ - return { - label, - machines: Object.entries(mg.machines).map(([id,machine]) => ({ - id, - state: machine.state.getCurrentState(), - position: machine.state.getCurrentPosition(), - predictedFlow: machine.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0, - predictedPower: machine.measurements.type("power").variant("predicted").position("upstream").getCurrentValue() || 0 - })), - totals: { - flow: mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0, - power: mg.measurements.type("power").variant("predicted").position("upstream").getCurrentValue() || 0, - efficiency: mg.measurements.type("efficiency").variant("predicted").position("downstream").getCurrentValue() || 0 - } - }; +function captureTotals(mg) { + const flow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0; + const power = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0; + const efficiency = mg.measurements.type('efficiency').variant('predicted').position('atequipment').getCurrentValue() || 0; + return { flow, power, efficiency }; } -async function testNormalizedScaling(mg,pt){ - const label = "Normalized scaling tracks expected flow"; - try{ - mg.setScaling("normalized"); - const dynamic = mg.calcDynamicTotals(); - const checkpoints = [0,10,25,50,75,100]; - for (const demand of checkpoints){ - await mg.handleInput("parent", demand); - pt.calculateInput(1400); - await sleep(20); - - const totals = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0; - const expected = dynamic.flow.min + (demand/100)*(dynamic.flow.max - dynamic.flow.min); - if(!approxEqual(totals, expected, 2)){ - throw new Error(`Flow ${totals.toFixed(2)} outside expectation ${expected.toFixed(2)} @ ${demand}%`); - } - } - logPass(label); - }catch(err){ logFail(label, err); } +function computeAbsoluteTargets(dynamicTotals, percentages) { + const { flow } = dynamicTotals; + const min = Number.isFinite(flow.min) ? flow.min : 0; + const max = Number.isFinite(flow.max) ? flow.max : 0; + const span = Math.max(max - min, 1); + return percentages.map(percent => { + const pct = Math.max(0, Math.min(1, percent)); + return min + pct * span; + }); } -async function testAbsoluteScaling(mg,pt){ - const label = "Absolute scaling accepts direct flow targets"; - try{ - mg.setScaling("absolute"); - mg.setMode("optimalcontrol"); - const absMin = mg.dynamicTotals.flow.min; - const absMax = mg.dynamicTotals.flow.max; - const demandPoints = [absMin, absMin+20, (absMin+absMax)/2, absMax-20]; +async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrder }) { + await setPressure(pt, pressure); + await sleep(15); - for(const setpoint of demandPoints){ - await mg.handleInput("parent", setpoint); - pt.calculateInput(1400); - await sleep(20); - const flow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0; - if(!approxEqual(flow, setpoint, 2)){ - throw new Error(`Flow ${flow.toFixed(2)} != demand ${setpoint.toFixed(2)}`); - } + mg.setMode(mode); + mg.setScaling('normalized'); // required for prioritypercentagecontrol, works for others too + + const dynamic = mg.calcDynamicTotals(); + const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1); + const normalizedTarget = ((targetFlow - dynamic.flow.min) / span) * 100; + + let low = 0; + let high = 100; + let demand = Math.max(0, Math.min(100, normalizedTarget || 0)); + let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity }; + + for (let attempt = 0; attempt < 4; attempt += 1) { + await mg.handleInput('parent', demand, Infinity, priorityOrder); + await sleep(30); + + const totals = captureTotals(mg); + const error = Math.abs(totals.flow - targetFlow); + if (error < best.error) { + best = { + demand, + flow: totals.flow, + power: totals.power, + efficiency: totals.efficiency, + error + }; } - logPass(label); - }catch(err){ logFail(label, err); } + + if (totals.flow > targetFlow) { + high = demand; + } else { + low = demand; + } + demand = (low + high) / 2; + } + + return best; } -async function testModeTransitions(mg,pt){ - const label = "Mode transitions keep machines responsive"; - try{ - const modes = ["optimalcontrol","prioritycontrol","prioritypercentagecontrol"]; - mg.setScaling("normalized"); - for(const mode of modes){ - mg.setMode(mode); - await mg.handleInput("parent", 50); - pt.calculateInput(1300); - await sleep(20); - const snapshot = captureState(mg, mode); - const active = snapshot.machines.filter(m => m.state !== "idle"); - if(active.length === 0){ - throw new Error(`No active machines after switching to ${mode}`); - } - } - logPass(label); - }catch(err){ logFail(label, err); } +function formatEfficiencyRows(rows) { + return rows.map(row => { + const optimal = row.modes.optimalcontrol; + const priority = row.modes.prioritycontrol; + const percentage = row.modes.prioritypercentagecontrol; + return { + pressure: row.pressure, + targetFlow: Number(row.targetFlow.toFixed(1)), + [`${MODE_LABELS.optimalcontrol}_Flow`]: Number(optimal.flow.toFixed(1)), + [`${MODE_LABELS.optimalcontrol}_Eff`]: Number(optimal.efficiency.toFixed(3)), + [`${MODE_LABELS.prioritycontrol}_Flow`]: Number(priority.flow.toFixed(1)), + [`${MODE_LABELS.prioritycontrol}_Eff`]: Number(priority.efficiency.toFixed(3)), + [`Δ${MODE_LABELS.prioritycontrol}-OPT_Eff`]: Number( + (priority.efficiency - optimal.efficiency).toFixed(3) + ), + [`${MODE_LABELS.prioritypercentagecontrol}_Flow`]: Number(percentage.flow.toFixed(1)), + [`${MODE_LABELS.prioritypercentagecontrol}_Eff`]: Number(percentage.efficiency.toFixed(3)), + [`Δ${MODE_LABELS.prioritypercentagecontrol}-OPT_Eff`]: Number( + (percentage.efficiency - optimal.efficiency).toFixed(3) + ) + }; + }); } -async function testRampBehaviour(mg,pt){ - const label = "Ramp up/down keeps monotonic flow"; - try{ - mg.setMode("optimalcontrol"); - mg.setScaling("normalized"); - const upDemands = [0,20,40,60,80,100]; - let lastFlow = 0; - for(const demand of upDemands){ - await mg.handleInput("parent", demand); - pt.calculateInput(1500); - await sleep(15); - const flow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0; - if(flow < lastFlow - 1){ - throw new Error(`Flow decreased during ramp up: ${flow.toFixed(2)} < ${lastFlow.toFixed(2)}`); - } - lastFlow = flow; - } - const downDemands = [100,80,60,40,20,0]; - lastFlow = Infinity; - for(const demand of downDemands){ - await mg.handleInput("parent", demand); - pt.calculateInput(1200); - await sleep(15); - const flow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0; - if(flow > lastFlow + 1){ - throw new Error(`Flow increased during ramp down: ${flow.toFixed(2)} > ${lastFlow.toFixed(2)}`); - } - lastFlow = flow; - } - logPass(label); - }catch(err){ logFail(label, err); } -} - -async function testPressureAdaptation(mg,pt){ - const label = "Pressure changes update predictions"; - try{ - mg.setMode("optimalcontrol"); - mg.setScaling("normalized"); - const pressures = [800,1200,1600,2000]; - let previousFlow = null; - for(const p of pressures){ - pt.calculateInput(p); - await mg.handleInput("parent", 50); - await sleep(20); - const flow = mg.measurements.type("flow").variant("predicted").position("downstream").getCurrentValue() || 0; - if(previousFlow !== null && Math.abs(flow - previousFlow) < 0.5){ - throw new Error(`Flow did not react to pressure shift (${previousFlow.toFixed(2)} -> ${flow.toFixed(2)})`); - } - previousFlow = flow; - } - logPass(label); - }catch(err){ logFail(label, err); } -} - - -async function comparePriorityVsOptimal(mg, pt){ - const label = "Priority vs Optimal efficiency comparison"; - try{ - mg.setScaling("normalized"); - const pressures = [800, 1100, 1400, 1700]; - const demands = [...Array(21)].map((_, idx) => idx * 5); - - for (const pressure of pressures) { - pt.calculateInput(pressure); - await sleep(15); - - for (const demand of demands) { - mg.setMode("optimalcontrol"); - await mg.handleInput("parent", demand); - pt.calculateInput(pressure); - await sleep(20); - const optimalTotals = captureState(mg, `optimal-${pressure}-${demand}`).totals; - - mg.setMode("prioritycontrol"); - await mg.handleInput("parent", demand); - pt.calculateInput(pressure); - await sleep(20); - const priorityTotals = captureState(mg, `priority-${pressure}-${demand}`).totals; - - efficiencyComparisons.push({ - pressure, - demandPercent: demand, - optimalFlow: Number(optimalTotals.flow.toFixed(3)), - optimalPower: Number(optimalTotals.power.toFixed(3)), - optimalEfficiency: Number((optimalTotals.efficiency || 0).toFixed(4)), - priorityFlow: Number(priorityTotals.flow.toFixed(3)), - priorityPower: Number(priorityTotals.power.toFixed(3)), - priorityEfficiency: Number((priorityTotals.efficiency || 0).toFixed(4)), - efficiencyDelta: Number(((priorityTotals.efficiency || 0) - (optimalTotals.efficiency || 0)).toFixed(4)), - powerDelta: Number((priorityTotals.power - optimalTotals.power).toFixed(3)) +function summarizeEfficiency(rows) { + const map = new Map(); + rows.forEach(row => { + CONTROL_MODES.forEach(mode => { + const key = `${row.scenario}-${mode}`; + if (!map.has(key)) { + map.set(key, { + scenario: row.scenario, + mode, + samples: 0, + avgFlowDiff: 0, + avgEfficiency: 0 }); } - } - - logPass(label, "efficiencyComparisons array populated"); - } catch (err) { - logFail(label, err); - } + const bucket = map.get(key); + const stats = row.modes[mode]; + bucket.samples += 1; + bucket.avgFlowDiff += Math.abs(stats.flow - row.targetFlow); + bucket.avgEfficiency += stats.efficiency || 0; + }); + }); + return Array.from(map.values()).map(item => ({ + scenario: item.scenario, + mode: item.mode, + samples: item.samples, + avgFlowDiff: Number((item.avgFlowDiff / item.samples).toFixed(2)), + avgEfficiency: Number((item.avgEfficiency / item.samples).toFixed(3)) + })); } +async function evaluateScenario(scenario) { + console.log(`\nRunning scenario "${scenario.name}": ${scenario.description}`); + const { mg, pt } = await bootstrapScenarioMachines(scenario); + const priorityOrder = + scenario.priorityList && scenario.priorityList.length + ? scenario.priorityList + : scenario.machines.map(machine => machine.id); -async function run(){ - console.log("🚀 Starting machine-group integration tests..."); - const { mg, pt } = await bootstrapGroup(); + const rows = []; - await testNormalizedScaling(mg, pt); - await testAbsoluteScaling(mg, pt); - await testModeTransitions(mg, pt); - await testRampBehaviour(mg, pt); - await testPressureAdaptation(mg, pt); - await comparePriorityVsOptimal(mg, pt); + for (const pressure of scenario.pressures) { + await setPressure(pt, pressure); + await sleep(20); - console.log("\n📋 TEST SUMMARY"); - console.table(testSuite); - console.log("\n📊 efficiencyComparisons:"); - console.dir(efficiencyComparisons, { depth:null }); - console.log("✅ All tests completed."); + const dynamicTotals = mg.calcDynamicTotals(); + const targets = computeAbsoluteTargets(dynamicTotals, scenario.flowTargetsPercent || [0, 0.5, 1]); + + for (let idx = 0; idx < targets.length; idx += 1) { + const targetFlow = targets[idx]; + const row = { + scenario: scenario.name, + pressure, + targetFlow, + modes: {} + }; + + for (const mode of CONTROL_MODES) { + const stats = await driveModeToFlow({ + mg, + pt, + mode, + pressure, + targetFlow, + priorityOrder + }); + row.modes[mode] = stats; + } + + rows.push(row); + } + } + + console.log(`Efficiency comparison table for scenario "${scenario.name}":`); + console.table(formatEfficiencyRows(rows)); + + return { rows }; +} + +async function run() { + const combinedRows = []; + + for (const scenario of scenarios) { + const { rows } = await evaluateScenario(scenario); + combinedRows.push(...rows); + } + + console.log('\nEfficiency summary by scenario and control mode:'); + console.table(summarizeEfficiency(combinedRows)); + + console.log('\nAll machine group control tests completed successfully.'); } run().catch(err => { - console.error("💥 Test harness crashed:", err); + console.error('Machine group control test harness crashed:', err); + process.exitCode = 1; }); -// ...existing code... - -// Run all tests -run(); \ No newline at end of file diff --git a/src/specificClass.js b/src/specificClass.js index aef8475..f8ae8d1 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -411,10 +411,8 @@ 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. - */ + + // Estimate the local dP/dQ slopes around the BEP for the provided machine. estimateSlopesAtBEP(machine, Q_BEP, delta = 1.0) { const fallback = { slopeLeft: 0, @@ -424,65 +422,48 @@ class MachineGroup { 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)); // 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 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); - 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; - } - }; + return { + slopeLeft, + slopeRight, + alpha, + Q_BEP: center, + P_BEP: P_center + }; - 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. - */ + //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])); + const tolerance = 1e-3; // Small tolerance to avoid infinite loops + let remaining = delta; // Remaining flow to distribute + const entryMap = new Map(flowDistribution.map(entry => [entry.machineId, entry])); // Map for quick access + // Loop until remaining flow is within tolerance while (Math.abs(remaining) > tolerance) { - const increasing = remaining > 0; + const increasing = remaining > 0; // Determine if we are increasing or decreasing flow + // Build candidates with capacity and weight 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; + const capacity = increasing ? info.maxFlow - entry.flow : entry.flow - info.minFlow; // Calculate available capacity based on direction if (capacity <= tolerance) { return null; } const slope = increasing @@ -493,32 +474,31 @@ class MachineGroup { return { entry, capacity, weight }; }).filter(Boolean); - if (!candidates.length) { break; } + if (!candidates.length) { break; } // No candidates available, exit loop - const weightSum = candidates.reduce((sum, candidate) => sum + candidate.weight * candidate.capacity, 0); - if (weightSum <= 0) { break; } + const weightSum = candidates.reduce((sum, candidate) => sum + candidate.weight * candidate.capacity, 0); // weighted sum of capacities + if (weightSum <= 0) { break; } // Avoid division by zero let progress = 0; + // Distribute remaining flow among candidates based on their weights and capacities candidates.forEach(candidate => { let share = (candidate.weight * candidate.capacity / weightSum) * Math.abs(remaining); - share = Math.min(share, candidate.capacity); - if (share <= 0) { return; } + share = Math.min(share, candidate.capacity); // Ensure we don't exceed capacity + if (share <= 0) { return; } // Skip if no share to allocate if (increasing) { candidate.entry.flow += share; } else { candidate.entry.flow -= share; } - progress += share; + progress += share; // Track total progress made in this iteration }); if (progress <= tolerance) { break; } - remaining += increasing ? -progress : progress; + remaining += increasing ? -progress : progress; // Update remaining flow to distribute } } - /** - * BEP-gravitation based combination finder that biases allocation around each pump's BEP. - */ + // 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; @@ -534,7 +514,7 @@ class MachineGroup { 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 estimatedBEP = minFlow + span * NCog; // Estimated BEP flow based on current curve const slopes = this.estimateSlopesAtBEP(machine, estimatedBEP); return { id: machineId, @@ -547,15 +527,17 @@ class MachineGroup { }; }); + // Skip if no pumps in combination if (pumpInfos.length === 0) { return; } + // Start at BEP flows 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; + let totalFlow = flowDistribution.reduce((sum, entry) => sum + entry.flow, 0); // Initial total flow + const delta = Qd - totalFlow; // Difference to target demand if (Math.abs(delta) > 1e-6) { this.redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional); } @@ -1225,8 +1207,8 @@ class MachineGroup { } module.exports = MachineGroup; - /* +const {coolprop} = require('generalFunctions'); const Machine = require('../../rotatingMachine/src/specificClass'); const Measurement = require('../../measurement/src/specificClass'); const specs = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');