Compare commits
1 Commits
28344c6810
...
394a972d10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394a972d10 |
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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