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:
@@ -57,11 +57,20 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
// Post-refactor handleInput takes canonical m³/s. This helper mirrors what
|
||||
// the set.demand handler does for a bare-number (percent) payload, so test
|
||||
// scenarios that previously sent `mgc.handleInput('parent', pctToCanonical(mgc, 100))` (= 100 %)
|
||||
// keep their intent.
|
||||
function pctToCanonical(mgc, pct) {
|
||||
if (pct < 0) return -1;
|
||||
const dt = mgc.calcDynamicTotals();
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
|
||||
function buildGroup({ withPressure = true } = {}) {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
||||
@@ -137,7 +146,7 @@ test('Scenario 1 — single-shot 100% demand to idle pumps', async () => {
|
||||
console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`);
|
||||
printSnapshots('before handleInput', pumps);
|
||||
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
printSnapshots('immediately after handleInput returns', pumps);
|
||||
|
||||
// Wait for full startup (3s) + movement (~0.5s) + slack
|
||||
@@ -159,16 +168,16 @@ test('Scenario 2 — rapid 100% retargeting during startup window', async () =>
|
||||
// mid-flight, parking it in 'accelerating'/'decelerating'.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', 100) every 200ms for 5s`);
|
||||
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', pctToCanonical(mgc, 100)) every 200ms for 5s`);
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// First call (kicks off startup); not awaited so retargets can layer on.
|
||||
mgc.handleInput('parent', 100).catch(e => console.log(`first call rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`first call rejected: ${e.message}`));
|
||||
|
||||
// Spam additional retargets every 200ms for 5s — covers the 3s startup
|
||||
// window with 25 extra retargeting calls.
|
||||
const interval = setInterval(() => {
|
||||
mgc.handleInput('parent', 100).catch(e => console.log(`retarget rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`retarget rejected: ${e.message}`));
|
||||
}, 200);
|
||||
await sleep(5000);
|
||||
clearInterval(interval);
|
||||
@@ -199,7 +208,7 @@ test('Scenario 3 — pumps with NO pressure measurements injected', async () =>
|
||||
console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`);
|
||||
printSnapshots('before handleInput', pumps);
|
||||
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
await sleep(6000);
|
||||
printSnapshots('after 6s settle (no pressure)', pumps);
|
||||
|
||||
@@ -228,7 +237,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// Phase 1: drive up to 100% from idle.
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
await sleep(5000); // full startup + ramp
|
||||
printSnapshots('after settle at 100%', pumps);
|
||||
for (const p of pumps) {
|
||||
@@ -236,12 +245,14 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
|
||||
}
|
||||
|
||||
// Phase 2: demand drops to 0% — pumps begin shutdown sequence.
|
||||
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which
|
||||
// Phase 2: demand drops below 0 — pumps begin shutdown sequence. Use a
|
||||
// strictly-negative percent because 0% now means "minimum-control"
|
||||
// (interpolates to dt.flow.min), not shutdown.
|
||||
// FIRE-AND-FORGET: handleInput(-1) awaits turnOffAllMachines which
|
||||
// awaits the full per-pump shutdown sequence. We need the next 100%
|
||||
// demand to arrive WHILE pumps are still in stopping/coolingdown,
|
||||
// not after they've reached idle.
|
||||
mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`));
|
||||
mgc.turnOffAllMachines().catch(e => console.log(`-1% rejected: ${e.message}`));
|
||||
// Wait briefly so the shutdown sequence enters but does NOT complete.
|
||||
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
|
||||
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
|
||||
@@ -252,7 +263,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
|
||||
|
||||
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
// Generous: full coolingdown remaining + full startup + ramp.
|
||||
await sleep(8000);
|
||||
printSnapshots('after re-engage to 100%', pumps);
|
||||
@@ -279,7 +290,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => {
|
||||
|
||||
console.log(' --- up sweep ---');
|
||||
for (const pct of upSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
@@ -291,7 +302,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => {
|
||||
|
||||
console.log(' --- down sweep ---');
|
||||
for (const pct of downSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
@@ -340,7 +351,7 @@ test('Scenario 4 — varying demand during startup (combo flips)', async () => {
|
||||
|
||||
for (const pct of sequence) {
|
||||
console.log(` → demand ${pct}%`);
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user