Files
machineGroupControl/test/integration/demand-cycle-walkthrough.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

223 lines
9.8 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.
// MGC demand-cycle walkthrough — drive the machine group through a
// configurable demand sweep and print a clean per-step snapshot of every
// pump's state, ctrl%, flow and power. This is a diagnostic test, not a
// strict invariant guard: it asserts only the basics (no stuck states,
// total flow tracks demand) and prints a readable table for visual
// inspection.
//
// Knobs (env vars):
// STEP_PERCENT — demand step in percent (default 10)
// DWELL_MS — wait per step for movement (default 800)
// HEAD_MBAR — pump head in mbar (default 1100)
// N_PUMPS — number of identical pumps (default 3)
// LOG_DEBUG=1 — enable verbose domain logging (default off)
//
// Run:
// node --test nodes/machineGroupControl/test/integration/demand-cycle-walkthrough.integration.test.js
// STEP_PERCENT=5 DWELL_MS=400 node --test ...
// LOG_DEBUG=1 node --test ... # firehose mode
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const STEP_PERCENT = parseFloat(process.env.STEP_PERCENT || '10');
const DWELL_MS = parseInt(process.env.DWELL_MS || '800', 10);
const HEAD_MBAR = parseFloat(process.env.HEAD_MBAR || '1100');
const N_PUMPS = parseInt(process.env.N_PUMPS || '3', 10);
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = HEAD_MBAR;
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
// Fast ramp so each step settles within DWELL_MS.
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
// Zero sequence-step durations — startup/shutdown are instantaneous so
// the per-step delta is purely the optimizer's response, not waiting
// for the FSM.
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
};
function machineConfig(id) {
return {
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { model: 'hidrostal-H05K-S03R', 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: logCfg, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
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).
};
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', {
timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', {
timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
mgc.childRegistrationUtils.registerChild(m, 'downstream');
}
mgc.calcAbsoluteTotals();
mgc.calcDynamicTotals();
return { mgc, pumps };
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// States where the pump is not actually producing flow/power. When the FSM
// is parked in any of these, predictFlow.outputY / predictPower.outputY
// still reflect the curve floor at the current operating point — that is
// useful for the optimizer but misleading in this walkthrough table. Show
// zeros instead so each row's per-pump column matches the optimizer's
// chosen split and ΣQ matches Qd.
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
function snapshot(pump) {
const state = pump.state.getCurrentState();
const ctrl = Number(pump.state.getCurrentPosition?.() ?? 0);
const running = !NON_RUNNING.has(state);
const flow = running ? Number(pump.predictFlow?.outputY ?? 0) * 3600 : 0; // m³/s → m³/h
const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0; // W → kW
return { state, ctrl, flow, power };
}
function fmt(x, w, d = 1) { return Number.isFinite(x) ? x.toFixed(d).padStart(w) : ' n/a'.padStart(w); }
function printHeader(pumps) {
const head = ['cmd%'.padStart(5), 'Qd m³/h'.padStart(9)];
for (const p of pumps) {
head.push('|', `${p.config.general.id}`.padEnd(8), 'state'.padEnd(13), 'ctrl%'.padStart(6),
'Q m³/h'.padStart(7), 'kW'.padStart(6));
}
head.push('|', 'ΣQ m³/h'.padStart(8), 'ΣkW'.padStart(6));
const line = head.join(' ');
console.log(line);
console.log('─'.repeat(line.length));
}
function printRow(pct, demandQout_m3h, pumps) {
const snaps = pumps.map(snapshot);
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
const totalP = snaps.reduce((s, x) => s + x.power, 0);
const cells = [fmt(pct, 5), fmt(demandQout_m3h, 9)];
for (let i = 0; i < pumps.length; i++) {
const s = snaps[i];
cells.push('|', ''.padEnd(8), s.state.padEnd(13), fmt(s.ctrl, 6), fmt(s.flow, 7), fmt(s.power, 6));
}
cells.push('|', fmt(totalQ, 8), fmt(totalP, 6));
console.log(cells.join(' '));
return { totalQ, totalP, snaps };
}
test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps, step=${STEP_PERCENT}%`, async () => {
const { mgc, pumps } = buildGroup();
// Bring all pumps to operational up-front so the very first row of the
// table reflects the optimizer's response, not "the FSM is still
// booting".
for (const m of pumps) await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && pumps.some(p => p.state.getCurrentState() !== 'operational'); i++) await sleep(20);
for (const p of pumps) {
assert.equal(p.state.getCurrentState(), 'operational',
`pre-condition: pump ${p.config.general.id} should be operational; got ${p.state.getCurrentState()}`);
}
const dyn = mgc.calcDynamicTotals();
const flowMin_m3h = dyn.flow.min * 3600;
const flowMax_m3h = dyn.flow.max * 3600;
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
console.log('');
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(` 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, 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, -1];
let stuckSeen = 0;
for (const pct of sequence) {
// 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);
// 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;
const { totalQ, snaps } = printRow(pct, demandQout_m3h, pumps);
// Loose invariants:
// - demand > 0% → station total flow within 10% of optimizer's chosen
// Qout (allow slack: optimizer may pick a smaller combo for
// efficiency, in which case totalQ falls below demand only inside
// the per-pump curve envelope; we ONLY check above feasibility).
// - no pump should sit in a residue state ('accelerating' /
// 'decelerating') AFTER the dwell — that's the deadlock symptom
// the abort-deadlock test guards against.
for (const s of snaps) {
if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
}
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 ${pct}% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
}
}
}
console.log('');
console.log(`Stuck-state observations across ${sequence.length} steps: ${stuckSeen}`);
assert.equal(stuckSeen, 0,
`${stuckSeen} pump×step observations parked in accelerating/decelerating after dwell — ` +
`would indicate the abort-deadlock regression has returned (state.js post-abort residue).`);
});