Files
machineGroupControl/test/integration/distribution-power-table.integration.test.js
znetsixe 26e92b54f7 governance + unit-self-describing demand + dashboard fixes
Two governance items from the 2026-05-14 quality review:
- test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its
  source, type, range, and which tests cover it in populated/degraded states
  (per .claude/rules/output-coverage.md).
- src/control/strategies.js extracts computeEqualFlowDistribution as a pure
  function so the equal-flow algorithm is testable without an MGC fixture.
  test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three
  demand branches and pins the legacy quirk where the default branch counts
  active machines but iterates priority-ordered first-N (documented in the
  test so the future cleanup is a deliberate change).

Plus rolled-up session work that landed alongside:
- set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...}
  or bare number = %); setScaling/scaling.current removed from MGC, commands,
  editor (mgc.html), specificClass.
- _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather
  than Q/P, keeping the metric in the same scale as each child's cog.
- groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when
  pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as
  '-' instead of showing a misleading 100% / 0% reading.
- examples/02-Dashboard.json: auto-init inject so the dashboard populates at
  deploy, NCog formatter normalizes the SUM emitted by MGC by
  machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't
  stretched to 40m by curve-envelope clamp points, num/pct treat null AND
  undefined as no-data (closes the +null === 0 trap).
- new test/integration/dashboard-fanout.integration.test.js (17 tests),
  bep-distance-demand-sweep.integration.test.js (3 tests),
  group-bep-cascade.integration.test.js -- total suite now 108/108 green.
- .gitignore: wiki/test.gif (143 MB screen recording, kept locally only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:31:25 +02:00

226 lines
8.6 KiB
JavaScript
Raw Permalink 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: { model, unit: 'm3/h' },
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' },
// No scaling field — handleInput always takes canonical m³/s post-refactor.
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.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.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)}%`);
}
});