'use strict'; const MachineGroup = require('./specificClass'); const Machine = require('../../rotatingMachine/src/specificClass'); const Measurement = require('../../measurement/src/specificClass'); const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json'); const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol', 'prioritypercentagecontrol']; const MODE_LABELS = { optimalcontrol: 'OPT', prioritycontrol: 'PRIO', prioritypercentagecontrol: 'PERC' }; const stateConfig = { time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0, emergencystop: 0 }, movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 } }; 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 } }; 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: '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'] } }, sequences: { startup: ['starting', 'warmingup', 'operational'], shutdown: ['stopping', 'coolingdown', 'idle'], emergencystop: ['emergencystop', 'off'], boot: ['idle', 'starting', 'warmingup', 'operational'] } }; } async function bootstrapScenarioMachines(scenario) { const mg = new MachineGroup(createGroupConfig(scenario.name)); const pt = new Measurement(ptConfig); 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'); } await sleep(25); return { mg, pt }; } 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 }; } 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 driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrder }) { await setPressure(pt, pressure); await sleep(15); 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 }; } if (totals.flow > targetFlow) { high = demand; } else { low = demand; } demand = (low + high) / 2; } return best; } 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) ) }; }); } 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 }); } 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); const rows = []; for (const pressure of scenario.pressures) { await setPressure(pt, pressure); await sleep(20); 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('Machine group control test harness crashed:', err); process.exitCode = 1; });