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}`, ); }); }