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

@@ -67,8 +67,10 @@ function groupConfig() {
return {
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
scaling: { current: 'normalized' }, // demand expressed as 0..100 %
mode: { current: 'optimalcontrol' }, // production mode
// No scaling config: post-refactor MGC has no scaling state. handleInput
// takes canonical m³/s. Test converts pct → m³/s before dispatch (mirrors
// what the set.demand handler does for bare-number payloads).
};
}
@@ -159,24 +161,33 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps,
console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`);
console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`);
console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
console.log(` scaling=normalized: 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`);
console.log(` (demand 0% turns ALL pumps off — see MGC handleInput)`);
console.log(` 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`);
console.log(` (demand < 0 turns ALL pumps off; 0 = minimum-control floor)`);
console.log('');
printHeader(pumps);
// Build demand sweep: 0..100% up, then 100..0% down.
// Build demand sweep: 0..100% up, then 100..0% down, then -1 (all-off sentinel).
const upSteps = [];
for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100));
const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100
const sequence = [...upSteps, ...downSteps];
const sequence = [...upSteps, ...downSteps, -1];
let stuckSeen = 0;
for (const pct of sequence) {
await mgc.handleInput('parent', pct);
// Post-refactor handleInput takes canonical m³/s; the percent → m³/s
// mapping the set.demand handler does is replicated here in test.
if (pct < 0) {
await mgc.turnOffAllMachines();
} else {
const flowMin_m3s = flowMin_m3h / 3600;
const flowMax_m3s = flowMax_m3h / 3600;
const canonical = flowMin_m3s + (pct / 100) * (flowMax_m3s - flowMin_m3s);
await mgc.handleInput('parent', canonical);
}
await sleep(DWELL_MS);
// Mirror MGC's normalized→absolute mapping for the printed Qd column.
const demandQout_m3h = pct <= 0
// pct < 0 → all off (Qd = 0); pct >= 0 → linear interpolation across [min, max].
const demandQout_m3h = pct < 0
? 0
: (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h;
@@ -194,11 +205,11 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps,
if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
}
if (pct === 0) {
// Demand 0% must turn ALL pumps off (or to a non-running state).
if (pct < 0) {
// Strict negative demand turns ALL pumps off (the explicit "all off" signal).
for (const s of snaps) {
assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state),
`demand 0% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
`demand ${pct}% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
}
}
}