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