Files
machineGroupControl/test/basic/pumpCombinations.basic.test.js
znetsixe 619b1311d2 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>
2026-05-10 20:45:23 +02:00

91 lines
3.5 KiB
JavaScript

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