Compare commits

..

1 Commits

Author SHA1 Message Date
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
9 changed files with 371 additions and 44 deletions

View File

@@ -1,6 +1,7 @@
const nameOfNode = 'rotatingMachine';
const nodeClass = require('./src/nodeClass.js');
const { MenuManager, configManager } = require('generalFunctions');
const { buildQHCurve } = require('./src/display/workingCurves');
module.exports = function(RED) {
// 1) Register the node type and delegate to your class
@@ -32,4 +33,20 @@ module.exports = function(RED) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
// Q-H curve sampler — served on RED.httpNode (the dashboard/runtime
// router) so dashboard function nodes can fetch without admin auth.
// GET /rotatingMachine/:id/qh-curve?ctrl=<percent>
// Returns { ctrlPct, points: [{ Q (m³/h), H (m), dpPa }, ...] }
RED.httpNode.get(`/${nameOfNode}/:id/qh-curve`, (req, res) => {
const node = RED.nodes.getNode(req.params.id);
const source = node?.source;
if (!source) {
res.status(404).json({ error: `No rotatingMachine with id ${req.params.id}` });
return;
}
const ctrl = Number(req.query.ctrl);
const result = buildQHCurve(source, Number.isFinite(ctrl) ? ctrl : source.state?.getCurrentPosition?.() ?? 0);
res.json(result);
});
};

View File

@@ -58,4 +58,58 @@ function showWorkingCurves(predictors) {
};
}
module.exports = { showWorkingCurves, showCoG };
/**
* Build a Q-H curve sample at a fixed control position.
*
* For each pressure slice the predictor knows about, evaluate predicted
* flow at `ctrlPct`, convert canonical Pa to pump head (m of water column,
* H = ΔP / (ρ · g)), and emit one (Q, H) point. Result is the pump's Q-H
* curve at the requested speed/control.
*
* State handling: temporarily writes fDimension to walk the slices, then
* restores the predictor's original fDimension and outputY by reissuing
* y(originalX) — so callers can hit this without corrupting live
* predictions. (Same trick as the existing benchmark scripts.)
*/
function buildQHCurve(predictors, ctrlPct, options = {}) {
if (!predictors || !predictors.hasCurve || !predictors.predictFlow) {
return { error: NO_CURVE_ERROR, points: [] };
}
const pf = predictors.predictFlow;
if (!pf.inputCurve || typeof pf.inputCurve !== 'object') {
return { error: NO_CURVE_ERROR, points: [] };
}
const x = Number.isFinite(+ctrlPct) ? +ctrlPct : (pf.currentX ?? 0);
const RHO = 999.1; // kg/m³ — water at ~15 °C
const G = 9.80665; // m/s²
// Allowed pressure range from the predict library; falls back to the
// raw inputCurve keys if fValues isn't populated yet.
const fMin = Number.isFinite(pf.fValues?.min) ? pf.fValues.min : -Infinity;
const fMax = Number.isFinite(pf.fValues?.max) ? pf.fValues.max : Infinity;
const pressures = Object.keys(pf.inputCurve)
.filter((k) => /^-?\d+(?:\.\d+)?$/.test(k))
.map(Number)
.filter((p) => p >= fMin && p <= fMax)
.sort((a, b) => a - b);
if (!pressures.length) {
return { error: 'No pressure slices in envelope', points: [] };
}
const originalF = pf.fDimension;
const originalX = pf.currentX;
const points = [];
try {
for (const p of pressures) {
pf.fDimension = p;
const QM3s = pf.y(x);
points.push({ Q: QM3s * 3600, H: p / (RHO * G), dpPa: p });
}
} finally {
pf.fDimension = originalF;
if (Number.isFinite(originalX)) pf.y(originalX);
}
return { ctrlPct: x, points };
}
module.exports = { showWorkingCurves, showCoG, buildQHCurve };

View File

@@ -5,19 +5,32 @@
* container and update the legacy fields (cog, NCog, currentEfficiencyCurve,
* absDistFromPeak, relDistFromPeak) on it in place — matching the
* pre-refactor surface tests assert on.
*
* Efficiency definition: hydraulic efficiency η = (Q · ΔP) / P_shaft —
* a dimensionless 0..1 ratio. The legacy pre-refactor implementation
* stored `flow/power` in canonical SI (m³/J), which (a) yields tiny
* numeric values that dashboards round to 0.0000 and (b) is monotonic
* in ctrl for centrifugal-pump curves so it has no interior peak — so
* NCog collapses to 0 and absDistFromPeak becomes meaningless. The
* hydraulic-efficiency form gives a real BEP (interior peak) and is
* directly comparable to nameplate efficiency. ΔP comes from the
* predictor's `currentF` (canonical Pa) because each fDimension slice
* IS the curve at that pressure differential.
*/
const { gravity, coolprop } = require('generalFunctions');
function calcEfficiencyCurve(powerCurve, flowCurve) {
function calcEfficiencyCurve(powerCurve, flowCurve, pressureDiffPa) {
const efficiencyCurve = [];
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
}
const dP = Number.isFinite(pressureDiffPa) && pressureDiffPa > 0 ? pressureDiffPa : 0;
powerCurve.y.forEach((power, i) => {
const flow = flowCurve.y[i];
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
// η = (Q · ΔP) / P. Falls back to 0 when any factor is missing.
const eff = (power > 0 && flow >= 0 && dP > 0) ? (flow * dP) / power : 0;
efficiencyCurve.push(eff);
if (eff > peak) { peak = eff; peakIndex = i; }
if (eff < minEfficiency) minEfficiency = eff;
@@ -31,10 +44,11 @@ function calcCog(host) {
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
}
const { powerCurve, flowCurve } = getCurrentCurves(host);
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve);
const dP = host.predictFlow.currentF;
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve, dP);
const yMin = host.predictFlow.currentFxyYMin;
const yMax = host.predictFlow.currentFxyYMax;
const NCog = (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
const NCog = (yMax > yMin) ? (flowCurve.y[peakIndex] - yMin) / (yMax - yMin) : 0;
host.currentEfficiencyCurve = efficiencyCurve;
host.cog = peak;
host.cogIndex = peakIndex;
@@ -86,14 +100,28 @@ function calcEfficiency(host, power, flow, variant) {
const flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
// Prefer the measured pressure differential; fall back to the predictor's
// current fDimension (the slice the prediction is being read from) so we
// still get a meaningful efficiency for predicted-variant calls when the
// measured differential isn't available yet.
let diffPa = pressureDiff?.value != null ? Number(pressureDiff.value) : null;
if (!Number.isFinite(diffPa) || diffPa <= 0) {
const fF = host.predictFlow?.currentF;
if (Number.isFinite(fF) && fF > 0) diffPa = fF;
}
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${diffPa || 0}`);
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
if (power > 0 && flow > 0) {
host.measurements.type('efficiency').variant(variant).position('atEquipment').value(flow / power);
// η_hydraulic = (Q · ΔP) / P_shaft, dimensionless 0..1. Stored as the
// primary `efficiency` so dashboards and BEP-distance math see a
// physically meaningful number instead of m³/J. `flow` and `power`
// here are canonical m³/s and W from the predictor.
if (Number.isFinite(diffPa) && diffPa > 0) {
host.measurements.type('efficiency').variant(variant).position('atEquipment').value((flow * diffPa) / power);
}
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
const diffPa = Number(pressureDiff.value);
if (Number.isFinite(diffPa) && diffPa > 0 && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
const hydraulicPowerW = diffPa * flowM3s;
if (Number.isFinite(head)) host.measurements.type('pumpHead').variant(variant).position('atEquipment').value(head, Date.now(), 'm');

View File

@@ -2,33 +2,44 @@
/**
* PressureRouter — routes a measured pressure value into the right
* MeasurementContainer slot and triggers downstream side-effects
* (position recompute + drift/health refresh) only when the source
* is a real child (not a dashboard-sim virtual one).
* MeasurementContainer slot and triggers the downstream cascade
* (preferred-pressure resolve → predicted recompute drifthealth)
* on every pressure write, matching the pre-refactor
* `updateMeasuredPressure` semantics.
*
* Extracted from rotatingMachine specificClass.updateMeasuredPressure.
* Why the cascade runs for virtual sources too: dashboard-sim pressure
* sliders route through virtual children, and the operator expects the
* predicted flow/power/efficiency/Cog to refresh on every slider tick.
* The cascade is idempotent — running it on a virtual write is cheap
* and matches what a real sensor would trigger.
*
* Why getPressure() runs first: getMeasuredPressure() writes the new
* pressure differential onto predictFlow/Power/Ctrl.fDimension. Only
* after that does updatePosition() compute flow/power via
* predictFlow.y(x) — otherwise calcFlowPower runs against a stale
* fDimension and the prediction lags one update behind the slider.
*/
class PressureRouter {
/**
* @param {object} ctx
* - measurements: MeasurementContainer
* - virtualPressureChildIds: { upstream, downstream }
* - virtualPressureChildIds: { upstream, downstream } (kept for debug only)
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
* - updatePosition?(): called after a real-source write
* - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus)
* - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth)
* - getPressure?(): optional, returns the current preferred pressure (for logging)
* - getPressure?(): resolves preferred pressure and pushes fDimension to predictors
* - updatePosition?(): recomputes predicted flow/power/efficiency/CoG at current ctrl
* - refreshDrift?(): refreshes pressure drift status
* - refreshHealth?(): refreshes prediction-health status
* - logger
*/
constructor(ctx = {}) {
this.measurements = ctx.measurements;
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
this.getPressure = ctx.getPressure;
this.updatePosition = ctx.updatePosition;
this.refreshDrift = ctx.refreshDrift;
this.refreshHealth = ctx.refreshHealth;
this.getPressure = ctx.getPressure;
this.logger = ctx.logger || { warn() {}, debug() {} };
}
@@ -54,16 +65,19 @@ class PressureRouter {
const isVirtual = this._isVirtual(childId);
this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`);
if (!isVirtual) {
// Legacy order: resolve preferred pressure (writes fDimension to
// predictors) BEFORE recomputing predicted flow/power at the current
// control position. Skipping any of these on virtual sources broke
// the dashboard-sim demo (NCog / efficiency / absDistFromPeak stuck
// at 0, predicted flow/power not updating with the pressure slider).
let p;
if (typeof this.getPressure === 'function') {
p = this.getPressure();
this.logger.debug(`Using pressure: ${p} for calculations`);
}
if (typeof this.updatePosition === 'function') this.updatePosition();
if (typeof this.refreshDrift === 'function') this.refreshDrift();
if (typeof this.refreshHealth === 'function') this.refreshHealth();
}
if (typeof this.getPressure === 'function') {
const p = this.getPressure();
this.logger.debug(`Using pressure: ${p} for calculations`);
}
return true;
}

View File

@@ -43,8 +43,24 @@ class Machine extends BaseDomain {
// ES6 forbids `this` before super(). Single-threaded JS means stashing
// on the class itself between the caller's args and super() is race-free;
// configure() picks the extras up immediately after.
constructor(machineConfig = {}, stateConfig = {}, errorMetricsConfig = {}) {
Machine._pendingExtras = { stateConfig, errorMetricsConfig };
//
// Two call sites exist:
// - nodeClass.buildDomainConfig() pre-sets Machine._pendingExtras and
// then BaseNodeAdapter calls `new Machine(this.config)` (single-arg).
// - Tests / direct callers pass (machineConfig, stateConfig, errMetrics)
// explicitly.
// With default-param `stateConfig={}`, the single-arg path was silently
// clobbering the pre-set extras with an empty object, so the state machine
// booted with schema defaults (warmingup=5s, speed=1%/s, mode=dynspeed)
// regardless of what the editor saved. Only overwrite when an explicit
// value is provided.
constructor(machineConfig = {}, stateConfig, errorMetricsConfig) {
if (stateConfig !== undefined || errorMetricsConfig !== undefined) {
Machine._pendingExtras = {
stateConfig: stateConfig ?? {},
errorMetricsConfig: errorMetricsConfig ?? {},
};
}
super(machineConfig);
}
@@ -72,7 +88,7 @@ class Machine extends BaseDomain {
// If the registry has no entry for this model, assetMetadata is null and
// we'll error out with a clear message below.
this.assetMetadata = this.model
? assetResolver.resolveAssetMetadata('machine', this.model)
? assetResolver.resolveAssetMetadata('rotatingmachine', this.model)
: null;
if (!this.model) {
@@ -81,7 +97,7 @@ class Machine extends BaseDomain {
return;
}
if (!this.assetMetadata) {
this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/machine.json). Cannot derive supplier/type/units.`);
this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/rotatingmachine.json). Cannot derive supplier/type/units.`);
this._installNullPredictors();
return;
}
@@ -291,6 +307,10 @@ class Machine extends BaseDomain {
this.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), pu);
}
this._updatePredictionHealth();
// Push port 0 deltas so downstream dashboards / probes see state +
// predicted-flow updates as they happen. BaseNodeAdapter listens for
// 'output-changed' on this.emitter to fire _emitOutputs().
this.notifyOutputChanged();
}
updatePosition() {
@@ -302,6 +322,7 @@ class Machine extends BaseDomain {
this.calcDistanceBEP(efficiency, cog, minEfficiency);
}
this._updatePredictionHealth();
this.notifyOutputChanged();
}
// ── mode + input dispatch ──────────────────────────────────────────
@@ -371,7 +392,8 @@ class Machine extends BaseDomain {
const powerCurve = this.groupPredictPower.currentFxyCurve[this.groupPredictPower.currentF];
const flowCurve = this.groupPredictFlow.currentFxyCurve[this.groupPredictFlow.currentF];
if (!powerCurve?.y?.length || !flowCurve?.y?.length) return 0;
const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve);
const dP = this.groupPredictFlow.currentF;
const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve, dP);
const yMin = this.groupPredictFlow.currentFxyYMin;
const yMax = this.groupPredictFlow.currentFxyYMax;
if (yMax <= yMin) return 0;
@@ -381,7 +403,7 @@ class Machine extends BaseDomain {
// ── efficiency math (delegates) ────────────────────────────────────
calcCog() { return eff.calcCog(this); }
calcEfficiencyCurve(p, f) { return eff.calcEfficiencyCurve(p, f); }
calcEfficiencyCurve(p, f, dP) { return eff.calcEfficiencyCurve(p, f, dP); }
calcEfficiency(power, flow, variant) { return eff.calcEfficiency(this, power, flow, variant); }
calcDistanceBEP(e, max, min) { return eff.calcDistanceBEP(this, e, max, min); }
calcDistanceFromPeak(e, peak) { return eff.calcDistanceFromPeak(e, peak); }

View File

@@ -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 = [];

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

View File

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

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