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:
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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user