P4 wave 1: extract MGC concerns into focused modules
src/groupOps/ groupOperatingPoint + groupCurves (pure functions)
src/totals/ totalsCalculator (dynamic + absolute + active)
src/combinatorics/ pumpCombinations (validPumpCombinations + checkSpecialCases)
src/optimizer/ bestCombination (CoG) + bepGravitation (BEP-G + marginal-cost)
src/efficiency/ groupEfficiency (calc + distance helpers)
src/dispatch/ demandDispatcher (LatestWinsGate-based; replaces
_dispatchInFlight + _delayedCall)
src/commands/ canonical names from start (set.mode/scaling/demand,
child.register) + legacy aliases
CONTRACT.md inputs/outputs/events surface
53 basic tests pass (52 new + 1 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P4 wave 2.
Findings flagged via agents (TODO append to OPEN_QUESTIONS.md):
- calcGroupEfficiency.maxEfficiency is actually the mean (misleading name)
- checkSpecialCases has a no-op `return false` inside forEach
- MGC doesn't route cmd.startup/shutdown/estop — confirm if station broadcasts need it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
110
test/basic/bepGravitation.basic.test.js
Normal file
110
test/basic/bepGravitation.basic.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
calcBestCombinationBEPGravitation,
|
||||
estimateSlopesAtBEP,
|
||||
redistributeFlowBySlope,
|
||||
} = require('../../src/optimizer/bepGravitation');
|
||||
const optimizerIndex = require('../../src/optimizer');
|
||||
|
||||
function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) {
|
||||
return {
|
||||
config: { general: { id } },
|
||||
NCog,
|
||||
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
|
||||
predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 },
|
||||
// Default: convex cost so marginal-cost refinement has a clear winner.
|
||||
inputFlowCalcPower: costFn ?? ((f) => 0.001 * f * f + f),
|
||||
};
|
||||
}
|
||||
|
||||
function mkCtx(machines) {
|
||||
return {
|
||||
machines,
|
||||
groupCurves: {
|
||||
groupFlow: (m) => m.predictFlow,
|
||||
groupPower: (m) => m.predictPower,
|
||||
groupNCog: (m) => m.NCog ?? 0,
|
||||
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
|
||||
},
|
||||
logger: { debug: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('estimateSlopesAtBEP: returns finite slopes/alpha/Q_BEP/P_BEP for a typical machine', () => {
|
||||
const machine = makeMachine({ id: 'a', fMin: 10, fMax: 90, NCog: 0.5 });
|
||||
const ctx = mkCtx({ a: machine });
|
||||
const slopes = estimateSlopesAtBEP(machine, 50, ctx);
|
||||
assert.ok(Number.isFinite(slopes.slopeLeft));
|
||||
assert.ok(Number.isFinite(slopes.slopeRight));
|
||||
assert.ok(Number.isFinite(slopes.alpha));
|
||||
assert.ok(slopes.alpha > 0);
|
||||
assert.ok(Number.isFinite(slopes.Q_BEP));
|
||||
assert.equal(slopes.Q_BEP, 50);
|
||||
assert.ok(Number.isFinite(slopes.P_BEP));
|
||||
});
|
||||
|
||||
test('redistributeFlowBySlope: redistributes within capacity, never exceeding min/max', () => {
|
||||
const pumpInfos = [
|
||||
{ id: 'a', minFlow: 0, maxFlow: 50,
|
||||
slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } },
|
||||
{ id: 'b', minFlow: 0, maxFlow: 50,
|
||||
slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } },
|
||||
];
|
||||
const flowDist = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
|
||||
redistributeFlowBySlope(pumpInfos, flowDist, 30); // add 30 across 2 pumps
|
||||
const total = flowDist.reduce((s, e) => s + e.flow, 0);
|
||||
assert.ok(Math.abs(total - 50) < 1e-2, `expected total ~50, got ${total}`);
|
||||
for (const e of flowDist) {
|
||||
assert.ok(e.flow <= 50 + 1e-6 && e.flow >= 0 - 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
test('marginal-cost refinement bounded (no infinite loop on a flat-curve scenario)', () => {
|
||||
// Flat cost everywhere -> marginal cost identical -> loop must exit cleanly.
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f }),
|
||||
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f }),
|
||||
};
|
||||
const ctx = mkCtx(machines);
|
||||
const start = Date.now();
|
||||
const res = calcBestCombinationBEPGravitation([['a', 'b']], 30, ctx);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 1000, `refinement should be fast, took ${elapsed}ms`);
|
||||
assert.ok(res.bestCombination);
|
||||
const total = res.bestCombination.reduce((s, e) => s + e.flow, 0);
|
||||
assert.ok(Math.abs(total - 30) < 1e-2, `total should be ~Qd, got ${total}`);
|
||||
});
|
||||
|
||||
test('method selection: directional uses slopeRight/slopeLeft; non-directional uses alpha', () => {
|
||||
// Asymmetric slopes so the two methods produce different allocations.
|
||||
const pumpInfos = [
|
||||
{ id: 'a', minFlow: 0, maxFlow: 100,
|
||||
slopes: { slopeLeft: 10, slopeRight: 0.1, alpha: 5 } },
|
||||
{ id: 'b', minFlow: 0, maxFlow: 100,
|
||||
slopes: { slopeLeft: 0.1, slopeRight: 10, alpha: 5 } },
|
||||
];
|
||||
const distDir = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
|
||||
const distAlpha = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
|
||||
|
||||
// Increase by 30 -> directional should prefer 'a' (shallow right slope).
|
||||
redistributeFlowBySlope(pumpInfos, distDir, 30, true);
|
||||
// Alpha mode: same slope-weight per pump -> roughly equal split.
|
||||
redistributeFlowBySlope(pumpInfos, distAlpha, 30, false);
|
||||
|
||||
const aDir = distDir.find(e => e.machineId === 'a').flow;
|
||||
const bDir = distDir.find(e => e.machineId === 'b').flow;
|
||||
const aAlpha = distAlpha.find(e => e.machineId === 'a').flow;
|
||||
const bAlpha = distAlpha.find(e => e.machineId === 'b').flow;
|
||||
|
||||
assert.ok(aDir > bDir, `directional should send more to a (got a=${aDir}, b=${bDir})`);
|
||||
assert.ok(Math.abs(aAlpha - bAlpha) < 1e-2, `alpha mode should split evenly (got a=${aAlpha}, b=${bAlpha})`);
|
||||
|
||||
// pickOptimizer wires the right module.
|
||||
assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation-Directional').calcBestCombinationBEPGravitation,
|
||||
calcBestCombinationBEPGravitation);
|
||||
assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation').calcBestCombinationBEPGravitation,
|
||||
calcBestCombinationBEPGravitation);
|
||||
assert.ok(optimizerIndex.pickOptimizer('CoG').calcBestCombination);
|
||||
});
|
||||
67
test/basic/bestCombination.basic.test.js
Normal file
67
test/basic/bestCombination.basic.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { calcBestCombination } = require('../../src/optimizer/bestCombination');
|
||||
|
||||
function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) {
|
||||
return {
|
||||
config: { general: { id } },
|
||||
NCog,
|
||||
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
|
||||
predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 },
|
||||
// Power model: caller picks the cost function so we can shape who wins.
|
||||
inputFlowCalcPower: costFn ?? ((flow) => flow * 1.0),
|
||||
};
|
||||
}
|
||||
|
||||
function mkCtx(machines) {
|
||||
return {
|
||||
machines,
|
||||
groupCurves: {
|
||||
groupFlow: (m) => m.predictFlow,
|
||||
groupPower: (m) => m.predictPower,
|
||||
groupNCog: (m) => m.NCog ?? 0,
|
||||
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
|
||||
},
|
||||
logger: { debug: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('calcBestCombination: 1 machine in combination receives Qd clamped to its range', () => {
|
||||
const machines = { a: makeMachine({ id: 'a', fMin: 5, fMax: 60 }) };
|
||||
const ctx = mkCtx(machines);
|
||||
|
||||
const res = calcBestCombination([['a']], 40, ctx);
|
||||
assert.ok(res.bestCombination);
|
||||
assert.equal(res.bestCombination.length, 1);
|
||||
assert.equal(res.bestCombination[0].flow, 40);
|
||||
|
||||
// Above max — clamps to max.
|
||||
const high = calcBestCombination([['a']], 200, ctx);
|
||||
assert.equal(high.bestCombination[0].flow, 60);
|
||||
});
|
||||
|
||||
test('calcBestCombination: 2 machines with equal NCog split flow evenly', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', NCog: 0.5, fMin: 0, fMax: 100 }),
|
||||
b: makeMachine({ id: 'b', NCog: 0.5, fMin: 0, fMax: 100 }),
|
||||
};
|
||||
const ctx = mkCtx(machines);
|
||||
const res = calcBestCombination([['a', 'b']], 40, ctx);
|
||||
const aFlow = res.bestCombination.find(e => e.machineId === 'a').flow;
|
||||
const bFlow = res.bestCombination.find(e => e.machineId === 'b').flow;
|
||||
assert.ok(Math.abs(aFlow - bFlow) < 1e-6, `expected even split, got a=${aFlow} b=${bFlow}`);
|
||||
assert.ok(Math.abs(aFlow + bFlow - 40) < 1e-6);
|
||||
});
|
||||
|
||||
test('calcBestCombination: returns combination with the lowest total power', () => {
|
||||
// Two combinations: [a] (expensive) vs [b] (cheap). Both can deliver Qd=20.
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f * 10 }),
|
||||
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f * 1 }),
|
||||
};
|
||||
const ctx = mkCtx(machines);
|
||||
const res = calcBestCombination([['a'], ['b']], 20, ctx);
|
||||
assert.equal(res.bestCombination[0].machineId, 'b');
|
||||
assert.equal(res.bestPower, 20);
|
||||
});
|
||||
172
test/basic/commands.basic.test.js
Normal file
172
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// Basic tests for the machineGroupControl commands registry.
|
||||
// Run with: node --test test/basic/commands.basic.test.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const commands = require('../../src/commands');
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(String(m)),
|
||||
error: (m) => calls.error.push(String(m)),
|
||||
info: (m) => calls.info.push(String(m)),
|
||||
debug: (m) => calls.debug.push(String(m)),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSource({ name = 'mgc-1', handleInputResult = undefined } = {}) {
|
||||
const calls = {
|
||||
setMode: [],
|
||||
setScaling: [],
|
||||
handleInput: [],
|
||||
registerChild: [],
|
||||
};
|
||||
const source = {
|
||||
logger: makeLogger(),
|
||||
config: { general: { name } },
|
||||
setMode: (m) => calls.setMode.push(m),
|
||||
setScaling: (s) => calls.setScaling.push(s),
|
||||
handleInput: async (src, demand) => {
|
||||
calls.handleInput.push({ src, demand });
|
||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||
return handleInputResult;
|
||||
},
|
||||
childRegistrationUtils: {
|
||||
registerChild: (childSource, position) =>
|
||||
calls.registerChild.push({ childSource, position }),
|
||||
},
|
||||
};
|
||||
return { source, calls };
|
||||
}
|
||||
|
||||
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
|
||||
return {
|
||||
logger,
|
||||
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
|
||||
node: {},
|
||||
send: sendSpy || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(logger) {
|
||||
return createRegistry(commands, { logger });
|
||||
}
|
||||
|
||||
// --- tests -----------------------------------------------------------------
|
||||
|
||||
test('canonical topics dispatch to their handlers', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.mode', payload: 'prioritycontrol' }, source, makeCtx());
|
||||
assert.deepEqual(calls.setMode, ['prioritycontrol']);
|
||||
|
||||
await reg.dispatch({ topic: 'set.scaling', payload: 'normalized' }, source, makeCtx());
|
||||
assert.deepEqual(calls.setScaling, ['normalized']);
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 });
|
||||
});
|
||||
|
||||
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const child = { id: 'child-1', source: { tag: 'child-domain' } };
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' },
|
||||
source,
|
||||
makeCtx({ child })
|
||||
);
|
||||
assert.equal(calls.registerChild.length, 1);
|
||||
assert.equal(calls.registerChild[0].childSource, child.source);
|
||||
assert.equal(calls.registerChild[0].position, 'upstream');
|
||||
});
|
||||
|
||||
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'prioritycontrol' }, source, makeCtx({ logger: ctxLogger }));
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'optimalcontrol' }, source, makeCtx({ logger: ctxLogger }));
|
||||
assert.deepEqual(calls.setMode, ['prioritycontrol', 'optimalcontrol']);
|
||||
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
|
||||
assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once');
|
||||
|
||||
await reg.dispatch({ topic: 'setScaling', payload: 'absolute' }, source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'setScaling' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
assert.deepEqual(calls.setScaling, ['absolute']);
|
||||
|
||||
await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
|
||||
const child = { id: 'child-x', source: { tag: 'child-domain' } };
|
||||
await reg.dispatch(
|
||||
{ topic: 'registerChild', payload: 'child-x', positionVsParent: 'atEquipment' },
|
||||
source,
|
||||
makeCtx({ child, logger: ctxLogger })
|
||||
);
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'registerChild' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
assert.equal(calls.registerChild.length, 1);
|
||||
});
|
||||
|
||||
test('set.demand with non-numeric payload logs error and does not call handleInput', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.error.some((m) => m.includes('set.demand') && m.includes('oops')),
|
||||
`expected error about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.error)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('set.demand on success calls ctx.send with reply { topic: config.general.name, payload: "done" }', async () => {
|
||||
const { source, calls } = makeSource({ name: 'mgc-A' });
|
||||
const sent = [];
|
||||
const ctx = makeCtx({ sendSpy: (m) => sent.push(m) });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 7.5 }, source, ctx);
|
||||
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 7.5 });
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].topic, 'mgc-A');
|
||||
assert.equal(sent[0].payload, 'done');
|
||||
});
|
||||
|
||||
test('child.register with unknown child id logs warn and does not throw', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
|
||||
source,
|
||||
makeCtx({ logger: ctxLogger })
|
||||
)
|
||||
);
|
||||
assert.equal(calls.registerChild.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
|
||||
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
140
test/basic/demandDispatcher.basic.test.js
Normal file
140
test/basic/demandDispatcher.basic.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const DemandDispatcher = require('../../src/dispatch/demandDispatcher.js');
|
||||
|
||||
const silentLogger = { warn() {}, error() {}, debug() {}, info() {} };
|
||||
|
||||
// Helper: a manually-resolvable promise so we can pin a dispatch in flight.
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('fire(50) triggers runFn with 50', async () => {
|
||||
const calls = [];
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => { calls.push(demand); },
|
||||
);
|
||||
dispatcher.fire(50);
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, [50]);
|
||||
});
|
||||
|
||||
test('two fires back-to-back during in-flight — only the second runs after first settles', async () => {
|
||||
const calls = [];
|
||||
const gates = [deferred()];
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => {
|
||||
calls.push(demand);
|
||||
await gates[0].promise;
|
||||
},
|
||||
);
|
||||
|
||||
dispatcher.fire(10);
|
||||
// first invocation is now in flight (after a microtask)
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
dispatcher.fire(20);
|
||||
// 20 should be pending, not yet run.
|
||||
assert.deepEqual(calls, [10]);
|
||||
gates[0].resolve();
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, [10, 20]);
|
||||
});
|
||||
|
||||
test('three rapid fires — only first + last run; middle dropped', async () => {
|
||||
const calls = [];
|
||||
const gate = deferred();
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => {
|
||||
calls.push(demand);
|
||||
if (calls.length === 1) await gate.promise;
|
||||
},
|
||||
);
|
||||
|
||||
dispatcher.fire(1);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
dispatcher.fire(2);
|
||||
dispatcher.fire(3); // overwrites the pending 2
|
||||
|
||||
assert.deepEqual(calls, [1]);
|
||||
gate.resolve();
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, [1, 3]);
|
||||
});
|
||||
|
||||
test('drain() resolves only when idle', async () => {
|
||||
const gate = deferred();
|
||||
let runs = 0;
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async () => { runs++; await gate.promise; },
|
||||
);
|
||||
|
||||
// drain() on an idle gate resolves immediately.
|
||||
await dispatcher.drain();
|
||||
|
||||
dispatcher.fire('a');
|
||||
let drained = false;
|
||||
const drainPromise = dispatcher.drain().then(() => { drained = true; });
|
||||
// Let a few microtasks run — drain must NOT be resolved while in flight.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
assert.equal(runs, 1);
|
||||
gate.resolve();
|
||||
await drainPromise;
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
test('error in runFn does not deadlock; subsequent fire still works', async () => {
|
||||
const calls = [];
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => {
|
||||
calls.push(demand);
|
||||
if (demand === 'boom') throw new Error('boom');
|
||||
},
|
||||
);
|
||||
dispatcher.fire('boom');
|
||||
await dispatcher.drain();
|
||||
dispatcher.fire('ok');
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, ['boom', 'ok']);
|
||||
});
|
||||
|
||||
test('inFlight getter reports correctly', async () => {
|
||||
const gate = deferred();
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async () => { await gate.promise; },
|
||||
);
|
||||
assert.equal(dispatcher.inFlight, false);
|
||||
dispatcher.fire(1);
|
||||
// Microtask scheduling — gate flips to inFlight after one tick.
|
||||
await Promise.resolve();
|
||||
assert.equal(dispatcher.inFlight, true);
|
||||
gate.resolve();
|
||||
await dispatcher.drain();
|
||||
assert.equal(dispatcher.inFlight, false);
|
||||
});
|
||||
|
||||
test('runFn receives the ctx supplied at construction', async () => {
|
||||
const seen = [];
|
||||
const ctx = { logger: silentLogger, marker: 'mgc-A' };
|
||||
const dispatcher = new DemandDispatcher(
|
||||
ctx,
|
||||
async (demand, runCtx) => { seen.push({ demand, marker: runCtx.marker }); },
|
||||
);
|
||||
dispatcher.fire(42);
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(seen, [{ demand: 42, marker: 'mgc-A' }]);
|
||||
});
|
||||
66
test/basic/groupCurves.basic.test.js
Normal file
66
test/basic/groupCurves.basic.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { groupFlow, groupPower, groupNCog, groupCalcPower } = require('../../src/groupOps/groupCurves');
|
||||
|
||||
function predictView(min, max, current = (min + max) / 2) {
|
||||
return {
|
||||
currentF: current,
|
||||
currentFxyYMin: min,
|
||||
currentFxyYMax: max,
|
||||
};
|
||||
}
|
||||
|
||||
test('groupFlow returns the same shape as the original _groupFlow (groupPredictFlow preferred)', () => {
|
||||
const machine = {
|
||||
predictFlow: predictView(0, 1, 0.5),
|
||||
groupPredictFlow: predictView(0.1, 0.9, 0.4),
|
||||
};
|
||||
const v = groupFlow(machine);
|
||||
assert.equal(v, machine.groupPredictFlow);
|
||||
assert.equal(v.currentFxyYMin, 0.1);
|
||||
assert.equal(v.currentFxyYMax, 0.9);
|
||||
assert.equal(v.currentF, 0.4);
|
||||
});
|
||||
|
||||
test('groupFlow falls back to predictFlow when groupPredictFlow is absent', () => {
|
||||
const machine = { predictFlow: predictView(0, 1) };
|
||||
assert.equal(groupFlow(machine), machine.predictFlow);
|
||||
});
|
||||
|
||||
test('groupPower returns groupPredictPower when present, else predictPower', () => {
|
||||
const m1 = { predictPower: predictView(0, 100), groupPredictPower: predictView(10, 90) };
|
||||
assert.equal(groupPower(m1), m1.groupPredictPower);
|
||||
|
||||
const m2 = { predictPower: predictView(0, 100) };
|
||||
assert.equal(groupPower(m2), m2.predictPower);
|
||||
});
|
||||
|
||||
test('groupNCog returns the group value when groupPredictFlow is present', () => {
|
||||
const m = { groupPredictFlow: predictView(0, 1), groupNCog: 0.42, NCog: 0.99, predictFlow: predictView(0, 1) };
|
||||
assert.equal(groupNCog(m), 0.42);
|
||||
});
|
||||
|
||||
test('groupNCog falls back to NCog when no groupPredictFlow', () => {
|
||||
const m = { predictFlow: predictView(0, 1), NCog: 0.7 };
|
||||
assert.equal(groupNCog(m), 0.7);
|
||||
});
|
||||
|
||||
test('groupNCog defaults to 0 when neither is defined', () => {
|
||||
const m = { predictFlow: predictView(0, 1) };
|
||||
assert.equal(groupNCog(m), 0);
|
||||
});
|
||||
|
||||
test('groupCalcPower prefers machine.groupCalcPower', () => {
|
||||
let lastFlow = null;
|
||||
const m = {
|
||||
groupCalcPower(flow) { lastFlow = flow; return flow * 2; },
|
||||
inputFlowCalcPower(flow) { return flow * 999; },
|
||||
};
|
||||
assert.equal(groupCalcPower(m, 0.3), 0.6);
|
||||
assert.equal(lastFlow, 0.3);
|
||||
});
|
||||
|
||||
test('groupCalcPower falls back to inputFlowCalcPower when groupCalcPower missing', () => {
|
||||
const m = { inputFlowCalcPower(flow) { return flow + 1; } };
|
||||
assert.equal(groupCalcPower(m, 5), 6);
|
||||
});
|
||||
71
test/basic/groupEfficiency.basic.test.js
Normal file
71
test/basic/groupEfficiency.basic.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { interpolation } = require('generalFunctions');
|
||||
const GroupEfficiency = require('../../src/efficiency/groupEfficiency.js');
|
||||
|
||||
function makeMachines(cogs) {
|
||||
const out = {};
|
||||
cogs.forEach((cog, i) => { out[`m${i}`] = { cog }; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeGE(extra = {}) {
|
||||
return new GroupEfficiency({
|
||||
interpolation: new interpolation(),
|
||||
logger: { warn() {}, error() {}, debug() {}, info() {} },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('calcGroupEfficiency aggregates across 3 machines', () => {
|
||||
const ge = makeGE();
|
||||
const machines = makeMachines([0.9, 0.8, 0.7]);
|
||||
const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(machines);
|
||||
assert.equal(lowestEfficiency, 0.7);
|
||||
// maxEfficiency in the original code is actually the MEAN cog.
|
||||
assert.ok(Math.abs(maxEfficiency - 0.8) < 1e-12);
|
||||
});
|
||||
|
||||
test('calcDistanceFromPeak returns |a - b|', () => {
|
||||
const ge = makeGE();
|
||||
assert.ok(Math.abs(ge.calcDistanceFromPeak(0.85, 0.92) - 0.07) < 1e-12);
|
||||
assert.ok(Math.abs(ge.calcDistanceFromPeak(0.92, 0.85) - 0.07) < 1e-12);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak maps current onto [0..1]', () => {
|
||||
const ge = makeGE();
|
||||
// current=0.85, max=0.92, min=0.7 → maps 0.85 in [0.92..0.7] onto [0..1].
|
||||
// interpolate_lin_single_point treats first range as input domain:
|
||||
// 0.85 → ((0.85 - 0.92) / (0.7 - 0.92)) * (1 - 0) + 0 = 0.07/0.22 ≈ 0.3181818...
|
||||
const v = ge.calcRelativeDistanceFromPeak(0.85, 0.92, 0.7);
|
||||
const expected = (0.85 - 0.92) / (0.7 - 0.92);
|
||||
assert.ok(Math.abs(v - expected) < 1e-9, `got ${v} expected ${expected}`);
|
||||
});
|
||||
|
||||
test('calcDistanceBEP returns both abs + rel', () => {
|
||||
const ge = makeGE();
|
||||
const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.85, 0.92, 0.7);
|
||||
assert.ok(Math.abs(absDistFromPeak - 0.07) < 1e-12);
|
||||
const expectedRel = (0.85 - 0.92) / (0.7 - 0.92);
|
||||
assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak returns 1 when max === min (degenerate)', () => {
|
||||
const ge = makeGE();
|
||||
assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), 1);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak returns 1 when current is null', () => {
|
||||
const ge = makeGE();
|
||||
assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), 1);
|
||||
});
|
||||
|
||||
test('calcGroupEfficiency handles a single machine', () => {
|
||||
const ge = makeGE();
|
||||
const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(makeMachines([0.77]));
|
||||
assert.equal(maxEfficiency, 0.77);
|
||||
assert.equal(lowestEfficiency, 0.77);
|
||||
});
|
||||
131
test/basic/groupOperatingPoint.basic.test.js
Normal file
131
test/basic/groupOperatingPoint.basic.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { MeasurementContainer, POSITIONS } = require('generalFunctions');
|
||||
const GroupOperatingPoint = require('../../src/groupOps/groupOperatingPoint');
|
||||
|
||||
const unitPolicy = {
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
};
|
||||
|
||||
const silentLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
function makeContainer() {
|
||||
return new MeasurementContainer({
|
||||
defaultUnits: unitPolicy.output,
|
||||
preferredUnits: unitPolicy.output,
|
||||
canonicalUnits: unitPolicy.canonical,
|
||||
storeCanonical: true,
|
||||
autoConvert: true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeMachine(id, pressures = {}) {
|
||||
// pressures: { down?: Pa, up?: Pa } — written into a real container
|
||||
const m = {
|
||||
config: { general: { id } },
|
||||
measurements: makeContainer(),
|
||||
setGroupOperatingPointCalls: [],
|
||||
setGroupOperatingPoint(down, up) {
|
||||
this.setGroupOperatingPointCalls.push({ down, up });
|
||||
},
|
||||
};
|
||||
const now = Date.now();
|
||||
if (pressures.down != null) {
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(pressures.down, now, 'Pa');
|
||||
}
|
||||
if (pressures.up != null) {
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(pressures.up, now, 'Pa');
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
test('readChild returns value in requested unit when present', () => {
|
||||
const machines = {};
|
||||
const m = makeMachine('m1', { down: 150000 });
|
||||
machines[m.config.general.id] = m;
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
|
||||
|
||||
const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.DOWNSTREAM, 'Pa');
|
||||
assert.equal(v, 150000);
|
||||
});
|
||||
|
||||
test('readChild returns null when measurement missing', () => {
|
||||
const m = makeMachine('m1');
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { m1: m }, unitPolicy, logger: silentLogger });
|
||||
|
||||
const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.UPSTREAM, 'Pa');
|
||||
assert.equal(v, null);
|
||||
});
|
||||
|
||||
test("writeOwn writes to the group's measurements container", () => {
|
||||
const ownC = makeContainer();
|
||||
const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, 0.1, 'm3/s');
|
||||
const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s');
|
||||
assert.equal(v, 0.1);
|
||||
});
|
||||
|
||||
test('writeOwn skips non-finite values', () => {
|
||||
const ownC = makeContainer();
|
||||
const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, NaN, 'm3/s');
|
||||
const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s');
|
||||
assert.equal(v, null);
|
||||
});
|
||||
|
||||
test('equalize() pushes the worst-case header onto each machine when 3 pressures differ', () => {
|
||||
// No group header → max child downstream, min positive child upstream.
|
||||
// max(120k, 140k, 100k) = 140000, min(80k, 90k, 70k) = 70000.
|
||||
const machines = {
|
||||
a: makeMachine('a', { down: 120000, up: 80000 }),
|
||||
b: makeMachine('b', { down: 140000, up: 90000 }),
|
||||
c: makeMachine('c', { down: 100000, up: 70000 }),
|
||||
};
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.equalize();
|
||||
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
const last = machines[id].setGroupOperatingPointCalls.at(-1);
|
||||
assert.ok(last, `machine ${id} should have been called`);
|
||||
assert.equal(last.down, 140000);
|
||||
assert.equal(last.up, 70000);
|
||||
}
|
||||
});
|
||||
|
||||
test('equalize() is a no-op when there is no pressure data', () => {
|
||||
const machines = { a: makeMachine('a'), b: makeMachine('b') };
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.equalize();
|
||||
|
||||
assert.equal(machines.a.setGroupOperatingPointCalls.length, 0);
|
||||
assert.equal(machines.b.setGroupOperatingPointCalls.length, 0);
|
||||
});
|
||||
|
||||
test('equalize() is a no-op when machines map is empty', () => {
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: {}, unitPolicy, logger: silentLogger });
|
||||
assert.doesNotThrow(() => gop.equalize());
|
||||
});
|
||||
|
||||
test('equalize() falls back to direct fDimension when setGroupOperatingPoint is missing', () => {
|
||||
const m = {
|
||||
config: { general: { id: 'old' } },
|
||||
measurements: makeContainer(),
|
||||
predictFlow: { fDimension: 0 },
|
||||
predictPower: { fDimension: 0 },
|
||||
predictCtrl: { fDimension: 0 },
|
||||
};
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(200000, Date.now(), 'Pa');
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(100000, Date.now(), 'Pa');
|
||||
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { old: m }, unitPolicy, logger: silentLogger });
|
||||
gop.equalize();
|
||||
|
||||
assert.equal(m.predictFlow.fDimension, 100000);
|
||||
assert.equal(m.predictPower.fDimension, 100000);
|
||||
assert.equal(m.predictCtrl.fDimension, 100000);
|
||||
});
|
||||
90
test/basic/pumpCombinations.basic.test.js
Normal file
90
test/basic/pumpCombinations.basic.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// Local stub for groupCurves — replace once ../groupOps/groupCurves lands.
|
||||
const groupCurves = {
|
||||
groupFlow: (m) => m.predictFlow,
|
||||
groupPower: (m) => m.predictPower,
|
||||
groupNCog: (m) => m.NCog ?? 0,
|
||||
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
|
||||
};
|
||||
|
||||
const { validPumpCombinations, checkSpecialCases } =
|
||||
require('../../src/combinatorics/pumpCombinations');
|
||||
|
||||
function makeMachine({ id, state = 'off', mode = 'auto',
|
||||
fMin = 0, fMax = 100, pMax = 100,
|
||||
NCog = 0.5, validAction = true } = {}) {
|
||||
return {
|
||||
config: { general: { id } },
|
||||
state: { getCurrentState: () => state },
|
||||
currentMode: mode,
|
||||
NCog,
|
||||
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
|
||||
predictPower: { currentFxyYMin: 0, currentFxyYMax: pMax },
|
||||
inputFlowCalcPower: (flow) => flow * 0.5,
|
||||
isValidActionForMode: () => validAction,
|
||||
};
|
||||
}
|
||||
|
||||
const POSITIONS = { DOWNSTREAM: 'downstream' };
|
||||
const baseCtx = (extra = {}) => ({
|
||||
groupCurves,
|
||||
logger: { warn: () => {}, debug: () => {}, error: () => {} },
|
||||
readChildMeasurement: () => undefined,
|
||||
POSITIONS,
|
||||
unitPolicy: { canonical: { flow: 'm3/s' } },
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('validPumpCombinations: 3 idle machines + Qd in range returns subsets that can deliver', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
b: makeMachine({ id: 'b', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
c: makeMachine({ id: 'c', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
};
|
||||
const combos = validPumpCombinations(machines, 40, baseCtx());
|
||||
assert.ok(combos.length > 0, 'expected at least one combination');
|
||||
// every combination must be able to deliver Qd
|
||||
for (const subset of combos) {
|
||||
const maxF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMax, 0);
|
||||
const minF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMin, 0);
|
||||
assert.ok(maxF >= 40);
|
||||
assert.ok(minF <= 40);
|
||||
}
|
||||
});
|
||||
|
||||
test('validPumpCombinations: excludes machines in off/coolingdown/stopping/emergencystop', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', state: 'off', fMin: 10, fMax: 50 }),
|
||||
b: makeMachine({ id: 'b', state: 'coolingdown', fMin: 10, fMax: 50 }),
|
||||
c: makeMachine({ id: 'c', state: 'stopping', fMin: 10, fMax: 50 }),
|
||||
d: makeMachine({ id: 'd', state: 'emergencystop', fMin: 10, fMax: 50 }),
|
||||
e: makeMachine({ id: 'e', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
};
|
||||
const combos = validPumpCombinations(machines, 30, baseCtx());
|
||||
// Only "e" can be in a combination
|
||||
for (const subset of combos) {
|
||||
for (const id of subset) assert.equal(id, 'e');
|
||||
}
|
||||
});
|
||||
|
||||
test('checkSpecialCases: reduces Qd by flow of manually controlled operational machines', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', state: 'operational', mode: 'virtualControl' }),
|
||||
b: makeMachine({ id: 'b', state: 'idle' }),
|
||||
};
|
||||
const ctx = baseCtx({
|
||||
readChildMeasurement: (m, type, variant) => {
|
||||
if (m.config.general.id === 'a' && variant === 'measured') return 12;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const adjusted = checkSpecialCases(machines, 50, ctx);
|
||||
assert.equal(adjusted, 38);
|
||||
});
|
||||
|
||||
test('validPumpCombinations: no machines returns empty array', () => {
|
||||
const combos = validPumpCombinations({}, 10, baseCtx());
|
||||
assert.deepEqual(combos, []);
|
||||
});
|
||||
128
test/basic/totalsCalculator.basic.test.js
Normal file
128
test/basic/totalsCalculator.basic.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const TotalsCalculator = require('../../src/totals/totalsCalculator');
|
||||
|
||||
const unitPolicy = {
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
};
|
||||
const silent = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
function predictView(min, max) {
|
||||
return { currentF: (min + max) / 2, currentFxyYMin: min, currentFxyYMax: max };
|
||||
}
|
||||
|
||||
function makeMachine(id, opts = {}) {
|
||||
const {
|
||||
flowMin = 0.0, flowMax = 1.0,
|
||||
powerMin = 100, powerMax = 1000,
|
||||
state = 'operational',
|
||||
hasCurve = true,
|
||||
NCog = 0.5,
|
||||
// Input-curve envelope (for calcAbsoluteTotals): { [pressureKey]: { y: [...] } }
|
||||
inputCurve = null,
|
||||
actFlow = 0,
|
||||
actPower = 0,
|
||||
} = opts;
|
||||
|
||||
const fakeInput = inputCurve || {
|
||||
'50000': { y: [flowMin, (flowMin + flowMax) / 2, flowMax] },
|
||||
};
|
||||
const fakePower = inputCurve
|
||||
? Object.fromEntries(Object.keys(inputCurve).map(k => [k, { y: [powerMin, (powerMin + powerMax) / 2, powerMax] }]))
|
||||
: { '50000': { y: [powerMin, (powerMin + powerMax) / 2, powerMax] } };
|
||||
|
||||
return {
|
||||
config: { general: { id } },
|
||||
hasCurve,
|
||||
state: { getCurrentState: () => state },
|
||||
NCog,
|
||||
predictFlow: { inputCurve: fakeInput, ...predictView(flowMin, flowMax) },
|
||||
predictPower: { inputCurve: fakePower, ...predictView(powerMin, powerMax) },
|
||||
_actFlow: actFlow,
|
||||
_actPower: actPower,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeOperatingPoint(/* machines */) {
|
||||
return {
|
||||
readChild(machine, type, _variant, _position /*, _unit */) {
|
||||
if (type === 'flow') return machine._actFlow;
|
||||
if (type === 'power') return machine._actPower;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('calcAbsoluteTotals returns zeros when no machines', () => {
|
||||
const tc = new TotalsCalculator({ machines: {}, unitPolicy, logger: silent });
|
||||
const t = tc.calcAbsoluteTotals();
|
||||
assert.deepEqual(t, { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 } });
|
||||
});
|
||||
|
||||
test('calcAbsoluteTotals scans curve envelope (sum of maxes, min of mins)', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500 }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.8, powerMin: 200, powerMax: 700 }),
|
||||
};
|
||||
const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent });
|
||||
const t = tc.calcAbsoluteTotals();
|
||||
assert.equal(t.flow.min, 0.1);
|
||||
assert.equal(t.power.min, 100);
|
||||
// max is summed across all machines
|
||||
assert.equal(t.flow.max, 0.5 + 0.8);
|
||||
assert.equal(t.power.max, 500 + 700);
|
||||
});
|
||||
|
||||
test('calcDynamicTotals sums across machines and skips machines with no valid curve', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, actFlow: 0.3, actPower: 300 }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, actFlow: 0.4, actPower: 400 }),
|
||||
skip: makeMachine('skip', { hasCurve: false }),
|
||||
};
|
||||
const tc = new TotalsCalculator({
|
||||
machines, unitPolicy, logger: silent,
|
||||
operatingPoint: fakeOperatingPoint(machines),
|
||||
});
|
||||
|
||||
const t = tc.calcDynamicTotals();
|
||||
|
||||
assert.equal(t.flow.min, 0.1);
|
||||
assert.equal(t.flow.max, 0.5 + 0.7);
|
||||
assert.equal(t.flow.act, 0.3 + 0.4);
|
||||
assert.equal(t.power.min, 100);
|
||||
assert.equal(t.power.max, 500 + 600);
|
||||
assert.equal(t.power.act, 300 + 400);
|
||||
assert.equal(t.NCog, machines.a.NCog + machines.b.NCog);
|
||||
});
|
||||
|
||||
test('activeTotals skips machines whose state is off or maintenance', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'off' }),
|
||||
c: makeMachine('c', { flowMin: 0.3, flowMax: 0.9, powerMin: 300, powerMax: 900, state: 'maintenance' }),
|
||||
d: makeMachine('d', { flowMin: 0.05, flowMax: 0.4, powerMin: 50, powerMax: 400, state: 'accelerating' }),
|
||||
};
|
||||
const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent });
|
||||
|
||||
const t = tc.activeTotals();
|
||||
assert.equal(t.countActiveMachines, 2); // a + d
|
||||
assert.equal(t.flow.min, 0.1 + 0.05);
|
||||
assert.equal(t.flow.max, 0.5 + 0.4);
|
||||
assert.equal(t.power.min, 100 + 50);
|
||||
assert.equal(t.power.max, 500 + 400);
|
||||
});
|
||||
|
||||
test('activeTotals honours the injected isMachineActive override', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'operational' }),
|
||||
};
|
||||
const tc = new TotalsCalculator({
|
||||
machines, unitPolicy, logger: silent,
|
||||
isMachineActive: (id) => id === 'b',
|
||||
});
|
||||
const t = tc.activeTotals();
|
||||
assert.equal(t.countActiveMachines, 1);
|
||||
assert.equal(t.flow.max, 0.7);
|
||||
});
|
||||
Reference in New Issue
Block a user