Files
machineGroupControl/test/integration/distribution-power-table.integration.test.js
znetsixe d55f401ab3 fix: production hardening — unit mismatch, safety guards, marginal-cost refinement
- Fix flowmovement unit mismatch: MGC computed flow in canonical (m³/s)
  but rotatingMachine expects output units (m³/h). All flowmovement calls
  now convert via _canonicalToOutputFlow(). Without this fix, every pump
  stayed at minimum flow regardless of demand.
- Fix absolute scaling: demandQout vs demandQ comparison bug, reorder
  conditions so <= 0 is checked first, add else branch for valid demand.
- Fix empty Qd <= 0 block: now calls turnOffAllMachines().
- Add empty-machines guards on optimalControl and equalizePressure.
- Add null fallback (|| 0) on pressure measurement reads.
- Fix division-by-zero in calcRelativeDistanceFromPeak.
- Fix missing flowmovement after startup in equalFlowControl.
- Add marginal-cost refinement loop in BEP-Gravitation: after slope-based
  redistribution, iteratively shifts flow from highest actual dP/dQ to
  lowest using real power evaluations. Closes gap to brute-force optimum
  from 2.1% to <0.1% without affecting combination selection stability.
- Add NCog distribution comparison tests and brute-force power table test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:40:45 +02:00

228 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* machineGroupControl vs naive strategies — real pump curves
*
* Station: 2× hidrostal H05K-S03R + 1× hidrostal C5-D03R-SHN1
* ΔP = 2000 mbar
*
* Compares the ACTUAL machineGroupControl optimalControl algorithm against
* naive baselines. All strategies must deliver exactly Qd.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const DIFF_MBAR = 2000;
const UP_MBAR = 500;
const DOWN_MBAR = UP_MBAR + DIFF_MBAR;
const stateConfig = {
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 }
};
function machineConfig(id, model) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal', model, 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: 'station' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
scaling: { current: 'absolute' },
mode: { current: 'optimalcontrol' }
};
}
function injectPressure(m) {
m.updateMeasuredPressure(UP_MBAR, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(DOWN_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
}
/* ---- naive baselines (pumps OFF = 0 flow, 0 power) ---- */
function distribute(machines, running, rawDist, Qd) {
const dist = {};
for (const id of Object.keys(machines)) dist[id] = 0;
for (const id of running) {
const m = machines[id];
dist[id] = Math.min(m.predictFlow.currentFxyYMax, Math.max(m.predictFlow.currentFxyYMin, rawDist[id] || 0));
}
for (let pass = 0; pass < 20; pass++) {
let rem = Qd - running.reduce((s, id) => s + dist[id], 0);
if (Math.abs(rem) < 1e-9) break;
for (const id of running) {
if (Math.abs(rem) < 1e-9) break;
const m = machines[id];
const cap = rem > 0 ? m.predictFlow.currentFxyYMax - dist[id] : dist[id] - m.predictFlow.currentFxyYMin;
if (cap > 1e-9) { const t = Math.min(Math.abs(rem), cap); dist[id] += rem > 0 ? t : -t; rem += rem > 0 ? -t : t; }
}
}
return dist;
}
function spillover(machines, Qd) {
const sorted = Object.keys(machines).sort((a, b) => machines[a].predictFlow.currentFxyYMax - machines[b].predictFlow.currentFxyYMax);
let running = [], maxCap = 0;
for (const id of sorted) { running.push(id); maxCap += machines[id].predictFlow.currentFxyYMax; if (maxCap >= Qd) break; }
const raw = {}; let rem = Qd;
for (const id of running) { raw[id] = rem; rem = Math.max(0, rem - machines[id].predictFlow.currentFxyYMax); }
const dist = distribute(machines, running, raw, Qd);
let p = 0, f = 0;
for (const id of running) { p += machines[id].inputFlowCalcPower(dist[id]); f += dist[id]; }
return { dist, power: p, flow: f, combo: running };
}
function equalAllOn(machines, Qd) {
const ids = Object.keys(machines);
const raw = {}; for (const id of ids) raw[id] = Qd / ids.length;
const dist = distribute(machines, ids, raw, Qd);
let p = 0, f = 0;
for (const id of ids) { p += machines[id].inputFlowCalcPower(dist[id]); f += dist[id]; }
return { dist, power: p, flow: f, combo: ids };
}
/* ---- test ---- */
test('machineGroupControl vs naive baselines — real curves, verified flow', async () => {
const mg = new MachineGroup(groupConfig());
const machines = {};
for (const [id, model] of [['H05K-1','hidrostal-H05K-S03R'],['H05K-2','hidrostal-H05K-S03R'],['C5','hidrostal-C5-D03R-SHN1']]) {
const m = new Machine(machineConfig(id, model), stateConfig);
injectPressure(m);
mg.childRegistrationUtils.registerChild(m, 'downstream');
machines[id] = m;
}
const toH = (v) => +(v * 3600).toFixed(1);
const CANON_FLOW = 'm3/s';
const CANON_POWER = 'W';
console.log(`\n=== STATION: 2×H05K + 1×C5 @ ΔP=${DIFF_MBAR} mbar ===`);
console.table(Object.entries(machines).map(([id, m]) => ({
id,
'min (m³/h)': toH(m.predictFlow.currentFxyYMin),
'max (m³/h)': toH(m.predictFlow.currentFxyYMax),
'BEP (m³/h)': toH(m.predictFlow.currentFxyYMin + (m.predictFlow.currentFxyYMax - m.predictFlow.currentFxyYMin) * m.NCog),
NCog: +m.NCog.toFixed(3),
})));
const minQ = Math.max(...Object.values(machines).map(m => m.predictFlow.currentFxyYMin));
const maxQ = Object.values(machines).reduce((s, m) => s + m.predictFlow.currentFxyYMax, 0);
const demandPcts = [0.10, 0.25, 0.50, 0.75, 0.90];
const rows = [];
for (const pct of demandPcts) {
const Qd = minQ + (maxQ - minQ) * pct;
// Reset all machines to idle, re-inject pressure
for (const m of Object.values(machines)) {
if (m.state.getCurrentState() !== 'idle') await m.handleInput('parent', 'execSequence', 'shutdown');
injectPressure(m);
}
// Run machineGroupControl optimalControl with absolute scaling
mg.setMode('optimalcontrol');
mg.setScaling('absolute');
mg.calcAbsoluteTotals();
mg.calcDynamicTotals();
await mg.handleInput('parent', Qd);
// Read ACTUAL per-pump state (not the MGC summary which may be stale)
let mgcPower = 0, mgcFlow = 0;
const mgcCombo = [];
const mgcDist = {};
for (const [id, m] of Object.entries(machines)) {
const state = m.state.getCurrentState();
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(CANON_FLOW) || 0;
const power = m.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue(CANON_POWER) || 0;
mgcDist[id] = { flow, power, state };
if (state === 'operational' || state === 'warmingup' || state === 'accelerating') {
mgcCombo.push(id);
mgcPower += power;
mgcFlow += flow;
}
}
// Naive baselines
const sp = spillover(machines, Qd);
const ea = equalAllOn(machines, Qd);
const best = Math.min(mgcPower, sp.power, ea.power);
const delta = (v) => best > 0 ? `${(((v - best) / best) * 100).toFixed(1)}%` : '';
rows.push({
demand: `${(pct * 100)}%`,
'Qd (m³/h)': toH(Qd),
'MGC kW': +(mgcPower / 1000).toFixed(1),
'MGC flow': toH(mgcFlow),
'MGC pumps': mgcCombo.join('+') || 'none',
'Spill kW': +(sp.power / 1000).toFixed(1),
'Spill flow': toH(sp.flow),
'Spill pumps': sp.combo.join('+'),
'EqAll kW': +(ea.power / 1000).toFixed(1),
'EqAll flow': toH(ea.flow),
'MGC Δ': delta(mgcPower),
'Spill Δ': delta(sp.power),
'EqAll Δ': delta(ea.power),
});
}
console.log('\n=== POWER + FLOW COMPARISON (★ = best, all must deliver Qd) ===');
console.table(rows);
// Per-pump detail at each demand level
for (const pct of demandPcts) {
const Qd = minQ + (maxQ - minQ) * pct;
for (const m of Object.values(machines)) {
if (m.state.getCurrentState() !== 'idle') await m.handleInput('parent', 'execSequence', 'shutdown');
injectPressure(m);
}
mg.setMode('optimalcontrol');
mg.setScaling('absolute');
mg.calcAbsoluteTotals();
mg.calcDynamicTotals();
await mg.handleInput('parent', Qd);
const detail = Object.entries(machines).map(([id, m]) => {
const state = m.state.getCurrentState();
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(CANON_FLOW) || 0;
const power = m.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue(CANON_POWER) || 0;
return {
pump: id,
state,
'flow (m³/h)': toH(flow),
'power (kW)': +(power / 1000).toFixed(1),
};
});
console.log(`\n--- MGC per-pump @ ${(pct*100)}% (${toH(Qd)} m³/h) ---`);
console.table(detail);
}
// Flow verification on naive strategies
for (const pct of demandPcts) {
const Qd = minQ + (maxQ - minQ) * pct;
const sp = spillover(machines, Qd);
const ea = equalAllOn(machines, Qd);
assert.ok(Math.abs(sp.flow - Qd) < Qd * 0.005, `Spillover flow mismatch at ${(pct*100)}%`);
assert.ok(Math.abs(ea.flow - Qd) < Qd * 0.005, `Equal-all flow mismatch at ${(pct*100)}%`);
}
});