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:
znetsixe
2026-05-10 20:45:23 +02:00
parent ea2857fb25
commit 619b1311d2
21 changed files with 1895 additions and 0 deletions

View 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);
});

View 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);
});

View 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)}`
);
});

View 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' }]);
});

View 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);
});

View 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);
});

View 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);
});

View 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, []);
});

View 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);
});