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>
111 lines
4.9 KiB
JavaScript
111 lines
4.9 KiB
JavaScript
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);
|
|
});
|