diff --git a/rotatingMachine.js b/rotatingMachine.js index 0f1abf4..21cb5e8 100644 --- a/rotatingMachine.js +++ b/rotatingMachine.js @@ -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= + // 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); + }); }; \ No newline at end of file diff --git a/src/display/workingCurves.js b/src/display/workingCurves.js index 8141146..d4631bf 100644 --- a/src/display/workingCurves.js +++ b/src/display/workingCurves.js @@ -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 }; diff --git a/src/prediction/efficiencyMath.js b/src/prediction/efficiencyMath.js index 1aa7549..4d0478a 100644 --- a/src/prediction/efficiencyMath.js +++ b/src/prediction/efficiencyMath.js @@ -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'); diff --git a/src/pressure/pressureRouter.js b/src/pressure/pressureRouter.js index cca7300..e27fed9 100644 --- a/src/pressure/pressureRouter.js +++ b/src/pressure/pressureRouter.js @@ -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 → drift → health) + * 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) { - if (typeof this.updatePosition === 'function') this.updatePosition(); - if (typeof this.refreshDrift === 'function') this.refreshDrift(); - if (typeof this.refreshHealth === 'function') this.refreshHealth(); - } - + // 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') { - const p = this.getPressure(); + 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(); return true; } diff --git a/src/specificClass.js b/src/specificClass.js index 88c31a7..0790122 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -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); } diff --git a/test/basic/pressureRouter.basic.test.js b/test/basic/pressureRouter.basic.test.js index f76e123..61d1bd9 100644 --- a/test/basic/pressureRouter.basic.test.js +++ b/test/basic/pressureRouter.basic.test.js @@ -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 = []; diff --git a/test/integration/bep-distance-cascade.integration.test.js b/test/integration/bep-distance-cascade.integration.test.js new file mode 100644 index 0000000..017afe3 --- /dev/null +++ b/test/integration/bep-distance-cascade.integration.test.js @@ -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}`); +}); diff --git a/test/integration/efficiency-cog.integration.test.js b/test/integration/efficiency-cog.integration.test.js index 1735e79..2d74af2 100644 --- a/test/integration/efficiency-cog.integration.test.js +++ b/test/integration/efficiency-cog.integration.test.js @@ -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}`); } } diff --git a/test/integration/qh-curve.integration.test.js b/test/integration/qh-curve.integration.test.js new file mode 100644 index 0000000..3a90122 --- /dev/null +++ b/test/integration/qh-curve.integration.test.js @@ -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}`); +});