181 lines
8.9 KiB
JavaScript
181 lines
8.9 KiB
JavaScript
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
|
||
|
|
const Machine = require('../../src/specificClass');
|
||
|
|
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||
|
|
const { loadCurve } = require('generalFunctions');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prediction benchmarks across all rotatingMachine curves currently shipped
|
||
|
|
* with generalFunctions. This guards the curve-backed prediction path against
|
||
|
|
* regressions in the loader, the reverse-nq inversion, and the pressure
|
||
|
|
* slicing logic — across machines of very different sizes.
|
||
|
|
*
|
||
|
|
* Ranges are derived from the curve data itself (loaded at test time) plus
|
||
|
|
* physical sanity properties (monotonicity in ctrl, inverse-monotonicity in
|
||
|
|
* pressure for flow, non-negative power, curve-backed CoG non-zero).
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Curves the node is expected to support. Add new entries here as soon as a
|
||
|
|
// new curve file lands in generalFunctions/datasets/assetData/curves/.
|
||
|
|
const PUMP_CURVES = [
|
||
|
|
{ model: 'hidrostal-H05K-S03R', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||
|
|
{ model: 'hidrostal-C5-D03R-SHN1', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||
|
|
];
|
||
|
|
|
||
|
|
function curveExtents(curveData) {
|
||
|
|
const pressures = Object.keys(curveData.nq)
|
||
|
|
.filter((k) => /^-?\d+$/.test(k))
|
||
|
|
.map(Number)
|
||
|
|
.sort((a, b) => a - b);
|
||
|
|
const slice = (set, p) => curveData[set][String(p)];
|
||
|
|
const lowP = pressures[0];
|
||
|
|
const midP = pressures[Math.floor(pressures.length / 2)];
|
||
|
|
const highP = pressures[pressures.length - 1];
|
||
|
|
const allFlowY = pressures.flatMap((p) => slice('nq', p).y);
|
||
|
|
const allPowerY = pressures.flatMap((p) => slice('np', p).y);
|
||
|
|
return {
|
||
|
|
pressures,
|
||
|
|
lowP, midP, highP,
|
||
|
|
flowMin: Math.min(...allFlowY), flowMax: Math.max(...allFlowY),
|
||
|
|
powerMin: Math.min(...allPowerY), powerMax: Math.max(...allPowerY),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function makeRunningMachine({ model, unit }) {
|
||
|
|
const cfg = makeMachineConfig({
|
||
|
|
general: { id: `rm-${model}`, name: model, unit, logging: { enabled: false, logLevel: 'error' } },
|
||
|
|
asset: {
|
||
|
|
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal', model, unit,
|
||
|
|
curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const m = new Machine(cfg, makeStateConfig());
|
||
|
|
await m.handleInput('parent', 'execSequence', 'startup');
|
||
|
|
assert.equal(m.state.getCurrentState(), 'operational', `${model}: should reach operational`);
|
||
|
|
return m;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const curve of PUMP_CURVES) {
|
||
|
|
const { model, unit, pUnit, powUnit } = curve;
|
||
|
|
|
||
|
|
test(`[${model}] curve loads and has both nq and np slices`, () => {
|
||
|
|
const raw = loadCurve(model);
|
||
|
|
assert.ok(raw, `loadCurve('${model}') must return data`);
|
||
|
|
assert.ok(raw.nq && Object.keys(raw.nq).length > 0, `${model}: nq has pressure slices`);
|
||
|
|
assert.ok(raw.np && Object.keys(raw.np).length > 0, `${model}: np has pressure slices`);
|
||
|
|
// Same pressure slices in both
|
||
|
|
const nqP = Object.keys(raw.nq).filter((k) => /^-?\d+$/.test(k)).sort();
|
||
|
|
const npP = Object.keys(raw.np).filter((k) => /^-?\d+$/.test(k)).sort();
|
||
|
|
assert.deepEqual(nqP, npP, `${model}: nq and np must share pressure slices`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test(`[${model}] predicted flow and power at mid-pressure, mid-ctrl are finite and in-range`, async () => {
|
||
|
|
const raw = loadCurve(model);
|
||
|
|
const ext = curveExtents(raw);
|
||
|
|
const m = await makeRunningMachine(curve);
|
||
|
|
|
||
|
|
// Feed differential pressure = midP (upstream 0, downstream = midP)
|
||
|
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||
|
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||
|
|
|
||
|
|
await m.handleInput('parent', 'execMovement', 50);
|
||
|
|
|
||
|
|
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||
|
|
const power = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue(powUnit);
|
||
|
|
|
||
|
|
assert.ok(Number.isFinite(flow), `${model}: flow must be finite`);
|
||
|
|
assert.ok(Number.isFinite(power), `${model}: power must be finite`);
|
||
|
|
// Flow can be negative at the low-end slice of some curves due to spline extrapolation,
|
||
|
|
// but at mid-pressure mid-ctrl it must be positive.
|
||
|
|
assert.ok(flow > 0, `${model}: flow ${flow} ${unit} must be > 0 at mid-pressure mid-ctrl`);
|
||
|
|
assert.ok(power >= 0, `${model}: power ${power} ${powUnit} must be >= 0`);
|
||
|
|
// Loose bracket against curve envelope (2x margin accommodates interpolation overshoot)
|
||
|
|
assert.ok(flow <= ext.flowMax * 2, `${model}: flow ${flow} exceeds curve envelope ${ext.flowMax}`);
|
||
|
|
assert.ok(power <= ext.powerMax * 2, `${model}: power ${power} exceeds curve envelope ${ext.powerMax}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test(`[${model}] flow is monotonically non-decreasing in ctrl at fixed pressure`, async () => {
|
||
|
|
const raw = loadCurve(model);
|
||
|
|
const ext = curveExtents(raw);
|
||
|
|
const m = await makeRunningMachine(curve);
|
||
|
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||
|
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||
|
|
|
||
|
|
const samples = [];
|
||
|
|
for (const setpoint of [10, 30, 50, 70, 90]) {
|
||
|
|
await m.handleInput('parent', 'execMovement', setpoint);
|
||
|
|
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||
|
|
samples.push({ setpoint, flow });
|
||
|
|
}
|
||
|
|
|
||
|
|
for (let i = 1; i < samples.length; i++) {
|
||
|
|
// Allow 1% tolerance for spline wiggle but reject any clear regression.
|
||
|
|
assert.ok(
|
||
|
|
samples[i].flow >= samples[i - 1].flow - Math.abs(samples[i - 1].flow) * 0.01,
|
||
|
|
`${model}: flow not monotonic across ctrl sweep: ${JSON.stringify(samples)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test(`[${model}] flow decreases (or stays level) when pressure rises at fixed ctrl`, async () => {
|
||
|
|
const raw = loadCurve(model);
|
||
|
|
const ext = curveExtents(raw);
|
||
|
|
const m = await makeRunningMachine(curve);
|
||
|
|
|
||
|
|
const samples = [];
|
||
|
|
for (const p of [ext.lowP, ext.midP, ext.highP]) {
|
||
|
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||
|
|
m.updateMeasuredPressure(p, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||
|
|
await m.handleInput('parent', 'execMovement', 60);
|
||
|
|
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||
|
|
samples.push({ pressure: p, flow });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Highest pressure must not exceed lowest pressure flow by more than 1%.
|
||
|
|
// (Centrifugal pump: head up -> flow down at a given speed.)
|
||
|
|
const first = samples[0].flow;
|
||
|
|
const last = samples[samples.length - 1].flow;
|
||
|
|
assert.ok(
|
||
|
|
last <= first * 1.01,
|
||
|
|
`${model}: flow at p=${samples[samples.length - 1].pressure} (${last}) exceeds flow at p=${samples[0].pressure} (${first}); samples=${JSON.stringify(samples)}`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test(`[${model}] cog and NCog are computed and finite after an operational move`, async () => {
|
||
|
|
const raw = loadCurve(model);
|
||
|
|
const ext = curveExtents(raw);
|
||
|
|
const m = await makeRunningMachine(curve);
|
||
|
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||
|
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||
|
|
await m.handleInput('parent', 'execMovement', 50);
|
||
|
|
|
||
|
|
assert.ok(Number.isFinite(m.cog), `${model}: cog must be finite, got ${m.cog}`);
|
||
|
|
assert.ok(Number.isFinite(m.NCog), `${model}: NCog must be finite, got ${m.NCog}`);
|
||
|
|
// CoG is a controller-% location of peak efficiency; must fall inside the ctrl range of the curve.
|
||
|
|
assert.ok(m.cog >= 0 && m.cog <= 100, `${model}: cog=${m.cog} must be within [0,100]`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test(`[${model}] reverse predictor (ctrl for requested flow) round-trips within tolerance`, async () => {
|
||
|
|
const raw = loadCurve(model);
|
||
|
|
const ext = curveExtents(raw);
|
||
|
|
const m = await makeRunningMachine(curve);
|
||
|
|
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||
|
|
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||
|
|
|
||
|
|
// Move to a known controller position and read the flow.
|
||
|
|
await m.handleInput('parent', 'execMovement', 60);
|
||
|
|
const observedFlow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||
|
|
assert.ok(observedFlow > 0, `${model}: need non-zero flow to invert`);
|
||
|
|
|
||
|
|
// Convert flow back to ctrl via calcCtrl (uses reversed nq internally) —
|
||
|
|
// note calcCtrl takes canonical flow (m3/s), so convert.
|
||
|
|
const canonicalFlow = observedFlow / 3600; // m3/h -> m3/s
|
||
|
|
const predictedCtrl = m.calcCtrl(canonicalFlow);
|
||
|
|
assert.ok(
|
||
|
|
Number.isFinite(predictedCtrl) && Math.abs(predictedCtrl - 60) <= 10,
|
||
|
|
`${model}: reverse predictor ctrl=${predictedCtrl} should be within 10 of 60 for flow=${observedFlow}`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|