Files
machineGroupControl/test/integration/bep-distance-demand-sweep.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

126 lines
5.6 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.
// Empirical answer: does absDistFromPeak / relDistFromPeak move with demand?
// Drives the live MGC + 3 identical pumps (same model as the dashboard demo)
// across a demand sweep and records what each metric actually does. The test
// asserts the expected qualitative shape, so any future change that
// regresses BEP-distance sensitivity will fail loudly.
const test = require('node:test');
const assert = require('node:assert/strict');
const RM = require('../../../rotatingMachine/src/specificClass');
const MGC = require('../../src/specificClass');
const { getOutput } = require('../../src/io/output');
const PUMP_MODEL = 'hidrostal-H05K-S03R';
const HEADER_DP_MBAR = 1100;
// stateConfig.time = 0 for every transition so warmup/cooldown don't add real
// seconds — without this the 4-demand sweep × 3 pumps takes >120s and the test
// runner kills it.
const INSTANT_STATE = {
time: { starting: 0, warmingup: 0, operational: 0, accelerating: 0,
decelerating: 0, stopping: 0, coolingdown: 0, idle: 0,
maintenance: 0, emergencystop: 0, off: 0 },
};
function mkPump(id) {
return new RM({
general: { id, name: id },
asset: { model: PUMP_MODEL, unit: 'm3/h' },
}, INSTANT_STATE);
}
async function buildGroupWithPressure() {
const mgc = new MGC({
general: { id: 'mgc', name: 'mgc' },
functionality: { mode: { current: 'optimalControl' }, positionVsParent: 'atEquipment' },
});
const pumps = ['A','B','C'].map(l => mkPump(`pump-${l}`));
for (const p of pumps) {
mgc.childRegistrationUtils?.registerChild?.(p, 'atEquipment');
}
for (const p of pumps) {
p.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-up' });
p.updateMeasuredPressure(HEADER_DP_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-dn' });
}
// Let pressure events propagate through the emitter chain.
await new Promise(r => setTimeout(r, 50));
return { mgc, pumps };
}
async function sweepDemand(mgc, demands_m3h) {
const rows = [];
for (const Qd_m3h of demands_m3h) {
const Qd = Qd_m3h / 3600; // m3/h → m3/s
try { await mgc.handleInput('parent', Qd); }
catch (e) { /* turnOff or no-combination paths are part of the contract */ }
await new Promise(r => setTimeout(r, 30));
const out = getOutput(mgc);
rows.push({
demand: Qd_m3h,
flow: out.atEquipment_predicted_flow,
eta: out.atEquipment_predicted_efficiency,
absDist: out.absDistFromPeak,
relDist: out.relDistFromPeak,
ncog: out.atEquipment_predicted_Ncog,
nAct: out.machineCountActive,
});
}
return rows;
}
test('absDistFromPeak rises when demand pushes pumps off BEP', async () => {
const { mgc } = await buildGroupWithPressure();
// Sweep covers "comfortably within combined BEP" (low/mid) and "over the
// group's BEP envelope, pumps must push" (high). For hidrostal-H05K-S03R
// at 1100 mbar, single-pump max ≈ 230 m³/h, 3-pump max ≈ 680 m³/h. Demand
// 600 m³/h forces each pump well past BEP.
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
// Sanity: pumps actually accepted the demand and flow is rising.
assert.ok(rows[3].flow > rows[0].flow + 100,
`flow should rise with demand, got ${JSON.stringify(rows.map(r => r.flow))}`);
// absDist should be larger at over-capacity demand than at within-capacity.
// Use a generous tolerance — the test asserts the QUALITATIVE shape, not
// exact numbers (which depend on curve interpolation).
const lowAbs = Math.min(rows[0].absDist, rows[1].absDist, rows[2].absDist);
const highAbs = rows[3].absDist;
assert.ok(highAbs > lowAbs + 0.005,
`absDistFromPeak should be larger off-BEP than on-BEP. ` +
`low (Qd∈{100,200,300}): min=${lowAbs}, high (Qd=600): ${highAbs}. ` +
`Full rows: ${JSON.stringify(rows, null, 2)}`);
});
test('absDistFromPeak ≈ 0 across the within-BEP demand range (working as designed)', async () => {
const { mgc } = await buildGroupWithPressure();
const rows = await sweepDemand(mgc, [100, 200, 300]);
// The BEP-Gravitation optimizer is supposed to KEEP us at BEP for demands
// the group can absorb at BEP. So absDist staying near zero across the
// "easy" range is the correct outcome — NOT a bug. This test pins that
// behaviour so any future "fix" that introduces drift here fails.
for (const r of rows) {
assert.ok(r.absDist != null && r.absDist < 0.02,
`at demand ${r.demand} m³/h, absDist=${r.absDist} should be near zero ` +
`(optimizer holds BEP); only off-BEP demand should produce noticeable drift`);
}
});
test('relDistFromPeak is structurally ill-defined for homogeneous pump groups', async () => {
const { mgc } = await buildGroupWithPressure();
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
// 3 identical pumps → all cogs equal → max=mean=min in calcDistanceBEP.
// The interpolation [max..min] → [0..1] collapses; the metric is
// mathematically undefined here. Whatever value comes out is float-noise
// dependent and MUST NOT be interpreted as "BEP distance percentage".
// This test documents the limitation as a contract; it deliberately does
// not assert a specific value — it asserts the metric does NOT move
// monotonically with demand (which it shouldn't for identical pumps).
const uniqueRel = new Set(rows.map(r => r.relDist));
assert.ok(uniqueRel.size <= 2,
`relDistFromPeak is expected to be effectively constant for identical pumps. ` +
`Distinct values across sweep: ${[...uniqueRel].join(', ')}. ` +
`If you want this metric to track demand, configure pumps with different ` +
`peak η (different models or different curve scaling).`);
});