// 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.`, ); });