hydraulic efficiency η = (Q·ΔP)/P + asset registry rename
The pre-existing efficiency formula `η = flow/power` produced tiny SI-unit
values (m³/J ≈ 1e-5), was monotonic in ctrl for centrifugal-pump curves
(no interior peak), and made NCog collapse to 0 — which cascaded into MGC
reporting BEP-position 0.0% always. Replaced with hydraulic efficiency
η = (Q·ΔP)/P_shaft, the dimensionless 0..1 ratio that has a real BEP and
matches the form MGC's group-level math uses.
- prediction/efficiencyMath.js:
* calcEfficiencyCurve takes pressureDiffPa; η = 0 when dP missing
* calcCog guards (yMax > yMin) before computing NCog (was unguarded /0)
* calcEfficiency falls back to predictFlow.currentF when measured ΔP is
missing, so predicted-variant calls still produce a meaningful η before
the differential measurement settles
- specificClass.js:
* Asset-registry lookup renamed: 'machine' → 'rotatingmachine' (matches
the datasets/assetData/ rename in generalFunctions). The error path
quotes the new filename so operators can find it.
* Two-call-site fix: with default-param stateConfig={}, the single-arg
constructor path (BaseNodeAdapter calls `new Machine(this.config)`
after pre-setting Machine._pendingExtras) was silently clobbering the
pre-set extras. Only overwrite when the caller explicitly passes them.
* Push port 0 deltas (notifyOutputChanged) after prediction updates so
dashboards see state + predicted-flow changes as they happen.
- pressure/pressureRouter.js: routing + fallback hardening (the trigger
for the bep-distance-cascade reproduction).
- display/workingCurves.js: Q-H curve generator extended.
- New tests:
* test/integration/qh-curve.integration.test.js — Q-H curve shape
* test/integration/bep-distance-cascade.integration.test.js — reproduces
the dashboard report (absDistFromPeak=0, NCog=0, efficiency=0 after a
setpoint move) at the unit level so future regressions fail loudly.
Full suite: 214/214 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user