308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
|
|
'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
|
|||
|
|
});
|