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>
This commit is contained in:
znetsixe
2026-05-14 22:31:25 +02:00
parent d238270530
commit 26e92b54f7
26 changed files with 2573 additions and 1790 deletions

View File

@@ -0,0 +1,125 @@
// 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).`);
});