feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.
Architecture (src/movement/):
- machineProfile.js – pure snapshot of a registered child (state,
position, velocityPctPerS, ladder timings,
flowAt / positionForFlow). Reads timings from
child.state.config.time (the actual storage
location — previous fallback paths silently
produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js – seconds-to-target per machine; handles
idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
command is delayed so its move finishes at t*.
Startup execsequence fires at 0; its flowmovement
is gated by max(ladderS, t* − rampS) so a fast
pump waits before ramping rather than landing
early. useRendezvous=false collapses to all
fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
every command whose fireAtTickN ≤ floor(elapsed/tickS).
tick() no longer awaits pending fireCommand
promises — the synchronous prologue of
handleInput claims the latest-wins gate, which
is what race-favouring relies on.
Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
_optimalControl. Builds profiles, calls movementScheduler.plan,
replans the executor, ticks once. Reads
config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
Same-time landing now applies in BOTH modes.
Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
config.planner.useRendezvous. Default ON.
Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
diagnostic — drives a 3-pump mixed-state dispatch and asserts both
convergence to the demand setpoint AND same-time landing within
one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
multi-startup case proving the fast pumps wait so all three land
together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.
Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
helper; every command now checks isValidActionForMode +
isValidSourceForMode before dispatching. Status-level commands
(set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
Contracts,Examples,Limitations}.md split with _Sidebar.md index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by
|
||||
|
||||
| Key | Source | Type / Range | Populated test | Degraded test |
|
||||
|---|---|---|---|---|
|
||||
| `mode` | `mgc.mode` (set via `set.mode` command) | string ∈ {`optimalcontrol`, `prioritycontrol`, …} | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default |
|
||||
| `mode` | `mgc.mode` (set via `set.mode` command; normalised by `specificClass.setMode`) | string ∈ {`optimalControl`, `priorityControl`, `maintenance`} (canonical camelCase) | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default |
|
||||
| `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') |
|
||||
| `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) |
|
||||
| `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) |
|
||||
|
||||
@@ -22,17 +22,44 @@ function makeLogger() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSource({ name = 'mgc-1', handleInputResult = undefined, dt = { flow: { min: 0, max: 100 } } } = {}) {
|
||||
function makeSource({
|
||||
name = 'mgc-1',
|
||||
handleInputResult = undefined,
|
||||
dt = { flow: { min: 0, max: 100 } },
|
||||
// Initial mode for the fake. Defaults to optimalControl so gates pass for
|
||||
// the historical tests; per-test override via the returned `source.mode = …`.
|
||||
mode = 'optimalControl',
|
||||
// Override the gate decisions. Default-true matches the no-gating world
|
||||
// tests assumed before this change; negative-path tests pass functions that
|
||||
// return false for specific actions / sources.
|
||||
isValidActionForMode = () => true,
|
||||
isValidSourceForMode = () => true,
|
||||
} = {}) {
|
||||
const calls = {
|
||||
setMode: [],
|
||||
handleInput: [],
|
||||
registerChild: [],
|
||||
turnOffAllMachines: 0,
|
||||
gateAction: [],
|
||||
gateSource: [],
|
||||
};
|
||||
const source = {
|
||||
logger: makeLogger(),
|
||||
config: { general: { name } },
|
||||
setMode: (m) => calls.setMode.push(m),
|
||||
mode,
|
||||
setMode: (m) => { calls.setMode.push(m); /* keep fake.mode unchanged unless test does it */ },
|
||||
isValidActionForMode: (action, m) => {
|
||||
const ok = isValidActionForMode(action, m);
|
||||
calls.gateAction.push({ action, mode: m, ok });
|
||||
if (!ok) source.logger.warn(`action '${action}' not allowed in mode '${m}'`);
|
||||
return ok;
|
||||
},
|
||||
isValidSourceForMode: (src, m) => {
|
||||
const ok = isValidSourceForMode(src, m);
|
||||
calls.gateSource.push({ src, mode: m, ok });
|
||||
if (!ok) source.logger.warn(`source '${src}' not allowed in mode '${m}'`);
|
||||
return ok;
|
||||
},
|
||||
handleInput: async (src, demand) => {
|
||||
calls.handleInput.push({ src, demand });
|
||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||
@@ -192,3 +219,124 @@ test('child.register with unknown child id logs warn and does not throw', async
|
||||
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
// --- mode gate tests -------------------------------------------------------
|
||||
|
||||
test('gate: set.demand in maintenance mode is dropped (action not allowed)', async () => {
|
||||
// Mirror schema: maintenance allows only statusCheck. The dispatch action
|
||||
// for a positive demand under optimalControl/priorityControl is
|
||||
// execOptimalCombination / execSequentialControl — neither in maintenance.
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'maintenance',
|
||||
isValidActionForMode: (action) => action === 'statusCheck',
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 0, 'handleInput must not be invoked');
|
||||
assert.equal(calls.turnOffAllMachines, 0, 'turnOffAllMachines must not be invoked');
|
||||
assert.ok(
|
||||
source.logger.calls.warn.some((m) => m.includes('not allowed')),
|
||||
`expected warn about action not allowed in maintenance, got: ${JSON.stringify(source.logger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("gate: set.demand from msg.source 'physical' in maintenance is dropped (source not allowed)", async () => {
|
||||
// Maintenance accepts sources ['parent','GUI'] per schema. Physical/HMI is
|
||||
// rejected by the source gate even before we ask which action to perform.
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'maintenance',
|
||||
isValidActionForMode: () => true, // pretend action is allowed; source gate must still reject
|
||||
isValidSourceForMode: (src) => src === 'parent' || src === 'GUI',
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 50, source: 'physical' }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
assert.equal(calls.turnOffAllMachines, 0);
|
||||
assert.ok(
|
||||
source.logger.calls.warn.some((m) => m.includes("'physical'") && m.includes('not allowed')),
|
||||
`expected warn about physical source not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('gate: set.demand from msg.source GUI in optimalControl reaches handleInput', async () => {
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'optimalControl',
|
||||
isValidActionForMode: (action) =>
|
||||
['statusCheck', 'execOptimalCombination', 'balanceLoad', 'emergencyStop'].includes(action),
|
||||
isValidSourceForMode: (src) => ['parent', 'GUI', 'physical', 'API'].includes(src),
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 25, source: 'GUI' }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 25 });
|
||||
// Sanity check on the gate plumbing: both gates were consulted with the
|
||||
// expected (action, source, mode) tuple.
|
||||
assert.ok(calls.gateAction.some((g) => g.action === 'execOptimalCombination' && g.mode === 'optimalControl' && g.ok));
|
||||
assert.ok(calls.gateSource.some((g) => g.src === 'GUI' && g.mode === 'optimalControl' && g.ok));
|
||||
});
|
||||
|
||||
test('gate: emergencyStop (negative demand) gated by mode → maintenance blocks the stop-all', async () => {
|
||||
// A negative demand is the operator stop-all signal. The schema declares
|
||||
// emergencyStop in optimalControl/priorityControl but NOT in maintenance,
|
||||
// so this should be rejected too — maintenance is "monitor only", which
|
||||
// includes "no dispatch decisions, even shutdowns".
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'maintenance',
|
||||
isValidActionForMode: (action) => action === 'statusCheck',
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
|
||||
assert.equal(calls.turnOffAllMachines, 0, 'turnOff must be gated');
|
||||
assert.ok(
|
||||
source.logger.calls.warn.some((m) => m.includes('emergencyStop') && m.includes('not allowed')),
|
||||
`expected warn about emergencyStop not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
// --- mode-string normalisation (specificClass internals) --------------------
|
||||
|
||||
const { _normaliseMode, ALLOWED_MODES } = require('../../src/specificClass');
|
||||
|
||||
test('mode normalisation: camelCase pass-through, lowercase accepted, garbage rejected', () => {
|
||||
assert.equal(_normaliseMode('optimalControl'), 'optimalControl');
|
||||
assert.equal(_normaliseMode('optimalcontrol'), 'optimalControl');
|
||||
assert.equal(_normaliseMode('OPTIMALCONTROL'), 'optimalControl');
|
||||
assert.equal(_normaliseMode('priorityControl'), 'priorityControl');
|
||||
assert.equal(_normaliseMode('prioritycontrol'), 'priorityControl');
|
||||
assert.equal(_normaliseMode('maintenance'), 'maintenance');
|
||||
assert.equal(_normaliseMode('MAINTENANCE'), 'maintenance');
|
||||
assert.equal(_normaliseMode('wat'), null);
|
||||
assert.equal(_normaliseMode(''), null);
|
||||
assert.equal(_normaliseMode(null), null);
|
||||
assert.equal(_normaliseMode(undefined), null);
|
||||
assert.deepEqual(ALLOWED_MODES, ['optimalControl', 'priorityControl', 'maintenance']);
|
||||
});
|
||||
|
||||
// --- schema-shape regression -----------------------------------------------
|
||||
|
||||
test('schema regression: allowedSources keys are camelCase for all three modes', () => {
|
||||
// Read the JSON directly — generalFunctions' package.json `exports` map
|
||||
// doesn't expose the configs subpath, and we don't want to add it just for
|
||||
// a test. Path is repo-relative from this test file.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const schemaPath = path.resolve(__dirname, '../../../generalFunctions/src/configs/machineGroupControl.json');
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
||||
const allowedSourcesSchema = schema.mode.allowedSources.rules.schema;
|
||||
assert.ok(allowedSourcesSchema.optimalControl, 'optimalControl key must exist on allowedSources');
|
||||
assert.ok(allowedSourcesSchema.priorityControl, 'priorityControl key must exist on allowedSources');
|
||||
assert.ok(allowedSourcesSchema.maintenance, 'maintenance key must exist on allowedSources');
|
||||
// Maintenance is monitor-only: parent + GUI permitted, physical/API rejected.
|
||||
const mDefaults = allowedSourcesSchema.maintenance.default;
|
||||
assert.ok(mDefaults.includes('parent'), `maintenance default should permit parent, got ${mDefaults}`);
|
||||
assert.ok(mDefaults.includes('GUI'), `maintenance default should permit GUI, got ${mDefaults}`);
|
||||
assert.ok(!mDefaults.includes('physical'), 'maintenance must NOT permit physical writes');
|
||||
assert.ok(!mDefaults.includes('API'), 'maintenance must NOT permit API writes');
|
||||
// Catch a regression to lowercase keys.
|
||||
assert.equal(allowedSourcesSchema.optimalcontrol, undefined, 'lowercase optimalcontrol key must NOT exist');
|
||||
assert.equal(allowedSourcesSchema.prioritycontrol, undefined, 'lowercase prioritycontrol key must NOT exist');
|
||||
});
|
||||
|
||||
142
test/basic/moveTrajectory.basic.test.js
Normal file
142
test/basic/moveTrajectory.basic.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MoveTrajectory = require('../../src/movement/moveTrajectory');
|
||||
|
||||
// Reusable profile builder — keeps each test focused on the field(s) it cares
|
||||
// about. Anything not overridden is in a sane "operational at 0%" baseline.
|
||||
function makeProfile(over = {}) {
|
||||
return Object.assign({
|
||||
id: 'P1',
|
||||
state: 'operational',
|
||||
position: 0,
|
||||
minPosition: 0,
|
||||
maxPosition: 100,
|
||||
velocityPctPerS: 2,
|
||||
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
|
||||
remainingTransitionS: null,
|
||||
flowAt: () => null,
|
||||
}, over);
|
||||
}
|
||||
|
||||
// TC1 — idle, full startup ladder + ramp from min.
|
||||
test('TC1 idle → target = startingS + warmingupS + (target−min)/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'idle' }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 10 + 20 + 60 / 2); // 60s
|
||||
});
|
||||
|
||||
// TC2 — operational up.
|
||||
test('TC2 operational up = |target−position|/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 40 }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 10);
|
||||
});
|
||||
|
||||
// TC3 — operational down. ETA is positive.
|
||||
test('TC3 operational down = |target−position|/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 80 }), { targetPosition: 30 });
|
||||
assert.equal(t.etaToTargetS(), 25);
|
||||
});
|
||||
|
||||
// TC4 — no-op.
|
||||
test('TC4 operational, target == position → 0s', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 50 }), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 0);
|
||||
});
|
||||
|
||||
// TC5 — accelerating post-abort residue, same formula as operational.
|
||||
test('TC5 accelerating residue = operational formula', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'accelerating', position: 35 }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 12.5);
|
||||
});
|
||||
|
||||
// TC6 — decelerating residue.
|
||||
test('TC6 decelerating residue = operational formula', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'decelerating', position: 70 }), { targetPosition: 40 });
|
||||
assert.equal(t.etaToTargetS(), 15);
|
||||
});
|
||||
|
||||
// TC7 — warmingup, remaining time from stateManager.
|
||||
test('TC7 warmingup = remainingWarmupS + (target−min)/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'warmingup',
|
||||
position: 0,
|
||||
remainingTransitionS: 12,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 12 + 50 / 2); // 37s
|
||||
});
|
||||
|
||||
// TC7b — warmingup but no remaining-time observation: falls back to full
|
||||
// configured warmup (worst-case). Kept for resilience when the state machine
|
||||
// pre-dates the getter.
|
||||
test('TC7b warmingup fallback to full warmingupS when no remaining provided', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'warmingup',
|
||||
position: 0,
|
||||
remainingTransitionS: null,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 20 + 50 / 2); // 45s
|
||||
});
|
||||
|
||||
// TC8 — starting: remaining + full warmup + ramp.
|
||||
test('TC8 starting = remainingStartingS + warmingupS + (target−min)/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'starting',
|
||||
position: 0,
|
||||
remainingTransitionS: 8,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 8 + 20 + 50 / 2); // 53s
|
||||
});
|
||||
|
||||
// TC8b — boundary: remaining hits 0 just before the setTimeout fires.
|
||||
test('TC8b starting with remainingTransitionS=0 still yields positive ETA', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'starting',
|
||||
position: 0,
|
||||
remainingTransitionS: 0,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 0 + 20 + 50 / 2); // 45s
|
||||
});
|
||||
|
||||
// TC9 — shutdown ladder excluded: returns null so scheduler skips it.
|
||||
test('TC9a stopping → null', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'stopping', position: 30 }), { targetPosition: 0 });
|
||||
assert.equal(t.etaToTargetS(), null);
|
||||
});
|
||||
test('TC9b coolingdown → null', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'coolingdown', position: 0 }), { targetPosition: 0 });
|
||||
assert.equal(t.etaToTargetS(), null);
|
||||
});
|
||||
|
||||
// TC10 — target above max clamps; ETA uses clamped value.
|
||||
test('TC10 target above maxPosition clamps to max', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, maxPosition: 100 }), { targetPosition: 120 });
|
||||
assert.equal(t.targetPosition, 100);
|
||||
assert.equal(t.etaToTargetS(), 50);
|
||||
});
|
||||
|
||||
// TC11 — target below min clamps; ETA zero when already at min.
|
||||
test('TC11 target below min clamps to min; ETA = 0 when at min', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, minPosition: 0 }), { targetPosition: -5 });
|
||||
assert.equal(t.targetPosition, 0);
|
||||
assert.equal(t.etaToTargetS(), 0);
|
||||
});
|
||||
|
||||
// TC12 — zero velocity yields Infinity, not NaN or crash.
|
||||
test('TC12 zero velocity → Infinity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, velocityPctPerS: 0 }), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), Infinity);
|
||||
});
|
||||
|
||||
// TC13 — non-finite target throws at construction (totality of etaToTargetS).
|
||||
test('TC13 non-finite target throws at construction', () => {
|
||||
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: NaN }), TypeError);
|
||||
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: undefined }), TypeError);
|
||||
});
|
||||
|
||||
// Extra: minPosition above 0 is honoured in ramp distance for startup cases.
|
||||
test('TC1b idle with minPosition=10 → ramp from 10, not 0', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'idle', minPosition: 10 }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 10 + 20 + (60 - 10) / 2); // 55s
|
||||
});
|
||||
136
test/basic/movementExecutor.basic.test.js
Normal file
136
test/basic/movementExecutor.basic.test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MovementExecutor = require('../../src/movement/movementExecutor');
|
||||
|
||||
function mkSchedule(commands, tStarS = 0, tickS = 1) {
|
||||
return { tStarS, tickS, commands };
|
||||
}
|
||||
|
||||
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
test('executor: throws if fireCommand callback missing', () => {
|
||||
assert.throws(() => new MovementExecutor({}), TypeError);
|
||||
});
|
||||
|
||||
test('executor: fires commands whose fireAtTickN <= cursor', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({
|
||||
fireCommand: (c) => fired.push(c),
|
||||
logger: noopLogger,
|
||||
});
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 },
|
||||
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 },
|
||||
]));
|
||||
let firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 1);
|
||||
assert.equal(firedThisTick[0].machineId, 'A');
|
||||
firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 0);
|
||||
firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 1);
|
||||
assert.equal(firedThisTick[0].machineId, 'B');
|
||||
await ex.tick(); await ex.tick();
|
||||
firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 1);
|
||||
assert.equal(firedThisTick[0].machineId, 'C');
|
||||
|
||||
assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']);
|
||||
assert.equal(ex.pending(), 0);
|
||||
});
|
||||
|
||||
test('executor: replan drops unfired commands and resets cursor', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
|
||||
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 },
|
||||
]));
|
||||
await ex.tick(); // A fires
|
||||
assert.deepEqual(fired, ['A']);
|
||||
assert.equal(ex.pending(), 1);
|
||||
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 },
|
||||
]));
|
||||
assert.equal(ex.cursor(), 0, 'cursor reset on replan');
|
||||
await ex.tick(); // X fires
|
||||
assert.deepEqual(fired, ['A', 'X']);
|
||||
await ex.tick(); await ex.tick(); await ex.tick();
|
||||
assert.ok(!fired.includes('B'), 'old B move was dropped by replan');
|
||||
assert.ok(fired.includes('Y'), 'new Y move fired after delay');
|
||||
});
|
||||
|
||||
test('executor: fires only once per command even across many ticks', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
]));
|
||||
for (let i = 0; i < 5; i++) await ex.tick();
|
||||
assert.deepEqual(fired, ['A']);
|
||||
});
|
||||
|
||||
test('executor: catches fireCommand errors and continues', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({
|
||||
fireCommand: (c) => {
|
||||
if (c.machineId === 'B') throw new Error('boom');
|
||||
fired.push(c.machineId);
|
||||
},
|
||||
logger: noopLogger,
|
||||
});
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 },
|
||||
]));
|
||||
await ex.tick();
|
||||
// B's error must not block A or C.
|
||||
assert.deepEqual(fired, ['A', 'C']);
|
||||
});
|
||||
|
||||
test('executor: empty / null schedule is safe to tick', async () => {
|
||||
const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger });
|
||||
assert.deepEqual(await ex.tick(), []);
|
||||
ex.replan({ commands: [] });
|
||||
assert.deepEqual(await ex.tick(), []);
|
||||
});
|
||||
|
||||
test('executor: tick fires commands synchronously and does NOT await their promises', async () => {
|
||||
// Contract: tick() returns as soon as every due fireCommand has been
|
||||
// invoked. It does NOT wait for the returned promises to resolve.
|
||||
// This matters because a flowmovement-after-startup resolves only
|
||||
// after the pump's entire ramp completes — awaiting it would freeze
|
||||
// the executor's wall-clock progression and drag every delayed
|
||||
// command in the schedule forward by that duration.
|
||||
const order = [];
|
||||
let resolveFire;
|
||||
const firePromise = new Promise((r) => { resolveFire = r; });
|
||||
const ex = new MovementExecutor({
|
||||
fireCommand: (c) => {
|
||||
order.push(`fire-start-${c.machineId}`);
|
||||
return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); });
|
||||
},
|
||||
logger: noopLogger,
|
||||
});
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
]));
|
||||
const tickPromise = ex.tick().then(() => order.push('tick-resolved'));
|
||||
// Wait one microtask cycle: tick should already have resolved even
|
||||
// though fire is still pending.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(order, ['fire-start-A', 'tick-resolved'],
|
||||
'tick must resolve immediately after invoking fireCommand — not wait for its promise');
|
||||
resolveFire();
|
||||
await tickPromise;
|
||||
// The fire's tail runs in the background and lands after tick resolved.
|
||||
assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']);
|
||||
});
|
||||
307
test/basic/movementScheduler.basic.test.js
Normal file
307
test/basic/movementScheduler.basic.test.js
Normal file
@@ -0,0 +1,307 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { plan } = require('../../src/movement/movementScheduler');
|
||||
|
||||
// Profile builder — same shape as buildProfile output. positionForFlow
|
||||
// approximates the inverse curve as a linear mapping over [min,max] for
|
||||
// flow ∈ [0, maxFlow], which is enough to test scheduler logic without
|
||||
// dragging real curve math in.
|
||||
function makeProfile(over = {}) {
|
||||
const defaults = {
|
||||
id: 'A',
|
||||
state: 'operational',
|
||||
position: 0,
|
||||
minPosition: 0,
|
||||
maxPosition: 100,
|
||||
velocityPctPerS: 2,
|
||||
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
|
||||
remainingTransitionS: null,
|
||||
maxFlow: 100, // synthetic — for the test mapping below
|
||||
};
|
||||
const p = Object.assign(defaults, over);
|
||||
// Linear position-for-flow over [min,max].
|
||||
p.positionForFlow = (flow) => {
|
||||
if (!Number.isFinite(flow) || flow <= 0) return p.minPosition;
|
||||
return p.minPosition + (flow / p.maxFlow) * (p.maxPosition - p.minPosition);
|
||||
};
|
||||
// flowAt — inverse of the above.
|
||||
p.flowAt = (pos /*, pressure */) => {
|
||||
if (!Number.isFinite(pos)) return 0;
|
||||
if (p.maxPosition === p.minPosition) return 0;
|
||||
return ((pos - p.minPosition) / (p.maxPosition - p.minPosition)) * p.maxFlow;
|
||||
};
|
||||
return p;
|
||||
}
|
||||
|
||||
// Tick rounding helper — scheduler uses Math.round(eta/tickS).
|
||||
function tickRound(s, tickS = 1) { return Math.round(s / tickS); }
|
||||
|
||||
test('plan: idle → start a single pump (no other pumps online)', () => {
|
||||
const profiles = [makeProfile({ id: 'A', state: 'idle', position: 0 })];
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// Two commands: execsequence(startup) + flowmovement(60). Both at tick 0.
|
||||
assert.equal(out.commands.length, 2);
|
||||
assert.equal(out.commands[0].action, 'execsequence');
|
||||
assert.equal(out.commands[0].sequence, 'startup');
|
||||
assert.equal(out.commands[0].fireAtTickN, 0);
|
||||
assert.equal(out.commands[1].action, 'flowmovement');
|
||||
assert.equal(out.commands[1].flow, 60);
|
||||
assert.equal(out.commands[1].fireAtTickN, 0);
|
||||
// tStar = full startup ladder + ramp from 0 to position-for-60 (= 60%).
|
||||
// = 10 + 20 + 60/2 = 60s.
|
||||
assert.equal(out.tStarS, 60);
|
||||
});
|
||||
|
||||
test('plan: operational up-move (no rendezvous partner)', () => {
|
||||
const profiles = [makeProfile({ id: 'A', state: 'operational', position: 40 })];
|
||||
// Currently delivering 40 (at maxFlow=100 → linear), targeting 60.
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
assert.equal(out.commands.length, 1);
|
||||
assert.equal(out.commands[0].action, 'flowmovement');
|
||||
assert.equal(out.commands[0].flow, 60);
|
||||
assert.equal(out.commands[0].fireAtTickN, 0);
|
||||
// eta = |60−40|/2 = 10s
|
||||
assert.equal(out.tStarS, 10);
|
||||
});
|
||||
|
||||
test('plan: rendezvous — startup pump + running pump that needs to shed load', () => {
|
||||
// A: starting from idle, target 60. eta = 10 + 20 + 60/2 = 60s.
|
||||
// B: operational at 80 (flow=80), target 40 (down). eta_B = 40/2 = 20s.
|
||||
// Expectation: A fires at tick 0; B fires at tick (60−20) = 40 so B
|
||||
// FINISHES at the same time A reaches its target.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 60 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
const cmdA_startup = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
|
||||
const cmdA_flow = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
|
||||
const cmdB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
assert.ok(cmdA_startup, 'A startup');
|
||||
assert.ok(cmdA_flow, 'A flowmovement (queued)');
|
||||
assert.ok(cmdB, 'B flowmovement');
|
||||
|
||||
assert.equal(cmdA_startup.fireAtTickN, 0);
|
||||
assert.equal(cmdA_flow.fireAtTickN, 0);
|
||||
// B delayed so it finishes at tStar=60 → fires at 60−20 = 40.
|
||||
assert.equal(cmdB.fireAtTickN, 40);
|
||||
assert.equal(out.tStarS, 60);
|
||||
});
|
||||
|
||||
test('plan: all machines moving down — all land at slowest mover\'s eta', () => {
|
||||
// Two operational pumps, both reducing flow. tStar = max eta over
|
||||
// ALL non-noop moves (not just increasing) so the slower pump
|
||||
// defines the rendezvous and the faster one is delayed to land
|
||||
// with it. Net effect: same-time landing in pure-down scenarios too,
|
||||
// sum-of-flows stays at the OLD setpoint until t* then drops cleanly.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 80, velocityPctPerS: 2 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 70, velocityPctPerS: 2 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 40 }, // target position via inverse curve → 40 (identity makeProfile)
|
||||
{ machineId: 'B', flow: 30 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// eta_A = |80-40|/2 = 20s, eta_B = |70-30|/2 = 20s → tStar = 20s.
|
||||
assert.equal(out.tStarS, 20);
|
||||
// Both pumps have eta == tStar so neither is delayed (fireAtTickN = 0).
|
||||
for (const c of out.commands) {
|
||||
assert.equal(c.fireAtTickN, 0, `${c.machineId} should fire at 0 when eta == tStar`);
|
||||
}
|
||||
});
|
||||
|
||||
test('plan: asymmetric down moves — faster one delayed to land with slower one', () => {
|
||||
// A and B both reduce flow but A's move is faster. The new
|
||||
// symmetric-rendezvous semantics delay the faster mover so both land
|
||||
// at tStar = max eta.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 60, velocityPctPerS: 4 }), // fast
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80, velocityPctPerS: 2 }), // slow
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 40 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// eta_A = |60-40|/4 = 5s, eta_B = |80-40|/2 = 20s → tStar = 20s.
|
||||
assert.equal(out.tStarS, 20);
|
||||
const cA = out.commands.find((c) => c.machineId === 'A');
|
||||
const cB = out.commands.find((c) => c.machineId === 'B');
|
||||
assert.equal(cA.fireAtTickN, 15, 'A (fast) delayed by tStar − eta_A = 20 − 5 = 15');
|
||||
assert.equal(cB.fireAtTickN, 0, 'B (slow) defines tStar — fires immediately');
|
||||
});
|
||||
|
||||
test('plan: shutdown — removed machine gets execsequence(shutdown)', () => {
|
||||
// A staying at flow 60, B getting shut down (target 0).
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 60 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 50 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 60 }, // unchanged
|
||||
{ machineId: 'B', flow: 0 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
const shutdownB = out.commands.find((c) => c.machineId === 'B' && c.action === 'execsequence' && c.sequence === 'shutdown');
|
||||
assert.ok(shutdownB, 'B shutdown command present');
|
||||
});
|
||||
|
||||
test('plan: noop — machine not in combination and already off does nothing', () => {
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 60 }),
|
||||
makeProfile({ id: 'B', state: 'idle', position: 0 }),
|
||||
];
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
const bAny = out.commands.find((c) => c.machineId === 'B');
|
||||
assert.equal(bAny, undefined, 'B should be omitted (no-op)');
|
||||
});
|
||||
|
||||
test('plan: rendezvous with three pumps — slowest startup sets the pace', () => {
|
||||
// A: idle → 50 (full startup, slow).
|
||||
// B: operational at 80 → 40 (down).
|
||||
// C: operational at 30 → 50 (up, fast).
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80 }),
|
||||
makeProfile({ id: 'C', state: 'operational', position: 30 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 50 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
{ machineId: 'C', flow: 50 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
|
||||
// eta_A = 10 + 20 + 50/2 = 55s (startup ladder + ramp; defines tStar)
|
||||
// eta_B = |80-40|/2 = 20s (decreasing)
|
||||
// eta_C = |50-30|/2 = 10s (increasing)
|
||||
// tStar = max(55, 20, 10) = 55.
|
||||
assert.equal(out.tStarS, 55);
|
||||
|
||||
const cA = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
|
||||
const cC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
|
||||
const cB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
// A's startup must begin NOW; its delayed flowmovement lands at t*
|
||||
// by construction.
|
||||
assert.equal(cA.fireAtTickN, 0);
|
||||
// Symmetric rendezvous: BOTH B and C are delayed to land at t*.
|
||||
// C (up, fast) gets delayed by t* − eta_C = 45.
|
||||
// B (down, mid) gets delayed by t* − eta_B = 35.
|
||||
assert.equal(cC.fireAtTickN, 55 - 10, 'C delayed to land at tStar (same-time landing)');
|
||||
assert.equal(cB.fireAtTickN, 55 - 20, 'B delayed to land at tStar (same-time landing)');
|
||||
});
|
||||
|
||||
test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar together', () => {
|
||||
// Three idle pumps starting from min position. Different per-pump
|
||||
// velocities → different etas. Without the rampStart gating, each
|
||||
// pump's delayedMove would fire at warmup-end and ramp at its own
|
||||
// speed, so the FAST pump lands long before the SLOW one — visible
|
||||
// on the dashboard as staggered landing curves.
|
||||
//
|
||||
// Real-world reproducer: pumpingstation-complete-example with the
|
||||
// editor's Reaction Speed set to A=3 %/s, B=10 %/s, C=1 %/s.
|
||||
//
|
||||
// Velocities here mirror that ratio but scaled for unit-test
|
||||
// readability. Position range is [0,100] so rampDist = 100.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0, velocityPctPerS: 3 }),
|
||||
makeProfile({ id: 'B', state: 'idle', position: 0, velocityPctPerS: 10 }),
|
||||
makeProfile({ id: 'C', state: 'idle', position: 0, velocityPctPerS: 1 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 100 },
|
||||
{ machineId: 'B', flow: 100 },
|
||||
{ machineId: 'C', flow: 100 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
|
||||
// Default ladder = starting(10) + warmingup(20) = 30 s.
|
||||
// ramp_A = 100/3 ≈ 33.33 s → eta_A ≈ 63.33 s
|
||||
// ramp_B = 100/10 = 10 s → eta_B = 40 s
|
||||
// ramp_C = 100/1 = 100 s → eta_C = 130 s
|
||||
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
|
||||
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
|
||||
|
||||
// execsequence fires at 0 for ALL idle pumps (the ladder must start now).
|
||||
for (const id of ['A', 'B', 'C']) {
|
||||
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
|
||||
assert.ok(exec, `${id} execsequence present`);
|
||||
assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`);
|
||||
}
|
||||
|
||||
// flowmovement gating — each pump's ramp must FINISH at tStar=130.
|
||||
const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
|
||||
const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
|
||||
|
||||
// A (medium): rampStart = 130 − 33.33 ≈ 96.67 → fireAtTickN = 97.
|
||||
assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3));
|
||||
// B (fast): rampStart = 130 − 10 = 120 → fireAtTickN = 120.
|
||||
assert.equal(flowB.fireAtTickN, 120);
|
||||
// C (slow, defines tStar): rendezvousRampStart = 130 − 100 = 30 == ladderS,
|
||||
// so no extra delay needed — fall back to fireAtTickN=0 and let
|
||||
// the pump's delayedMove fire it naturally at warmup-end.
|
||||
assert.equal(flowC.fireAtTickN, 0);
|
||||
|
||||
// Sanity: with these schedules, all three pumps' ramps end at the
|
||||
// same wall-clock instant (within rounding).
|
||||
// A: 97 + 100/3 ≈ 130.33
|
||||
// B: 120 + 10 = 130
|
||||
// C: 30 (delayedMove) + 100 = 130
|
||||
// Max spread ≈ 0.33 s — far better than the per-eta spread of
|
||||
// 130 − 40 = 90 s the planner would produce without this gating.
|
||||
});
|
||||
|
||||
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 0, velocityPctPerS: 0 }),
|
||||
];
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// Eta is Infinity → filtered out of tStar computation (only finite etas count).
|
||||
// Command still scheduled; fireAtTickN remains 0 for increasing move.
|
||||
const c = out.commands.find((c) => c.action === 'flowmovement');
|
||||
assert.ok(c);
|
||||
assert.equal(c.fireAtTickN, 0);
|
||||
assert.equal(out.tStarS, 0); // no finite increasing eta → tStar collapses to 0
|
||||
});
|
||||
|
||||
test('plan: respects custom tickS option', () => {
|
||||
// Same as the rendezvous test but with tickS=5 → fireAt should be in
|
||||
// ticks-of-5-seconds, not seconds.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 60 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000, { tickS: 5 });
|
||||
const cmdB = out.commands.find((c) => c.machineId === 'B');
|
||||
assert.equal(out.tStarS, 60);
|
||||
assert.equal(out.tickS, 5);
|
||||
assert.equal(cmdB.fireAtTickN, tickRound(60 - 20, 5)); // = 8
|
||||
});
|
||||
254
test/integration/planner-convergence.integration.test.js
Normal file
254
test/integration/planner-convergence.integration.test.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// 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.`,
|
||||
);
|
||||
});
|
||||
210
test/integration/planner-rendezvous.integration.test.js
Normal file
210
test/integration/planner-rendezvous.integration.test.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// MGC + planner end-to-end integration. Proves the timing-aware
|
||||
// rendezvous schedule actually fires on real rotatingMachine objects
|
||||
// (not just the abstract scheduler unit tests).
|
||||
//
|
||||
// Layout mirrors idle-startup-deadlock.integration.test.js: three real
|
||||
// pump objects, a real MGC, registration via childRegistrationUtils. The
|
||||
// difference: instead of asserting end-state, we tap into the executor's
|
||||
// schedule + intercept fireCommand to record exact ordering.
|
||||
|
||||
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 },
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// Wrap the MGC's executor.fireCommand so we record every command in
|
||||
// timing order. Replaces the actual fireCommand so the test stays
|
||||
// hermetic (pumps don't actually move — we just verify the SCHEDULE).
|
||||
function tapExecutor(mgc) {
|
||||
const log = [];
|
||||
const originalFire = mgc.movementExecutor._fireCommand;
|
||||
mgc.movementExecutor._fireCommand = (cmd) => {
|
||||
log.push({ ...cmd, firedAtMs: Date.now() });
|
||||
// Still call the original so the FSM moves and the test stays realistic.
|
||||
try { originalFire(cmd); } catch (_) { /* ignore */ }
|
||||
};
|
||||
return log;
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('planner-integration: idle group → demand brings up all 3 pumps in lockstep', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const log = tapExecutor(mgc);
|
||||
|
||||
// 100% demand from idle → optimizer picks a 3-pump combination.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
|
||||
// Wait one tick so the executor's setInterval-driven follow-up ticks
|
||||
// (if any) have a chance to fire. Three-pump symmetric startup has
|
||||
// identical etas → tStar = max(eta) = eta itself → all commands at
|
||||
// fireAtTickN=0 → all fire synchronously.
|
||||
await sleep(50);
|
||||
|
||||
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
|
||||
const flowCmds = log.filter((c) => c.action === 'flowmovement');
|
||||
|
||||
assert.equal(startupCmds.length, N_PUMPS, 'one startup per pump');
|
||||
assert.equal(flowCmds.length, N_PUMPS, 'one flowmovement per pump (queued via delayedMove)');
|
||||
// All startups must be fired in the same tick — i.e. roughly the same
|
||||
// wall-clock instant (within a few ms).
|
||||
const spread = Math.max(...startupCmds.map((c) => c.firedAtMs)) - Math.min(...startupCmds.map((c) => c.firedAtMs));
|
||||
assert.ok(spread < 50, `startup spread too wide: ${spread}ms`);
|
||||
});
|
||||
|
||||
test('planner-integration: rendezvous — startup pump fires immediately, retarget on running pump is delayed', async () => {
|
||||
// Bring up two pumps first; then change demand so the third pump
|
||||
// starts AND the two existing pumps shed load. The two running pumps'
|
||||
// flowmovement should be delayed so they land at the rendezvous time
|
||||
// matching the third pump's startup completion.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
|
||||
// Phase 1: low demand so optimizer picks a sub-set of pumps and at
|
||||
// least one stays idle. We try a few decreasing values until we find
|
||||
// one that leaves an idle pump (optimizer's combination choice is
|
||||
// sensitive to curve/pressure, hard to predict precisely).
|
||||
let idlePumpFound = false;
|
||||
for (const pct of [30, 20, 10, 5, 1]) {
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(() => {});
|
||||
await sleep(4500);
|
||||
const states0 = pumps.map((p) => p.state.getCurrentState());
|
||||
if (states0.includes('idle')) { idlePumpFound = true; break; }
|
||||
}
|
||||
if (!idlePumpFound) {
|
||||
const finalStates = pumps.map((p) => p.state.getCurrentState());
|
||||
console.log(` (skipping) optimizer always picked all 3 pumps even at low demand: ${finalStates.join(',')}`);
|
||||
return; // optimizer behaviour denies us the scenario — not a failure of the planner.
|
||||
}
|
||||
|
||||
// Start tapping AFTER the first ramp settles — we only care about
|
||||
// the schedule from the next dispatch.
|
||||
const log = tapExecutor(mgc);
|
||||
|
||||
// Phase 2: drive to 100%. Now optimizer wants all 3 pumps. The idle
|
||||
// pump needs full startup; existing pumps adjust their flow.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
|
||||
// Wait long enough for the executor's wall-clock ticks to fire
|
||||
// delayed commands. tStar can be up to startingS + warmingupS + ramp
|
||||
// = 1 + 2 + 0.5 = 3.5s.
|
||||
await sleep(5000);
|
||||
|
||||
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
|
||||
const flowCmds = log.filter((c) => c.action === 'flowmovement');
|
||||
|
||||
// We expect: at least one startup (for the idle pump) AND flow
|
||||
// adjustments on the running pumps. The exact split depends on
|
||||
// optimizer behaviour, so assert loosely.
|
||||
assert.ok(startupCmds.length >= 1, 'at least one startup expected for the idle pump');
|
||||
assert.ok(flowCmds.length >= 1, 'at least one flowmovement expected');
|
||||
|
||||
// The schedule snapshot stored on the executor should record a
|
||||
// positive tStar (rendezvous time).
|
||||
const lastSchedule = mgc.movementExecutor.schedule();
|
||||
assert.ok(lastSchedule, 'executor schedule should be set');
|
||||
// The schedule should have at least one increasing eta (the startup),
|
||||
// which sets tStar > 0.
|
||||
assert.ok(lastSchedule.tStarS > 0, `tStar should be > 0 when a startup is in the plan; got ${lastSchedule.tStarS}`);
|
||||
|
||||
// If any flowmovement on an EXISTING (then-operational) pump was a
|
||||
// down-move, its fireAtTickN should be > 0 (delayed). Find any such
|
||||
// command in the schedule.
|
||||
const delayedDownMoves = lastSchedule.commands.filter((c) => c.action === 'flowmovement' && c.fireAtTickN > 0);
|
||||
// Note: this assertion is "expected on most runs" rather than
|
||||
// "guaranteed every time" — depends on whether the optimizer picks a
|
||||
// combination that requires existing pumps to reduce. We assert the
|
||||
// schedule SHAPE (positive tStar) and accept that delayed-down moves
|
||||
// are common-but-not-mandatory.
|
||||
if (delayedDownMoves.length === 0) {
|
||||
// Surface a debug print if the run didn't exercise delayed moves —
|
||||
// helps when reading test logs to know what happened.
|
||||
console.log(' (planner-integration) note: no delayed down-moves this run — combination may have been all-up.');
|
||||
}
|
||||
});
|
||||
|
||||
test('planner-integration: replan drops unfired commands when a new demand arrives', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const log = tapExecutor(mgc);
|
||||
|
||||
// First demand: 100% from idle. tStar will be ~3.5s; all startup
|
||||
// cmds fire at tick 0 (synchronous), but if there were any delayed
|
||||
// down-moves, they'd be in the schedule.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
|
||||
await sleep(100);
|
||||
const firstSnapshot = mgc.movementExecutor.schedule().commands.length;
|
||||
|
||||
// Immediately fire a second demand: 50%. Replan happens; some unfired
|
||||
// commands from the first schedule get dropped.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 50)).catch(() => {});
|
||||
await sleep(100);
|
||||
|
||||
// Schedule was replaced.
|
||||
const secondSnapshot = mgc.movementExecutor.schedule();
|
||||
assert.ok(secondSnapshot, 'executor schedule replaced after replan');
|
||||
// Cursor reset to a low value (≤ a couple of ticks from the replan).
|
||||
assert.ok(mgc.movementExecutor.cursor() <= 2, `cursor should reset on replan; got ${mgc.movementExecutor.cursor()}`);
|
||||
// Sanity: replan didn't blow up the executor.
|
||||
assert.ok(firstSnapshot > 0, 'first dispatch should have queued at least one command');
|
||||
});
|
||||
Reference in New Issue
Block a user