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