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:
@@ -1,6 +1,7 @@
|
|||||||
const nameOfNode = 'rotatingMachine';
|
const nameOfNode = 'rotatingMachine';
|
||||||
const nodeClass = require('./src/nodeClass.js');
|
const nodeClass = require('./src/nodeClass.js');
|
||||||
const { MenuManager, configManager } = require('generalFunctions');
|
const { MenuManager, configManager } = require('generalFunctions');
|
||||||
|
const { buildQHCurve } = require('./src/display/workingCurves');
|
||||||
|
|
||||||
module.exports = function(RED) {
|
module.exports = function(RED) {
|
||||||
// 1) Register the node type and delegate to your class
|
// 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}`);
|
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,
|
* container and update the legacy fields (cog, NCog, currentEfficiencyCurve,
|
||||||
* absDistFromPeak, relDistFromPeak) on it in place — matching the
|
* absDistFromPeak, relDistFromPeak) on it in place — matching the
|
||||||
* pre-refactor surface tests assert on.
|
* 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');
|
const { gravity, coolprop } = require('generalFunctions');
|
||||||
|
|
||||||
function calcEfficiencyCurve(powerCurve, flowCurve) {
|
function calcEfficiencyCurve(powerCurve, flowCurve, pressureDiffPa) {
|
||||||
const efficiencyCurve = [];
|
const efficiencyCurve = [];
|
||||||
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
|
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
|
||||||
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
|
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
|
||||||
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
|
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
|
||||||
}
|
}
|
||||||
|
const dP = Number.isFinite(pressureDiffPa) && pressureDiffPa > 0 ? pressureDiffPa : 0;
|
||||||
powerCurve.y.forEach((power, i) => {
|
powerCurve.y.forEach((power, i) => {
|
||||||
const flow = flowCurve.y[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);
|
efficiencyCurve.push(eff);
|
||||||
if (eff > peak) { peak = eff; peakIndex = i; }
|
if (eff > peak) { peak = eff; peakIndex = i; }
|
||||||
if (eff < minEfficiency) minEfficiency = eff;
|
if (eff < minEfficiency) minEfficiency = eff;
|
||||||
@@ -31,10 +44,11 @@ function calcCog(host) {
|
|||||||
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
|
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
|
||||||
}
|
}
|
||||||
const { powerCurve, flowCurve } = getCurrentCurves(host);
|
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 yMin = host.predictFlow.currentFxyYMin;
|
||||||
const yMax = host.predictFlow.currentFxyYMax;
|
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.currentEfficiencyCurve = efficiencyCurve;
|
||||||
host.cog = peak;
|
host.cog = peak;
|
||||||
host.cogIndex = peakIndex;
|
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 flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
|
||||||
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
|
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}`);
|
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
|
||||||
|
|
||||||
if (power > 0 && flow > 0) {
|
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);
|
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
|
||||||
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
|
if (Number.isFinite(diffPa) && diffPa > 0 && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
|
||||||
const diffPa = Number(pressureDiff.value);
|
|
||||||
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
|
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
|
||||||
const hydraulicPowerW = diffPa * flowM3s;
|
const hydraulicPowerW = diffPa * flowM3s;
|
||||||
if (Number.isFinite(head)) host.measurements.type('pumpHead').variant(variant).position('atEquipment').value(head, Date.now(), 'm');
|
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
|
* PressureRouter — routes a measured pressure value into the right
|
||||||
* MeasurementContainer slot and triggers downstream side-effects
|
* MeasurementContainer slot and triggers the downstream cascade
|
||||||
* (position recompute + drift/health refresh) only when the source
|
* (preferred-pressure resolve → predicted recompute → drift → health)
|
||||||
* is a real child (not a dashboard-sim virtual one).
|
* 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 {
|
class PressureRouter {
|
||||||
/**
|
/**
|
||||||
* @param {object} ctx
|
* @param {object} ctx
|
||||||
* - measurements: MeasurementContainer
|
* - measurements: MeasurementContainer
|
||||||
* - virtualPressureChildIds: { upstream, downstream }
|
* - virtualPressureChildIds: { upstream, downstream } (kept for debug only)
|
||||||
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
|
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
|
||||||
* - updatePosition?(): called after a real-source write
|
* - getPressure?(): resolves preferred pressure and pushes fDimension to predictors
|
||||||
* - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus)
|
* - updatePosition?(): recomputes predicted flow/power/efficiency/CoG at current ctrl
|
||||||
* - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth)
|
* - refreshDrift?(): refreshes pressure drift status
|
||||||
* - getPressure?(): optional, returns the current preferred pressure (for logging)
|
* - refreshHealth?(): refreshes prediction-health status
|
||||||
* - logger
|
* - logger
|
||||||
*/
|
*/
|
||||||
constructor(ctx = {}) {
|
constructor(ctx = {}) {
|
||||||
this.measurements = ctx.measurements;
|
this.measurements = ctx.measurements;
|
||||||
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
|
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
|
||||||
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
|
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
|
||||||
|
this.getPressure = ctx.getPressure;
|
||||||
this.updatePosition = ctx.updatePosition;
|
this.updatePosition = ctx.updatePosition;
|
||||||
this.refreshDrift = ctx.refreshDrift;
|
this.refreshDrift = ctx.refreshDrift;
|
||||||
this.refreshHealth = ctx.refreshHealth;
|
this.refreshHealth = ctx.refreshHealth;
|
||||||
this.getPressure = ctx.getPressure;
|
|
||||||
this.logger = ctx.logger || { warn() {}, debug() {} };
|
this.logger = ctx.logger || { warn() {}, debug() {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,16 +65,19 @@ class PressureRouter {
|
|||||||
const isVirtual = this._isVirtual(childId);
|
const isVirtual = this._isVirtual(childId);
|
||||||
this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`);
|
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
|
||||||
if (typeof this.updatePosition === 'function') this.updatePosition();
|
// predictors) BEFORE recomputing predicted flow/power at the current
|
||||||
if (typeof this.refreshDrift === 'function') this.refreshDrift();
|
// control position. Skipping any of these on virtual sources broke
|
||||||
if (typeof this.refreshHealth === 'function') this.refreshHealth();
|
// 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') {
|
if (typeof this.getPressure === 'function') {
|
||||||
const p = this.getPressure();
|
p = this.getPressure();
|
||||||
this.logger.debug(`Using pressure: ${p} for calculations`);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,24 @@ class Machine extends BaseDomain {
|
|||||||
// ES6 forbids `this` before super(). Single-threaded JS means stashing
|
// ES6 forbids `this` before super(). Single-threaded JS means stashing
|
||||||
// on the class itself between the caller's args and super() is race-free;
|
// on the class itself between the caller's args and super() is race-free;
|
||||||
// configure() picks the extras up immediately after.
|
// 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);
|
super(machineConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +88,7 @@ class Machine extends BaseDomain {
|
|||||||
// If the registry has no entry for this model, assetMetadata is null and
|
// If the registry has no entry for this model, assetMetadata is null and
|
||||||
// we'll error out with a clear message below.
|
// we'll error out with a clear message below.
|
||||||
this.assetMetadata = this.model
|
this.assetMetadata = this.model
|
||||||
? assetResolver.resolveAssetMetadata('machine', this.model)
|
? assetResolver.resolveAssetMetadata('rotatingmachine', this.model)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!this.model) {
|
if (!this.model) {
|
||||||
@@ -81,7 +97,7 @@ class Machine extends BaseDomain {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.assetMetadata) {
|
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();
|
this._installNullPredictors();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -291,6 +307,10 @@ class Machine extends BaseDomain {
|
|||||||
this.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), pu);
|
this.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), pu);
|
||||||
}
|
}
|
||||||
this._updatePredictionHealth();
|
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() {
|
updatePosition() {
|
||||||
@@ -302,6 +322,7 @@ class Machine extends BaseDomain {
|
|||||||
this.calcDistanceBEP(efficiency, cog, minEfficiency);
|
this.calcDistanceBEP(efficiency, cog, minEfficiency);
|
||||||
}
|
}
|
||||||
this._updatePredictionHealth();
|
this._updatePredictionHealth();
|
||||||
|
this.notifyOutputChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── mode + input dispatch ──────────────────────────────────────────
|
// ── mode + input dispatch ──────────────────────────────────────────
|
||||||
@@ -371,7 +392,8 @@ class Machine extends BaseDomain {
|
|||||||
const powerCurve = this.groupPredictPower.currentFxyCurve[this.groupPredictPower.currentF];
|
const powerCurve = this.groupPredictPower.currentFxyCurve[this.groupPredictPower.currentF];
|
||||||
const flowCurve = this.groupPredictFlow.currentFxyCurve[this.groupPredictFlow.currentF];
|
const flowCurve = this.groupPredictFlow.currentFxyCurve[this.groupPredictFlow.currentF];
|
||||||
if (!powerCurve?.y?.length || !flowCurve?.y?.length) return 0;
|
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 yMin = this.groupPredictFlow.currentFxyYMin;
|
||||||
const yMax = this.groupPredictFlow.currentFxyYMax;
|
const yMax = this.groupPredictFlow.currentFxyYMax;
|
||||||
if (yMax <= yMin) return 0;
|
if (yMax <= yMin) return 0;
|
||||||
@@ -381,7 +403,7 @@ class Machine extends BaseDomain {
|
|||||||
|
|
||||||
// ── efficiency math (delegates) ────────────────────────────────────
|
// ── efficiency math (delegates) ────────────────────────────────────
|
||||||
calcCog() { return eff.calcCog(this); }
|
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); }
|
calcEfficiency(power, flow, variant) { return eff.calcEfficiency(this, power, flow, variant); }
|
||||||
calcDistanceBEP(e, max, min) { return eff.calcDistanceBEP(this, e, max, min); }
|
calcDistanceBEP(e, max, min) { return eff.calcDistanceBEP(this, e, max, min); }
|
||||||
calcDistanceFromPeak(e, peak) { return eff.calcDistanceFromPeak(e, peak); }
|
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');
|
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();
|
const meas = makeFakeMeasurements();
|
||||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
let pressCalled = 0, posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||||
const router = new PressureRouter({
|
const router = new PressureRouter({
|
||||||
measurements: meas,
|
measurements: meas,
|
||||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||||
resolveMeasurementUnit: () => 'mbar',
|
resolveMeasurementUnit: () => 'mbar',
|
||||||
|
getPressure: () => { pressCalled++; return 100; },
|
||||||
updatePosition: () => { posCalled++; },
|
updatePosition: () => { posCalled++; },
|
||||||
refreshDrift: () => { driftCalled++; },
|
refreshDrift: () => { driftCalled++; },
|
||||||
refreshHealth: () => { healthCalled++; },
|
refreshHealth: () => { healthCalled++; },
|
||||||
logger: SILENT,
|
logger: SILENT,
|
||||||
});
|
});
|
||||||
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
|
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
|
||||||
assert.equal(posCalled, 0);
|
assert.equal(pressCalled, 1);
|
||||||
assert.equal(driftCalled, 0);
|
assert.equal(posCalled, 1);
|
||||||
assert.equal(healthCalled, 0);
|
assert.equal(driftCalled, 1);
|
||||||
|
assert.equal(healthCalled, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('real source: all refresh hooks called', () => {
|
test('real source: all refresh hooks called', () => {
|
||||||
const meas = makeFakeMeasurements();
|
const meas = makeFakeMeasurements();
|
||||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
let pressCalled = 0, posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||||
const router = new PressureRouter({
|
const router = new PressureRouter({
|
||||||
measurements: meas,
|
measurements: meas,
|
||||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||||
resolveMeasurementUnit: () => 'mbar',
|
resolveMeasurementUnit: () => 'mbar',
|
||||||
|
getPressure: () => { pressCalled++; return 100; },
|
||||||
updatePosition: () => { posCalled++; },
|
updatePosition: () => { posCalled++; },
|
||||||
refreshDrift: () => { driftCalled++; },
|
refreshDrift: () => { driftCalled++; },
|
||||||
refreshHealth: () => { healthCalled++; },
|
refreshHealth: () => { healthCalled++; },
|
||||||
logger: SILENT,
|
logger: SILENT,
|
||||||
});
|
});
|
||||||
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
|
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
|
||||||
|
assert.equal(pressCalled, 1);
|
||||||
assert.equal(posCalled, 1);
|
assert.equal(posCalled, 1);
|
||||||
assert.equal(driftCalled, 1);
|
assert.equal(driftCalled, 1);
|
||||||
assert.equal(healthCalled, 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', () => {
|
test('rejected unit returns false and skips the write', () => {
|
||||||
const meas = makeFakeMeasurements();
|
const meas = makeFakeMeasurements();
|
||||||
const warns = [];
|
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');
|
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 machine = makePressurizedOperationalMachine();
|
||||||
const { powerCurve, flowCurve } = machine.getCurrentCurves();
|
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.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
|
||||||
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
|
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++) {
|
for (let i = 0; i < efficiencyCurve.length; i++) {
|
||||||
const power = powerCurve.y[i];
|
const power = powerCurve.y[i];
|
||||||
const flow = flowCurve.y[i];
|
const flow = flowCurve.y[i];
|
||||||
if (power > 0 && flow >= 0) {
|
if (power > 0 && flow >= 0 && dP > 0) {
|
||||||
const expected = flow / power;
|
const expected = (flow * dP) / power;
|
||||||
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
|
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