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>
129 lines
5.1 KiB
JavaScript
129 lines
5.1 KiB
JavaScript
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);
|
|
});
|