255 lines
12 KiB
JavaScript
255 lines
12 KiB
JavaScript
|
|
// MGC planner — real-time CONVERGENCE diagnostic.
|
||
|
|
//
|
||
|
|
// Where planner-rendezvous.integration.test.js intercepts _fireCommand to
|
||
|
|
// only assert schedule SHAPE, this test lets the executor REALLY run on
|
||
|
|
// real pumps with non-zero startup/warmup times, and asks two questions:
|
||
|
|
//
|
||
|
|
// (a) does sum-of-pump-flows converge to the demand setpoint?
|
||
|
|
// (b) do all pumps reach their individual flow target at roughly the
|
||
|
|
// same wall-clock instant (the rendezvous)?
|
||
|
|
//
|
||
|
|
// Realistic scenario: ONE pump already operational, TWO pumps idle. A new
|
||
|
|
// demand requires (i) the two idle pumps to start (slow, ~3.5s) AND (ii)
|
||
|
|
// the running pump to retarget. Per the planner code, only flow-DECREASING
|
||
|
|
// moves get delayed to land at t*; flow-INCREASING moves on running pumps
|
||
|
|
// fire at tick 0 and land at their own eta. So the running pump's landing
|
||
|
|
// time should NOT match the two idle pumps unless its target equals its
|
||
|
|
// current flow (an unusual coincidence). This test surfaces that.
|
||
|
|
|
||
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
|
||
|
|
const MachineGroup = require('../../src/specificClass');
|
||
|
|
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||
|
|
|
||
|
|
const HEAD_MBAR_UP = 0;
|
||
|
|
const HEAD_MBAR_DOWN = 1100;
|
||
|
|
const N_PUMPS = 3;
|
||
|
|
|
||
|
|
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
|
||
|
|
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
|
||
|
|
|
||
|
|
const stateConfig = {
|
||
|
|
general: { logging: logCfg },
|
||
|
|
state: { current: 'idle' },
|
||
|
|
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
|
||
|
|
// REAL ladder times — this is the whole point of the test.
|
||
|
|
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
|
||
|
|
};
|
||
|
|
|
||
|
|
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' },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
|
||
|
|
function pumpFlow_m3h(pump) {
|
||
|
|
const state = pump.state.getCurrentState();
|
||
|
|
if (NON_RUNNING.has(state)) return 0;
|
||
|
|
return Number(pump.predictFlow?.outputY ?? 0) * 3600;
|
||
|
|
}
|
||
|
|
|
||
|
|
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));
|
||
|
|
|
||
|
|
// Sample per-pump flow at fixed intervals and return a trajectory: an array
|
||
|
|
// of {tMs, perPump:[...], sum}.
|
||
|
|
async function sampleFlows(pumps, durationMs, intervalMs = 200) {
|
||
|
|
const t0 = Date.now();
|
||
|
|
const out = [];
|
||
|
|
while (Date.now() - t0 < durationMs) {
|
||
|
|
const perPump = pumps.map(pumpFlow_m3h);
|
||
|
|
out.push({ tMs: Date.now() - t0, perPump, sum: perPump.reduce((a, b) => a + b, 0) });
|
||
|
|
await sleep(intervalMs);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find the wall-clock instant (in ms from t0) at which a given series
|
||
|
|
// REACHES and STAYS within `tol` of `target` for the rest of the run. If
|
||
|
|
// never reached, returns null.
|
||
|
|
function arrivalTimeMs(series, target, tol) {
|
||
|
|
for (let i = 0; i < series.length; i++) {
|
||
|
|
const v = series[i];
|
||
|
|
if (Math.abs(v - target) <= tol) {
|
||
|
|
// require it to stay close
|
||
|
|
let stayed = true;
|
||
|
|
for (let j = i + 1; j < series.length; j++) {
|
||
|
|
if (Math.abs(series[j] - target) > tol * 1.5) { stayed = false; break; }
|
||
|
|
}
|
||
|
|
if (stayed) return i;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function printTrace(label, traj, demand_m3h) {
|
||
|
|
console.log(`\n${label} (demand=${demand_m3h.toFixed(1)} m³/h)`);
|
||
|
|
const head = [' t(s)'.padStart(7), 'pump_a'.padStart(8), 'pump_b'.padStart(8), 'pump_c'.padStart(8), 'Σ m³/h'.padStart(8), 'err'.padStart(7)];
|
||
|
|
console.log(head.join(' '));
|
||
|
|
console.log('─'.repeat(head.join(' ').length));
|
||
|
|
for (const s of traj) {
|
||
|
|
const err = s.sum - demand_m3h;
|
||
|
|
console.log([
|
||
|
|
(s.tMs / 1000).toFixed(2).padStart(7),
|
||
|
|
s.perPump[0].toFixed(1).padStart(8),
|
||
|
|
s.perPump[1].toFixed(1).padStart(8),
|
||
|
|
s.perPump[2].toFixed(1).padStart(8),
|
||
|
|
s.sum.toFixed(1).padStart(8),
|
||
|
|
err.toFixed(1).padStart(7),
|
||
|
|
].join(' '));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── The diagnostic ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
test('planner-convergence: mixed-state dispatch — sum reaches demand AND lands together', async () => {
|
||
|
|
const { mgc, pumps } = buildGroup();
|
||
|
|
const dyn = mgc.calcDynamicTotals();
|
||
|
|
const flowMin_m3h = dyn.flow.min * 3600;
|
||
|
|
const flowMax_m3h = dyn.flow.max * 3600;
|
||
|
|
console.log(`\nStation envelope at head ${HEAD_MBAR_DOWN} mbar (${N_PUMPS} pumps): ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
|
||
|
|
|
||
|
|
// Phase 1: bring pump_a (only) to operational at a low setpoint via a
|
||
|
|
// direct child command. This bypasses the optimizer and gives us a
|
||
|
|
// deterministic mixed state: 1 running, 2 idle. We then drive a global
|
||
|
|
// demand to ramp up — the planner must coordinate one in-flight retarget
|
||
|
|
// with two startups.
|
||
|
|
const pumpA = pumps[0];
|
||
|
|
await pumpA.handleInput('parent', 'execsequence', 'startup');
|
||
|
|
// wait for warmup to complete
|
||
|
|
for (let i = 0; i < 200 && pumpA.state.getCurrentState() !== 'operational'; i++) await sleep(50);
|
||
|
|
assert.equal(pumpA.state.getCurrentState(), 'operational', 'pre-condition: pump_a should be operational');
|
||
|
|
|
||
|
|
// Put pump_a at ~30% of its per-pump flow range. This guarantees the
|
||
|
|
// optimizer's later combination will want pump_a to MOVE (either up to
|
||
|
|
// share work with the new pumps, or down to balance them) — either
|
||
|
|
// direction surfaces a rendezvous concern.
|
||
|
|
const sample = pumpA.groupPredictFlow ?? pumpA.predictFlow;
|
||
|
|
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
|
||
|
|
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
|
||
|
|
const initialFlow_m3h = perPumpMin_m3h + 0.30 * (perPumpMax_m3h - perPumpMin_m3h);
|
||
|
|
await pumpA.handleInput('parent', 'flowmovement', initialFlow_m3h);
|
||
|
|
await sleep(500); // let pump_a settle
|
||
|
|
|
||
|
|
const initialSnap = pumps.map((p) => ({ state: p.state.getCurrentState(), q: pumpFlow_m3h(p) }));
|
||
|
|
console.log('\nInitial state (1 running, 2 idle):');
|
||
|
|
for (let i = 0; i < pumps.length; i++) {
|
||
|
|
console.log(` ${pumps[i].config.general.id}: ${initialSnap[i].state.padEnd(13)} Q=${initialSnap[i].q.toFixed(1)} m³/h`);
|
||
|
|
}
|
||
|
|
assert.equal(initialSnap[0].state, 'operational', 'pump_a operational at start');
|
||
|
|
assert.equal(initialSnap[1].state, 'idle', 'pump_b idle at start');
|
||
|
|
assert.equal(initialSnap[2].state, 'idle', 'pump_c idle at start');
|
||
|
|
|
||
|
|
// Phase 2: drive 90% demand — needs all 3 pumps.
|
||
|
|
const demandPct = 90;
|
||
|
|
const demand_m3s = pctToCanonical(mgc, demandPct);
|
||
|
|
const demand_m3h = demand_m3s * 3600;
|
||
|
|
console.log(`\nDispatching ${demandPct}% → ${demand_m3h.toFixed(1)} m³/h demand…`);
|
||
|
|
|
||
|
|
// Fire-and-don't-wait so we can sample DURING the move.
|
||
|
|
mgc.handleInput('parent', demand_m3s).catch(() => {});
|
||
|
|
|
||
|
|
// Give the dispatcher a microtask + tick to plan, then dump the
|
||
|
|
// schedule so we can see WHAT the planner produced (vs. what the
|
||
|
|
// executor actually does).
|
||
|
|
await sleep(60);
|
||
|
|
const sched = mgc.movementExecutor.schedule();
|
||
|
|
console.log(`\nPlanner schedule (tStar=${sched?.tStarS?.toFixed(2)}s, ${sched?.commands?.length} cmds):`);
|
||
|
|
for (const c of (sched?.commands || [])) {
|
||
|
|
console.log(` ${c.machineId.padEnd(8)} ${c.action.padEnd(13)} ${c.sequence ?? ('flow=' + (c.flow?.toFixed(1) ?? 'n/a')).padEnd(12)} fireAtTickN=${c.fireAtTickN} eta=${c.eta?.toFixed(2)}s`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sample for 8 seconds at 200 ms — long enough for tStar ≈ 3.5 s + ramp.
|
||
|
|
const traj = await sampleFlows(pumps, 8000, 200);
|
||
|
|
|
||
|
|
printTrace('Per-pump flow trajectory', traj, demand_m3h);
|
||
|
|
|
||
|
|
// ── Question (a): does sum-of-flows converge to demand? ────────────
|
||
|
|
const finalSum = traj[traj.length - 1].sum;
|
||
|
|
const tolAbs = demand_m3h * 0.05; // 5% tolerance
|
||
|
|
console.log(`\nFinal ΣQ = ${finalSum.toFixed(1)} m³/h vs demand ${demand_m3h.toFixed(1)} m³/h (tol ±${tolAbs.toFixed(1)})`);
|
||
|
|
assert.ok(
|
||
|
|
Math.abs(finalSum - demand_m3h) <= tolAbs,
|
||
|
|
`(a) CONVERGENCE FAILED: final ΣQ=${finalSum.toFixed(1)} m³/h, demand=${demand_m3h.toFixed(1)} m³/h, err=${(finalSum - demand_m3h).toFixed(1)} m³/h (>${tolAbs.toFixed(1)})`,
|
||
|
|
);
|
||
|
|
|
||
|
|
// ── Question (b): same-time landing? ───────────────────────────────
|
||
|
|
//
|
||
|
|
// For each pump, find when its flow first reached a stable value (its
|
||
|
|
// own steady-state target). Compare the spread across the three pumps:
|
||
|
|
// if they "land together", all arrival indices are within ~1 sample.
|
||
|
|
const sampleTargets = pumps.map((_, i) => {
|
||
|
|
// Use the LAST sample's flow as that pump's actual landing value.
|
||
|
|
// We're measuring "when did this pump stop moving" not "did it hit
|
||
|
|
// some externally-specified target" — that's what same-time-landing
|
||
|
|
// is about.
|
||
|
|
return traj[traj.length - 1].perPump[i];
|
||
|
|
});
|
||
|
|
const arrivalIdx = pumps.map((_, i) => {
|
||
|
|
const series = traj.map((s) => s.perPump[i]);
|
||
|
|
const tgt = sampleTargets[i];
|
||
|
|
const tol = Math.max(2.0, Math.abs(tgt) * 0.05); // 5% or 2 m³/h, whichever larger
|
||
|
|
return arrivalTimeMs(series, tgt, tol);
|
||
|
|
});
|
||
|
|
console.log('\nArrival index per pump (sample # where flow stabilises within 5%):');
|
||
|
|
for (let i = 0; i < pumps.length; i++) {
|
||
|
|
const idx = arrivalIdx[i];
|
||
|
|
const t = idx == null ? 'NEVER' : `${(traj[idx].tMs / 1000).toFixed(2)} s`;
|
||
|
|
console.log(` ${pumps[i].config.general.id}: idx=${idx}, t=${t}, finalQ=${sampleTargets[i].toFixed(1)} m³/h`);
|
||
|
|
}
|
||
|
|
const validIdx = arrivalIdx.filter((x) => x != null);
|
||
|
|
assert.equal(validIdx.length, N_PUMPS, '(b) one or more pumps never landed on a stable flow');
|
||
|
|
|
||
|
|
const spreadSamples = Math.max(...validIdx) - Math.min(...validIdx);
|
||
|
|
const spreadMs = spreadSamples * 200;
|
||
|
|
console.log(`Same-time-landing spread: ${spreadSamples} samples = ${spreadMs} ms`);
|
||
|
|
// Loose bound: within 1.5 s. A bigger spread means the schedule did
|
||
|
|
// NOT bring the pumps to their setpoints together.
|
||
|
|
assert.ok(
|
||
|
|
spreadMs <= 1500,
|
||
|
|
`(b) SAME-TIME LANDING FAILED: pumps landed ${spreadMs} ms apart (>1500 ms tolerance). ` +
|
||
|
|
`This means flow-INCREASING moves on running pumps land BEFORE startup pumps reach operational.`,
|
||
|
|
);
|
||
|
|
});
|