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:
125
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
125
test/integration/bep-distance-demand-sweep.integration.test.js
Normal 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).`);
|
||||
});
|
||||
240
test/integration/dashboard-fanout.integration.test.js
Normal file
240
test/integration/dashboard-fanout.integration.test.js
Normal file
@@ -0,0 +1,240 @@
|
||||
// Output-coverage tests for examples/02-Dashboard.json :: fn_status_split.
|
||||
// Exercises every output port in three states (deploy / post-setup / post-demand)
|
||||
// AND verifies the per-port format contract that every downstream ui-* widget
|
||||
// or chart expects. Per .claude/rules/output-coverage.md.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(
|
||||
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
||||
const fn = flow.find(n => n.id === 'fn_status_split');
|
||||
|
||||
function runFn(msgs) {
|
||||
let ctxStore = {};
|
||||
const context = {
|
||||
get: (k) => ctxStore[k],
|
||||
set: (k, v) => { ctxStore[k] = v; },
|
||||
};
|
||||
const fn_body = new Function('msg', 'context', fn.func);
|
||||
return msgs.map(msg => fn_body(msg, context));
|
||||
}
|
||||
|
||||
// Indices into the 17-output return array. Kept here as the manifest contract
|
||||
// for this function — every test below references these names, never raw ints.
|
||||
const PORT = {
|
||||
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
|
||||
text_machines: 4, text_bep_rel: 5, text_eta: 6, text_eta_peak: 7,
|
||||
text_bep_abs: 8, text_ncog: 9,
|
||||
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
|
||||
chart_eta: 14,
|
||||
raw_rows: 15, raw_passthrough: 16,
|
||||
};
|
||||
|
||||
const initialMsg = {
|
||||
payload: {
|
||||
mode: 'optimalControl', scaling: 'normalized',
|
||||
absDistFromPeak: 0, relDistFromPeak: 0,
|
||||
flowCapacityMax: 0, flowCapacityMin: 0,
|
||||
machineCount: 3, machineCountActive: 0,
|
||||
},
|
||||
};
|
||||
const postSetupMsg = {
|
||||
payload: {
|
||||
atEquipment_predicted_flow: 0, downstream_predicted_flow: 0,
|
||||
atEquipment_predicted_power: 0,
|
||||
flowCapacityMax: 450, flowCapacityMin: 0,
|
||||
machineCountActive: 0,
|
||||
headerDiffPa: 110000, headerDiffMbar: 1100,
|
||||
},
|
||||
};
|
||||
const postDemandMsg = {
|
||||
payload: {
|
||||
atEquipment_predicted_flow: 200,
|
||||
downstream_predicted_flow: 200,
|
||||
atEquipment_predicted_power: 11.4,
|
||||
atEquipment_predicted_efficiency: 0.62,
|
||||
// Ncog as MGC actually emits it: SUM of per-pump NCog values.
|
||||
// 2 pumps each at NCog=0.6 → sum=1.2; per-pump average should display as 60.0 %.
|
||||
atEquipment_predicted_Ncog: 1.2,
|
||||
absDistFromPeak: 0.05, relDistFromPeak: 0.08,
|
||||
flowCapacityMax: 450, machineCountActive: 2,
|
||||
},
|
||||
};
|
||||
|
||||
test('manifest: function has exactly 17 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 17);
|
||||
assert.equal(fn.wires.length, 17);
|
||||
});
|
||||
|
||||
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
|
||||
const [out] = runFn([initialMsg]);
|
||||
assert.equal(out[PORT.text_mode].payload, 'optimalControl');
|
||||
assert.equal(out[PORT.text_flow].payload, '—');
|
||||
assert.equal(out[PORT.text_power].payload, '—');
|
||||
assert.equal(out[PORT.text_ncog].payload, '—');
|
||||
assert.equal(out[PORT.text_eta].payload, '—');
|
||||
});
|
||||
|
||||
test('State A: charts with no source data emit null msg, never { payload: null }', () => {
|
||||
const [out] = runFn([initialMsg]);
|
||||
// Charts 10, 12, 14 have no source data in State A → must be null (drop msg).
|
||||
assert.equal(out[PORT.chart_flow], null, 'chart_flow must be null when flow missing');
|
||||
assert.equal(out[PORT.chart_power], null, 'chart_power must be null when power missing');
|
||||
assert.equal(out[PORT.chart_eta], null, 'chart_eta must be null when eta missing');
|
||||
// For every msg-emitting chart output: payload is never literally null.
|
||||
for (const idx of Object.values(PORT)) {
|
||||
if (out[idx] && Object.prototype.hasOwnProperty.call(out[idx], 'payload')) {
|
||||
assert.notEqual(out[idx].payload, null,
|
||||
`port ${idx} emitted { payload: null } — would crash ui-chart`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('State B (post-setup, no demand): flow/power = 0, eta missing', () => {
|
||||
const [, out] = runFn([initialMsg, postSetupMsg]);
|
||||
assert.equal(out[PORT.text_flow].payload, '0.0 m³/h');
|
||||
assert.equal(out[PORT.text_power].payload, '0.00 kW');
|
||||
assert.equal(out[PORT.text_capacity].payload, '0.0 – 450.0 m³/h');
|
||||
// η still missing → '—'
|
||||
assert.equal(out[PORT.text_eta].payload, '—');
|
||||
});
|
||||
|
||||
test('State C (post-demand): every text/chart output has real value', () => {
|
||||
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
|
||||
assert.equal(out[PORT.text_flow].payload, '200.0 m³/h');
|
||||
assert.equal(out[PORT.text_power].payload, '11.40 kW');
|
||||
assert.equal(out[PORT.text_eta].payload, '62.0 %');
|
||||
// BEP abs gap: η-points dimensionless, 3 dp.
|
||||
assert.equal(out[PORT.text_bep_abs].payload, '0.050');
|
||||
// Charts have numeric payload.
|
||||
assert.equal(out[PORT.chart_flow].payload, 200);
|
||||
assert.equal(out[PORT.chart_power].payload, 11.4);
|
||||
assert.equal(out[PORT.chart_eta].payload, 62);
|
||||
});
|
||||
|
||||
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
|
||||
// The fix under test. MGC emits Ncog as the SUM of per-pump NCog values
|
||||
// (range 0..N), so a raw pct() would display 120% for 2 pumps at 0.6 each.
|
||||
// The formatter must divide by machineCountActive first.
|
||||
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
|
||||
// 2 pumps × 0.6 each = sum 1.2, mean 0.6, displayed "60.0 %".
|
||||
assert.equal(out[PORT.text_ncog].payload, '60.0 %');
|
||||
});
|
||||
|
||||
test('NCog formatter: ncogSum=0 with active pumps → 0.0 %, not em-dash', () => {
|
||||
const msg = { payload: { ...postSetupMsg.payload,
|
||||
atEquipment_predicted_Ncog: 0, machineCountActive: 3 } };
|
||||
const [out] = runFn([msg]);
|
||||
// Today this is exactly what the live MGC emits (per-pump groupNCog=0
|
||||
// for the hidrostal-H05K-S03R curve at 110 kPa). The dashboard must show
|
||||
// a clean "0.0 %" — not "—" — because we DO have data, it's just zero.
|
||||
assert.equal(out[PORT.text_ncog].payload, '0.0 %');
|
||||
});
|
||||
|
||||
test('NCog formatter: ncogSum present but machineCountActive = 0 → em-dash (no /0)', () => {
|
||||
const msg = { payload: { atEquipment_predicted_Ncog: 1.5, machineCountActive: 0 } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_ncog].payload, '—');
|
||||
});
|
||||
|
||||
test('NCog formatter: ncogSum present but machineCountActive missing → em-dash', () => {
|
||||
const msg = { payload: { atEquipment_predicted_Ncog: 1.5 /* no nAct */ } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_ncog].payload, '—');
|
||||
});
|
||||
|
||||
test('NCog formatter: 3 pumps each at NCog=0.5 (sum 1.5) → 50.0 %, not 150 %', () => {
|
||||
// Regression test for the bug class — the formatter was displaying sum × 100,
|
||||
// so 1.5 became "150.0 %". Verify the normalization sticks.
|
||||
const msg = { payload: {
|
||||
atEquipment_predicted_Ncog: 1.5,
|
||||
machineCountActive: 3,
|
||||
} };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_ncog].payload, '50.0 %');
|
||||
});
|
||||
|
||||
test('BEP rel%: undefined bepRel → "—" (degenerate homogeneous-pump case)', () => {
|
||||
// After today's groupEfficiency fix, MGC emits relDistFromPeak=undefined when
|
||||
// pumps are identical. The dashboard text formatter must display "—" — NOT
|
||||
// "0.0 %" via the +null === 0 trap.
|
||||
const msg = { payload: { mode: 'optimalControl', relDistFromPeak: undefined } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_bep_rel].payload, '—');
|
||||
});
|
||||
|
||||
test('BEP rel%: null bepRel → "—" (defensive against null emission)', () => {
|
||||
// Same trap as the NCog fix: +null === 0 → pct() would return "0.0 %".
|
||||
const msg = { payload: { relDistFromPeak: null } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_bep_rel].payload, '—');
|
||||
});
|
||||
|
||||
test('BEP rel% chart: drops msg when bepRel is null/undefined (no payload:null)', () => {
|
||||
const msg = { payload: { relDistFromPeak: undefined } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.chart_bep_rel], null, 'chart must drop msg when bepRel missing');
|
||||
});
|
||||
|
||||
// ── fn_qh_fanout: Q-H curve → chart points ────────────────────────────
|
||||
const fnQH = flow.find(n => n.id === 'fn_qh_fanout');
|
||||
|
||||
function runFanout(payload) {
|
||||
const fn_body = new Function('msg', fnQH.func);
|
||||
return fn_body({ payload });
|
||||
}
|
||||
|
||||
test('Q-H fanout: trims trailing flat-Q tail so chart axis doesn\'t blow up', () => {
|
||||
// Synthetic input mimics buildQHCurve at low ctrl%: useful range followed by
|
||||
// a horizontal tail (Q clamped to env minimum across high H).
|
||||
const points = [
|
||||
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 },
|
||||
{ Q: 20, H: 20 }, { Q: 9.5, H: 24 }, { Q: 9.5, H: 28 },
|
||||
{ Q: 9.5, H: 32 }, { Q: 9.5, H: 36 }, { Q: 9.5, H: 40 },
|
||||
];
|
||||
const [out] = runFanout({ points });
|
||||
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
|
||||
// The 5 tail points at Q=9.5 should collapse to (at most) one — the first
|
||||
// one to mark the curve's tail entry, not all five.
|
||||
const tailPoints = curvePoints.filter(p => p.payload.Q === 9.5 || p.payload.x === 9.5);
|
||||
assert.ok(tailPoints.length <= 1,
|
||||
`expected ≤1 flat-tail point, got ${tailPoints.length}: ${JSON.stringify(curvePoints)}`);
|
||||
});
|
||||
|
||||
test('Q-H fanout: still emits the rising portion of the curve unchanged', () => {
|
||||
const points = [
|
||||
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 }, { Q: 20, H: 20 },
|
||||
{ Q: 9.5, H: 24 }, { Q: 9.5, H: 28 }, // flat tail
|
||||
];
|
||||
const [out] = runFanout({ points });
|
||||
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
|
||||
const rising = curvePoints.filter(p => p.payload.x > 10);
|
||||
assert.equal(rising.length, 4, `expected 4 rising points, got ${rising.length}`);
|
||||
// First rising point preserves Q=100, H=7.
|
||||
assert.equal(rising[0].payload.x, 100);
|
||||
assert.equal(rising[0].payload.y, 7);
|
||||
});
|
||||
|
||||
test('Q-H fanout: empty/error input → null msg', () => {
|
||||
assert.equal(runFanout({ error: 'no curve', points: [] }), null);
|
||||
assert.equal(runFanout({ points: [] }), null);
|
||||
});
|
||||
|
||||
test('contract: no output ever emits { payload: null } for any of the three states', () => {
|
||||
// The original η-null bug. Re-asserted across all three states because a
|
||||
// regression here crashes the FlowFuse ui-chart with TypeError on .y.
|
||||
const states = runFn([initialMsg, postSetupMsg, postDemandMsg]);
|
||||
for (let s = 0; s < states.length; s++) {
|
||||
const out = states[s];
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const msg = out[i];
|
||||
if (msg && Object.prototype.hasOwnProperty.call(msg, 'payload')) {
|
||||
assert.notEqual(msg.payload, null,
|
||||
`state ${s} port ${i} → { payload: null } would crash ui-chart`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'absolute' },
|
||||
// No scaling field — handleInput always takes canonical m³/s post-refactor.
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
@@ -139,7 +139,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
|
||||
|
||||
// Run machineGroupControl optimalControl with absolute scaling
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
@@ -196,7 +195,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
|
||||
injectPressure(m);
|
||||
}
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
|
||||
93
test/integration/group-bep-cascade.integration.test.js
Normal file
93
test/integration/group-bep-cascade.integration.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
|
||||
|
||||
/**
|
||||
* After fixing rotatingMachine + MGC to use hydraulic efficiency
|
||||
* (η = Q·ΔP / P_shaft) instead of raw flow/power, every BEP-related output
|
||||
* on MGC should be in the dimensionless 0..1 range and respond to demand
|
||||
* changes. This check ties the whole chain together:
|
||||
* - per-machine cog updates after equalize
|
||||
* - group efficiency measurement is hydraulic (matches scale of cogs)
|
||||
* - calcDistanceBEP(eff, mean(cog), min(cog)) is non-degenerate
|
||||
*/
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 },
|
||||
};
|
||||
|
||||
function machineConfig(id, label) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: label, 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: { enabled: false, logLevel: 'error' }, name: 'TestGroup' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
async function setupGroupWithTwoPumps() {
|
||||
const m1 = new Machine(machineConfig(1, 'pump-1'), stateConfig);
|
||||
const m2 = new Machine(machineConfig(2, 'pump-2'), stateConfig);
|
||||
m1.config.asset.machineCurve = baseCurve;
|
||||
m2.config.asset.machineCurve = baseCurve;
|
||||
await m1.handleInput('parent', 'execSequence', 'startup');
|
||||
await m2.handleInput('parent', 'execSequence', 'startup');
|
||||
|
||||
const mgc = new MachineGroup(groupConfig(), stateConfig);
|
||||
// Mutate the existing machines object — replacing the reference would
|
||||
// strand operatingPoint/totals/efficiency on the original empty bag.
|
||||
mgc.machines[1] = m1;
|
||||
mgc.machines[2] = m2;
|
||||
// Set header (system) pressure differential: 800/1200 mbar => 400 mbar = 40 kPa
|
||||
mgc.measurements.type('pressure').variant('measured').position('upstream').value(80000, Date.now(), 'Pa');
|
||||
mgc.measurements.type('pressure').variant('measured').position('downstream').value(120000, Date.now(), 'Pa');
|
||||
mgc.operatingPoint.equalize();
|
||||
return { mgc, m1, m2 };
|
||||
}
|
||||
|
||||
test('after equalize, each child cog is a dimensionless 0..1 hydraulic efficiency', async () => {
|
||||
const { m1, m2 } = await setupGroupWithTwoPumps();
|
||||
// Trigger updatePosition by setting ctrl explicitly
|
||||
m1.updatePosition();
|
||||
m2.updatePosition();
|
||||
for (const m of [m1, m2]) {
|
||||
assert.ok(Number.isFinite(m.cog), `cog must be finite, got ${m.cog}`);
|
||||
assert.ok(m.cog >= 0 && m.cog <= 1.0,
|
||||
`cog must be a 0..1 hydraulic efficiency, got ${m.cog}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('operatingPoint.headerDiffPa is set by equalize and matches measured differential', async () => {
|
||||
const { mgc, m1 } = await setupGroupWithTwoPumps();
|
||||
// Equalize reads from host measurements; falls back to children when
|
||||
// header is missing. Either path should produce headerDiffPa > 0.
|
||||
// headerDiff must equal the measured differential (40 kPa) once any
|
||||
// pressure source is populated.
|
||||
assert.equal(mgc.operatingPoint.headerDiffPa, 40000,
|
||||
`headerDiffPa should equal downstream-upstream = 40000 Pa, got ${mgc.operatingPoint.headerDiffPa}`);
|
||||
// Sanity: the host's child reference is still consumable for diagnostics.
|
||||
void m1.measurements;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ function createGroupConfig(name) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
@@ -407,10 +406,14 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
|
||||
// Run optimalControl
|
||||
// Run optimalControl. handleInput takes canonical m³/s post-refactor —
|
||||
// mirror the set.demand handler's percent → canonical mapping inline.
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('normalized');
|
||||
await mg.handleInput('parent', 50, Infinity);
|
||||
function pctCanonical(mgc, pct) {
|
||||
const dt = mgc.calcDynamicTotals();
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity);
|
||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
@@ -422,7 +425,7 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
|
||||
// Run priorityControl
|
||||
mg.setMode('prioritycontrol');
|
||||
await mg.handleInput('parent', 50, Infinity, ['eff', 'std', 'weak']);
|
||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']);
|
||||
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'absolute' }, // talk to MGC in m³/h directly
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ function loadJson(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||
}
|
||||
|
||||
const FLOW_FILES = ['01-Basic.json', '02-Dashboard.json'];
|
||||
|
||||
test('examples package exists for machineGroupControl', () => {
|
||||
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
for (const file of ['README.md', ...FLOW_FILES]) {
|
||||
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing');
|
||||
}
|
||||
});
|
||||
|
||||
test('example flows are parseable arrays for machineGroupControl', () => {
|
||||
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
for (const file of FLOW_FILES) {
|
||||
const parsed = loadJson(file);
|
||||
assert.equal(Array.isArray(parsed), true);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user