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>
68 lines
2.6 KiB
JavaScript
68 lines
2.6 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { calcBestCombination } = require('../../src/optimizer/bestCombination');
|
|
|
|
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 },
|
|
// Power model: caller picks the cost function so we can shape who wins.
|
|
inputFlowCalcPower: costFn ?? ((flow) => flow * 1.0),
|
|
};
|
|
}
|
|
|
|
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('calcBestCombination: 1 machine in combination receives Qd clamped to its range', () => {
|
|
const machines = { a: makeMachine({ id: 'a', fMin: 5, fMax: 60 }) };
|
|
const ctx = mkCtx(machines);
|
|
|
|
const res = calcBestCombination([['a']], 40, ctx);
|
|
assert.ok(res.bestCombination);
|
|
assert.equal(res.bestCombination.length, 1);
|
|
assert.equal(res.bestCombination[0].flow, 40);
|
|
|
|
// Above max — clamps to max.
|
|
const high = calcBestCombination([['a']], 200, ctx);
|
|
assert.equal(high.bestCombination[0].flow, 60);
|
|
});
|
|
|
|
test('calcBestCombination: 2 machines with equal NCog split flow evenly', () => {
|
|
const machines = {
|
|
a: makeMachine({ id: 'a', NCog: 0.5, fMin: 0, fMax: 100 }),
|
|
b: makeMachine({ id: 'b', NCog: 0.5, fMin: 0, fMax: 100 }),
|
|
};
|
|
const ctx = mkCtx(machines);
|
|
const res = calcBestCombination([['a', 'b']], 40, ctx);
|
|
const aFlow = res.bestCombination.find(e => e.machineId === 'a').flow;
|
|
const bFlow = res.bestCombination.find(e => e.machineId === 'b').flow;
|
|
assert.ok(Math.abs(aFlow - bFlow) < 1e-6, `expected even split, got a=${aFlow} b=${bFlow}`);
|
|
assert.ok(Math.abs(aFlow + bFlow - 40) < 1e-6);
|
|
});
|
|
|
|
test('calcBestCombination: returns combination with the lowest total power', () => {
|
|
// Two combinations: [a] (expensive) vs [b] (cheap). Both can deliver Qd=20.
|
|
const machines = {
|
|
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f * 10 }),
|
|
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f * 1 }),
|
|
};
|
|
const ctx = mkCtx(machines);
|
|
const res = calcBestCombination([['a'], ['b']], 20, ctx);
|
|
assert.equal(res.bestCombination[0].machineId, 'b');
|
|
assert.equal(res.bestPower, 20);
|
|
});
|