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>
This commit is contained in:
@@ -35,42 +35,63 @@ test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => {
|
||||
assert.equal(meas.writes[0].u, 'mbar');
|
||||
});
|
||||
|
||||
test('virtual source: refresh hooks NOT called', () => {
|
||||
test('virtual source: full cascade still runs (dashboard-sim must update predictions)', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
let pressCalled = 0, posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
getPressure: () => { pressCalled++; return 100; },
|
||||
updatePosition: () => { posCalled++; },
|
||||
refreshDrift: () => { driftCalled++; },
|
||||
refreshHealth: () => { healthCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
|
||||
assert.equal(posCalled, 0);
|
||||
assert.equal(driftCalled, 0);
|
||||
assert.equal(healthCalled, 0);
|
||||
assert.equal(pressCalled, 1);
|
||||
assert.equal(posCalled, 1);
|
||||
assert.equal(driftCalled, 1);
|
||||
assert.equal(healthCalled, 1);
|
||||
});
|
||||
|
||||
test('real source: all refresh hooks called', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
let pressCalled = 0, posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
getPressure: () => { pressCalled++; return 100; },
|
||||
updatePosition: () => { posCalled++; },
|
||||
refreshDrift: () => { driftCalled++; },
|
||||
refreshHealth: () => { healthCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
|
||||
assert.equal(pressCalled, 1);
|
||||
assert.equal(posCalled, 1);
|
||||
assert.equal(driftCalled, 1);
|
||||
assert.equal(healthCalled, 1);
|
||||
});
|
||||
|
||||
test('cascade order: getPressure runs before updatePosition (fDimension must be fresh when calcFlowPower runs)', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const calls = [];
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
getPressure: () => { calls.push('getPressure'); return 100; },
|
||||
updatePosition: () => { calls.push('updatePosition'); },
|
||||
refreshDrift: () => { calls.push('refreshDrift'); },
|
||||
refreshHealth: () => { calls.push('refreshHealth'); },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
|
||||
assert.deepEqual(calls, ['getPressure', 'updatePosition', 'refreshDrift', 'refreshHealth']);
|
||||
});
|
||||
|
||||
test('rejected unit returns false and skips the write', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const warns = [];
|
||||
|
||||
92
test/integration/bep-distance-cascade.integration.test.js
Normal file
92
test/integration/bep-distance-cascade.integration.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
/**
|
||||
* Reproduction harness for the dashboard report: after the pressure-router
|
||||
* fix, the user sees absDistFromPeak=0, NCog=0, efficiency=0, predicted
|
||||
* atEquipment flow blank, even after the machine is running and pressure
|
||||
* sliders are being moved.
|
||||
*
|
||||
* This test mirrors the actual dashboard interaction:
|
||||
* 1. start the machine (reach operational at ctrl=0)
|
||||
* 2. set virtual pressure (dashboard slider equivalent)
|
||||
* 3. move setpoint to non-zero ctrl
|
||||
* 4. read the host fields + measurement values
|
||||
*
|
||||
* Every value should be non-zero after step 3. If anything is 0 here, the
|
||||
* failure is reproducible at the unit level and we can patch it directly.
|
||||
*/
|
||||
|
||||
async function makeRunningMachine() {
|
||||
const cfg = makeMachineConfig({
|
||||
general: { id: 'rm-bep', name: 'BEP-test', unit: 'm3/h', logging: { enabled: false, logLevel: 'error' } },
|
||||
asset: {
|
||||
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal',
|
||||
model: 'hidrostal-H05K-S03R', unit: 'm3/h',
|
||||
curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
|
||||
},
|
||||
});
|
||||
const m = new Machine(cfg, makeStateConfig());
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(m.state.getCurrentState(), 'operational');
|
||||
return m;
|
||||
}
|
||||
|
||||
test('after startup + pressure + ctrl move: NCog / efficiency / absDistFromPeak / flow-at-equipment are all non-zero', async () => {
|
||||
const m = await makeRunningMachine();
|
||||
|
||||
// Dashboard slider equivalent — fire as virtual children (this is what
|
||||
// simulateMeasurement does):
|
||||
m.updateSimulatedMeasurement('pressure', 'upstream', 200, { unit: 'mbar' });
|
||||
m.updateSimulatedMeasurement('pressure', 'downstream', 1100, { unit: 'mbar' });
|
||||
|
||||
// Move to a non-zero ctrl position.
|
||||
await m.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
// Read every metric the user reports as 0.
|
||||
const flowDn = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h');
|
||||
const flowAtEq = m.measurements.type('flow').variant('predicted').position('atEquipment').getCurrentValue('m3/h');
|
||||
const powerAtEq = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW');
|
||||
const efficiency = m.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
state: m.state.getCurrentState(),
|
||||
ctrl: m.state.getCurrentPosition(),
|
||||
flowDn, flowAtEq, powerAtEq, efficiency,
|
||||
NCog: m.NCog, cog: m.cog, cogIndex: m.cogIndex,
|
||||
absDistFromPeak: m.absDistFromPeak, relDistFromPeak: m.relDistFromPeak,
|
||||
minEfficiency: m.minEfficiency,
|
||||
}, null, 2));
|
||||
|
||||
assert.ok(Number.isFinite(flowDn) && flowDn > 0, `flow downstream should be > 0, got ${flowDn}`);
|
||||
assert.ok(Number.isFinite(flowAtEq) && flowAtEq > 0, `flow at-equipment should be > 0, got ${flowAtEq}`);
|
||||
assert.ok(Number.isFinite(powerAtEq) && powerAtEq > 0, `power at-equipment should be > 0, got ${powerAtEq}`);
|
||||
// Hydraulic efficiency η = (Q·ΔP)/P is a dimensionless 0..1 ratio. For
|
||||
// a reasonable pump operating point it should be at least a few percent.
|
||||
assert.ok(Number.isFinite(efficiency) && efficiency > 0.01,
|
||||
`efficiency should be a meaningful 0..1 ratio (>1%), got ${efficiency}`);
|
||||
assert.ok(efficiency <= 1.0,
|
||||
`efficiency must be <= 1 (dimensionless ratio), got ${efficiency}`);
|
||||
// Peak efficiency (cog) likewise should be a meaningful ratio.
|
||||
assert.ok(Number.isFinite(m.cog) && m.cog > 0.01 && m.cog <= 1.0,
|
||||
`cog (peak efficiency) should be a meaningful 0..1 ratio, got ${m.cog}`);
|
||||
// NCog is the normalized flow at peak — depending on the curve, BEP can
|
||||
// land at peakIndex=0 (yielding NCog=0). Just require finiteness here.
|
||||
assert.ok(Number.isFinite(m.NCog) && m.NCog >= 0 && m.NCog <= 1,
|
||||
`NCog should be finite 0..1, got ${m.NCog}`);
|
||||
// Distance-from-peak is what the user actually reads. It should be finite
|
||||
// and at non-BEP positions it should be > 0.
|
||||
assert.ok(Number.isFinite(m.absDistFromPeak) && m.absDistFromPeak >= 0,
|
||||
`absDistFromPeak should be finite >= 0, got ${m.absDistFromPeak}`);
|
||||
assert.ok(Number.isFinite(m.relDistFromPeak) && m.relDistFromPeak >= 0 && m.relDistFromPeak <= 1,
|
||||
`relDistFromPeak should be finite 0..1, got ${m.relDistFromPeak}`);
|
||||
// At ctrl=50 the current efficiency must differ from peak (we're off BEP),
|
||||
// so absDistFromPeak should be non-zero.
|
||||
assert.ok(m.absDistFromPeak > 0,
|
||||
`absDistFromPeak must be > 0 when off BEP, got ${m.absDistFromPeak}`);
|
||||
});
|
||||
@@ -33,22 +33,25 @@ test('calcCog peak is always >= minEfficiency', () => {
|
||||
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
|
||||
});
|
||||
|
||||
test('calcEfficiencyCurve produces correct specific flow ratio', () => {
|
||||
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);
|
||||
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');
|
||||
|
||||
// Verify each point: efficiency = flow / power (unrounded, canonical units)
|
||||
// η = (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) {
|
||||
const expected = flow / power;
|
||||
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
test/integration/qh-curve.integration.test.js
Normal file
76
test/integration/qh-curve.integration.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { buildQHCurve } = require('../../src/display/workingCurves');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
async function makeRunningMachine() {
|
||||
const cfg = makeMachineConfig({
|
||||
general: { id: 'rm-qh', name: 'qh-test', unit: 'm3/h', logging: { enabled: false, logLevel: 'error' } },
|
||||
asset: {
|
||||
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal',
|
||||
model: 'hidrostal-H05K-S03R', unit: 'm3/h',
|
||||
curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
|
||||
},
|
||||
});
|
||||
const m = new Machine(cfg, makeStateConfig());
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
m.updateMeasuredPressure(0, 'upstream', { unit: 'mbar', timestamp: Date.now(), childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(1500, 'downstream', { unit: 'mbar', timestamp: Date.now(), childName: 'pt-down' });
|
||||
await m.handleInput('parent', 'execMovement', 60);
|
||||
return m;
|
||||
}
|
||||
|
||||
test('buildQHCurve returns one (Q, H) point per pressure slice in envelope', async () => {
|
||||
const m = await makeRunningMachine();
|
||||
const r = buildQHCurve(m, 60);
|
||||
assert.ok(!r.error, `should not error, got ${r.error}`);
|
||||
assert.ok(Array.isArray(r.points) && r.points.length > 0, 'must return points array');
|
||||
for (const pt of r.points) {
|
||||
assert.ok(Number.isFinite(pt.Q), `Q must be finite, got ${pt.Q}`);
|
||||
assert.ok(Number.isFinite(pt.H), `H must be finite, got ${pt.H}`);
|
||||
assert.ok(pt.Q > 0, `Q must be > 0, got ${pt.Q}`);
|
||||
assert.ok(pt.H > 0, `H must be > 0, got ${pt.H}`);
|
||||
}
|
||||
// Centrifugal pump: as head rises (higher pressure slice), flow drops.
|
||||
// Verify monotone non-increasing Q across rising H.
|
||||
const sortedByH = [...r.points].sort((a, b) => a.H - b.H);
|
||||
for (let i = 1; i < sortedByH.length; i++) {
|
||||
assert.ok(
|
||||
sortedByH[i].Q <= sortedByH[i - 1].Q * 1.01 + 1e-6,
|
||||
`flow should be non-increasing as head rises: ${JSON.stringify(sortedByH)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildQHCurve does not mutate predictor state', async () => {
|
||||
const m = await makeRunningMachine();
|
||||
const beforeF = m.predictFlow.fDimension;
|
||||
const beforeX = m.predictFlow.currentX;
|
||||
const beforeOutputY = m.predictFlow.outputY;
|
||||
|
||||
buildQHCurve(m, 60);
|
||||
|
||||
assert.equal(m.predictFlow.fDimension, beforeF, 'fDimension must be restored');
|
||||
assert.equal(m.predictFlow.currentX, beforeX, 'currentX must be restored');
|
||||
assert.ok(
|
||||
Math.abs(m.predictFlow.outputY - beforeOutputY) < 1e-9,
|
||||
`outputY must be restored, before=${beforeOutputY} after=${m.predictFlow.outputY}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildQHCurve handles no-curve gracefully', () => {
|
||||
const r = buildQHCurve({ hasCurve: false }, 50);
|
||||
assert.ok(r.error, 'must report error');
|
||||
assert.deepEqual(r.points, []);
|
||||
});
|
||||
|
||||
test('buildQHCurve uses current ctrl when none provided', async () => {
|
||||
const m = await makeRunningMachine();
|
||||
const r = buildQHCurve(m);
|
||||
assert.equal(r.ctrlPct, m.predictFlow.currentX,
|
||||
`ctrlPct should default to current x, got ${r.ctrlPct} vs ${m.predictFlow.currentX}`);
|
||||
});
|
||||
Reference in New Issue
Block a user