Files
rotatingMachine/test/integration/efficiency-cog.integration.test.js
znetsixe 394a972d10 hydraulic efficiency η = (Q·ΔP)/P + asset registry rename
The pre-existing efficiency formula `η = flow/power` produced tiny SI-unit
values (m³/J ≈ 1e-5), was monotonic in ctrl for centrifugal-pump curves
(no interior peak), and made NCog collapse to 0 — which cascaded into MGC
reporting BEP-position 0.0% always. Replaced with hydraulic efficiency
η = (Q·ΔP)/P_shaft, the dimensionless 0..1 ratio that has a real BEP and
matches the form MGC's group-level math uses.

- prediction/efficiencyMath.js:
  * calcEfficiencyCurve takes pressureDiffPa; η = 0 when dP missing
  * calcCog guards (yMax > yMin) before computing NCog (was unguarded /0)
  * calcEfficiency falls back to predictFlow.currentF when measured ΔP is
    missing, so predicted-variant calls still produce a meaningful η before
    the differential measurement settles
- specificClass.js:
  * Asset-registry lookup renamed: 'machine' → 'rotatingmachine' (matches
    the datasets/assetData/ rename in generalFunctions). The error path
    quotes the new filename so operators can find it.
  * Two-call-site fix: with default-param stateConfig={}, the single-arg
    constructor path (BaseNodeAdapter calls `new Machine(this.config)`
    after pre-setting Machine._pendingExtras) was silently clobbering the
    pre-set extras. Only overwrite when the caller explicitly passes them.
  * Push port 0 deltas (notifyOutputChanged) after prediction updates so
    dashboards see state + predicted-flow changes as they happen.
- pressure/pressureRouter.js: routing + fallback hardening (the trigger
  for the bep-distance-cascade reproduction).
- display/workingCurves.js: Q-H curve generator extended.
- New tests:
  * test/integration/qh-curve.integration.test.js — Q-H curve shape
  * test/integration/bep-distance-cascade.integration.test.js — reproduces
    the dashboard report (absDistFromPeak=0, NCog=0, efficiency=0 after a
    setpoint move) at the unit level so future regressions fail loudly.

Full suite: 214/214 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:52:24 +02:00

151 lines
5.9 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
function makePressurizedOperationalMachine() {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
return machine;
}
test('calcCog returns valid peak efficiency and index', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.calcCog();
assert.ok(Number.isFinite(result.cog), 'cog should be finite');
assert.ok(result.cog > 0, 'peak efficiency should be positive');
assert.ok(Number.isFinite(result.cogIndex), 'cogIndex should be finite');
assert.ok(result.cogIndex >= 0, 'cogIndex should be non-negative');
assert.ok(Number.isFinite(result.NCog), 'NCog should be finite');
assert.ok(result.NCog >= 0 && result.NCog <= 1, 'NCog should be between 0 and 1');
assert.ok(Number.isFinite(result.minEfficiency), 'minEfficiency should be finite');
assert.ok(result.minEfficiency >= 0, 'minEfficiency should be non-negative');
});
test('calcCog peak is always >= minEfficiency', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.calcCog();
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
});
test('calcEfficiencyCurve produces hydraulic efficiency η = (Q·ΔP)/P at every point', () => {
const machine = makePressurizedOperationalMachine();
const { powerCurve, flowCurve } = machine.getCurrentCurves();
const dP = machine.predictFlow.currentF; // canonical Pa
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve, dP);
assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
// η = (Q·ΔP)/P. flow and power are in canonical SI (m³/s and W), so η is
// a dimensionless 0..1 ratio. dP is the pressure differential the slice
// represents (host.predictFlow.currentF).
for (let i = 0; i < efficiencyCurve.length; i++) {
const power = powerCurve.y[i];
const flow = flowCurve.y[i];
if (power > 0 && flow >= 0 && dP > 0) {
const expected = (flow * dP) / power;
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}: got ${efficiencyCurve[i]}, expected ${expected}`);
}
}
// Peak should be the max
const actualMax = Math.max(...efficiencyCurve);
assert.equal(peak, actualMax, 'Peak should match max of efficiency curve');
assert.equal(efficiencyCurve[peakIndex], peak, 'peakIndex should point to peak value');
assert.equal(minEfficiency, Math.min(...efficiencyCurve), 'minEfficiency should match min');
});
test('calcEfficiencyCurve handles empty curves gracefully', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const result = machine.calcEfficiencyCurve({ x: [], y: [] }, { x: [], y: [] });
assert.deepEqual(result.efficiencyCurve, []);
assert.equal(result.peak, 0);
assert.equal(result.peakIndex, 0);
assert.equal(result.minEfficiency, 0);
});
test('calcDistanceBEP returns absolute and relative distances', () => {
const machine = makePressurizedOperationalMachine();
const efficiency = 5;
const maxEfficiency = 10;
const minEfficiency = 2;
const result = machine.calcDistanceBEP(efficiency, maxEfficiency, minEfficiency);
assert.ok(Number.isFinite(result.absDistFromPeak), 'abs distance should be finite');
assert.equal(result.absDistFromPeak, Math.abs(efficiency - maxEfficiency));
assert.ok(Number.isFinite(result.relDistFromPeak), 'rel distance should be finite');
});
test('calcRelativeDistanceFromPeak returns 1 when maxEfficiency equals minEfficiency', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.calcRelativeDistanceFromPeak(5, 5, 5);
assert.equal(result, 1, 'Should return default distance when max==min (division by zero guard)');
});
test('showCoG returns structured data with curve guards', () => {
const machine = makePressurizedOperationalMachine();
const result = machine.showCoG();
assert.ok('cog' in result);
assert.ok('cogIndex' in result);
assert.ok('NCog' in result);
assert.ok('NCogPercent' in result);
assert.ok('minEfficiency' in result);
assert.ok('currentEfficiencyCurve' in result);
assert.ok(result.cog > 0);
assert.equal(result.NCogPercent, Math.round(result.NCog * 100 * 100) / 100);
});
test('showCoG returns safe fallback when no curve is available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const result = machine.showCoG();
assert.equal(result.cog, 0);
assert.ok('error' in result);
});
test('showWorkingCurves returns safe fallback when no curve is available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const result = machine.showWorkingCurves();
assert.ok('error' in result);
});
test('efficiency output fields are present in getOutput', () => {
const machine = makePressurizedOperationalMachine();
// Move to a position so predictions produce values
machine.state.transitionToState('operational');
machine.updatePosition();
const output = machine.getOutput();
assert.ok('cog' in output);
assert.ok('NCog' in output);
assert.ok('NCogPercent' in output);
assert.ok('effDistFromPeak' in output);
assert.ok('effRelDistFromPeak' in output);
assert.ok('predictionQuality' in output);
assert.ok('predictionConfidence' in output);
assert.ok('predictionPressureSource' in output);
});