P5 wave 1: extract rotatingMachine concerns into focused modules
src/curves/ loader + normalizer (with cross-pressure anomaly
detection) + reverseCurve helper
src/prediction/ predictors (predictFlow/Power/Ctrl) +
groupPredictors (lazy group-scope views) +
OperatingPoint (pressure-driven prediction setpoints)
src/drift/ DriftAssessor (per-metric drift) + PredictionHealth
(composes flow/power/pressure into HealthStatus +
confidence sibling — see OPEN_QUESTIONS 2026-05-10)
src/pressure/ VirtualPressureChildren (dashboard-sim) +
PressureInitialization (real-vs-virtual tracking) +
PressureRouter (dispatches by position)
src/state/ stateBindings (state.emitter listener helper) +
isOperationalState
src/measurement/ measurementHandlers (dispatcher for flow/power/temp/pressure)
src/flow/ flowController (handleInput body — execSequence,
execMovement, flowMovement, emergencystop)
src/display/ workingCurves (showWorkingCurves + showCoG admin)
src/commands/ canonical names: set.mode, cmd.startup/shutdown/estop,
set.setpoint, set.flow-setpoint,
data.simulate-measurement, query.curves, query.cog,
child.register. execSequence demuxes by payload.action
to canonical cmd.* handlers.
CONTRACT.md inputs/outputs/events/children surface
110 basic tests pass (100 new + 10 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P5 wave 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
275
test/basic/commands.basic.test.js
Normal file
275
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,275 @@
|
||||
// Basic tests for the rotatingMachine commands registry.
|
||||
// Run with: node --test test/basic/commands.basic.test.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const commands = require('../../src/commands');
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(String(m)),
|
||||
error: (m) => calls.error.push(String(m)),
|
||||
info: (m) => calls.info.push(String(m)),
|
||||
debug: (m) => calls.debug.push(String(m)),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSource({ name = 'rm-1', unitValid = true } = {}) {
|
||||
const calls = {
|
||||
setMode: [],
|
||||
handleInput: [],
|
||||
registerChild: [],
|
||||
sim: [],
|
||||
updatePressure: [],
|
||||
updateFlow: [],
|
||||
updateTemp: [],
|
||||
updatePower: [],
|
||||
showWorkingCurves: 0,
|
||||
showCoG: 0,
|
||||
};
|
||||
const source = {
|
||||
logger: makeLogger(),
|
||||
config: { general: { name } },
|
||||
setMode: (m) => calls.setMode.push(m),
|
||||
handleInput: async (src, action, parameter) => {
|
||||
calls.handleInput.push({ src, action, parameter });
|
||||
},
|
||||
isUnitValidForType: () => unitValid,
|
||||
updateSimulatedMeasurement: (type, position, value, ctx) =>
|
||||
calls.sim.push({ type, position, value, ctx }),
|
||||
updateMeasuredPressure: (v, p, c) => calls.updatePressure.push({ v, p, c }),
|
||||
updateMeasuredFlow: (v, p, c) => calls.updateFlow.push({ v, p, c }),
|
||||
updateMeasuredTemperature: (v, p, c) => calls.updateTemp.push({ v, p, c }),
|
||||
updateMeasuredPower: (v, p, c) => calls.updatePower.push({ v, p, c }),
|
||||
showWorkingCurves: () => { calls.showWorkingCurves++; return { curves: 'mock' }; },
|
||||
showCoG: () => { calls.showCoG++; return { cog: 'mock' }; },
|
||||
childRegistrationUtils: {
|
||||
registerChild: (childSource, position) =>
|
||||
calls.registerChild.push({ childSource, position }),
|
||||
},
|
||||
};
|
||||
return { source, calls };
|
||||
}
|
||||
|
||||
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
|
||||
return {
|
||||
logger,
|
||||
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
|
||||
node: {},
|
||||
send: sendSpy || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(logger) {
|
||||
return createRegistry(commands, { logger });
|
||||
}
|
||||
|
||||
// --- tests -----------------------------------------------------------------
|
||||
|
||||
test('canonical topics dispatch to their handlers', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.mode', payload: 'GUI' }, source, makeCtx());
|
||||
assert.deepEqual(calls.setMode, ['GUI']);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'cmd.startup', payload: { source: 'GUI' } }, source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'startup' });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'cmd.shutdown', payload: { source: 'GUI' } }, source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'cmd.estop', payload: { source: 'GUI', action: 'emergencystop' } }, source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'emergencystop', parameter: undefined });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'set.setpoint', payload: { source: 'GUI', action: 'execMovement', setpoint: '75' } },
|
||||
source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execMovement', parameter: 75 });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'set.flow-setpoint', payload: { source: 'GUI', action: 'flowMovement', setpoint: '12' } },
|
||||
source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'flowMovement', parameter: 12 });
|
||||
});
|
||||
|
||||
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'GUI' }, source, makeCtx({ logger: ctxLogger }));
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'virtualControl' }, source, makeCtx({ logger: ctxLogger }));
|
||||
assert.deepEqual(calls.setMode, ['GUI', 'virtualControl']);
|
||||
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
|
||||
await reg.dispatch({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'emergencystop' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
|
||||
await reg.dispatch({ topic: 'execMovement', payload: { source: 'GUI', action: 'execMovement', setpoint: 50 } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'execMovement' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
|
||||
await reg.dispatch({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 5 } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'flowMovement' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
});
|
||||
|
||||
test('execSequence with payload.action=startup reaches cmd.startup handler', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'startup' });
|
||||
// Registry logs the legacy-topic deprecation (no canonical alias, but
|
||||
// the demux handler accepts both startup/shutdown actions).
|
||||
});
|
||||
|
||||
test('execSequence with payload.action=shutdown reaches cmd.shutdown handler', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'execSequence', payload: { source: 'GUI', action: 'shutdown' } },
|
||||
source, makeCtx());
|
||||
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
|
||||
});
|
||||
|
||||
test('execSequence with unknown action logs warn and does not call handleInput', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'execSequence', payload: { source: 'GUI', action: 'frobnicate' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('execSequence') && m.includes('frobnicate')),
|
||||
`expected warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`);
|
||||
});
|
||||
|
||||
test('data.simulate-measurement happy path dispatches to the right updater', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement',
|
||||
payload: { type: 'pressure', position: 'upstream', value: 1013, unit: 'mbar' } },
|
||||
source, makeCtx());
|
||||
assert.equal(calls.sim.length, 1);
|
||||
assert.equal(calls.sim[0].type, 'pressure');
|
||||
assert.equal(calls.sim[0].value, 1013);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement',
|
||||
payload: { type: 'flow', value: 30, unit: 'm3/h' } },
|
||||
source, makeCtx());
|
||||
assert.equal(calls.updateFlow.length, 1);
|
||||
});
|
||||
|
||||
test('data.simulate-measurement validation: bad type / missing unit / non-finite value', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
// unsupported type
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement', payload: { type: 'voltage', value: 1, unit: 'V' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('Unsupported simulateMeasurement type: voltage')));
|
||||
|
||||
// missing unit
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 1013 } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('unit is required')));
|
||||
|
||||
// non-finite value
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 'abc', unit: 'mbar' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('must be a finite number')));
|
||||
|
||||
// nothing was forwarded to the source
|
||||
assert.equal(calls.sim.length, 0);
|
||||
assert.equal(calls.updateFlow.length, 0);
|
||||
assert.equal(calls.updatePressure.length, 0);
|
||||
});
|
||||
|
||||
test('query.curves and query.cog reply on Port 0 via ctx.send', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const sent = [];
|
||||
const ctx = makeCtx({ sendSpy: (ports) => sent.push(ports) });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'query.curves' }, source, ctx);
|
||||
await reg.dispatch({ topic: 'query.cog' }, source, ctx);
|
||||
|
||||
assert.equal(calls.showWorkingCurves, 1);
|
||||
assert.equal(calls.showCoG, 1);
|
||||
assert.equal(sent.length, 2);
|
||||
// First port carries the reply; Ports 1 & 2 are null.
|
||||
assert.equal(sent[0][0].topic, 'showWorkingCurves');
|
||||
assert.deepEqual(sent[0][0].payload, { curves: 'mock' });
|
||||
assert.equal(sent[0][1], null);
|
||||
assert.equal(sent[0][2], null);
|
||||
assert.equal(sent[1][0].topic, 'showCoG');
|
||||
assert.deepEqual(sent[1][0].payload, { cog: 'mock' });
|
||||
});
|
||||
|
||||
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const child = { id: 'm-1', source: { tag: 'm-domain' } };
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'm-1', positionVsParent: 'upstream' },
|
||||
source,
|
||||
makeCtx({ child })
|
||||
);
|
||||
assert.equal(calls.registerChild.length, 1);
|
||||
assert.equal(calls.registerChild[0].childSource, child.source);
|
||||
assert.equal(calls.registerChild[0].position, 'upstream');
|
||||
});
|
||||
|
||||
test('child.register with unknown id logs warn and does not throw', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
|
||||
source,
|
||||
makeCtx({ logger: ctxLogger })
|
||||
)
|
||||
);
|
||||
assert.equal(calls.registerChild.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
|
||||
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
30
test/basic/curveLoader.basic.test.js
Normal file
30
test/basic/curveLoader.basic.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { loadModelCurve } = require('../../src/curves/curveLoader');
|
||||
|
||||
test('curveLoader: valid model returns rawCurve and null error', () => {
|
||||
const result = loadModelCurve('hidrostal-H05K-S03R');
|
||||
assert.equal(result.error, null);
|
||||
assert.ok(result.rawCurve);
|
||||
assert.ok(result.rawCurve.np);
|
||||
assert.ok(result.rawCurve.nq);
|
||||
});
|
||||
|
||||
test('curveLoader: missing model returns Model not specified', () => {
|
||||
const result = loadModelCurve('');
|
||||
assert.equal(result.rawCurve, null);
|
||||
assert.equal(result.error, 'Model not specified');
|
||||
});
|
||||
|
||||
test('curveLoader: undefined model returns Model not specified', () => {
|
||||
const result = loadModelCurve(undefined);
|
||||
assert.equal(result.rawCurve, null);
|
||||
assert.equal(result.error, 'Model not specified');
|
||||
});
|
||||
|
||||
test('curveLoader: unknown model returns Curve not found error', () => {
|
||||
const result = loadModelCurve('this-model-does-not-exist');
|
||||
assert.equal(result.rawCurve, null);
|
||||
assert.match(result.error, /Curve not found for model/);
|
||||
});
|
||||
88
test/basic/curveNormalizer.basic.test.js
Normal file
88
test/basic/curveNormalizer.basic.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { UnitPolicy } = require('generalFunctions');
|
||||
const {
|
||||
normalizeMachineCurve,
|
||||
normalizeCurveSection,
|
||||
convertUnitValue,
|
||||
} = require('../../src/curves/curveNormalizer');
|
||||
|
||||
function makePolicy() {
|
||||
return UnitPolicy.declare({
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
|
||||
curve: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
|
||||
});
|
||||
}
|
||||
|
||||
function captureLogger() {
|
||||
const warns = [];
|
||||
return {
|
||||
warn: (m) => warns.push(m),
|
||||
warns,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizeMachineCurve: rejects raw without nq/np', () => {
|
||||
const policy = makePolicy();
|
||||
assert.throws(() => normalizeMachineCurve(null, policy), /missing required nq\/np/);
|
||||
assert.throws(() => normalizeMachineCurve({ nq: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
|
||||
assert.throws(() => normalizeMachineCurve({ np: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
|
||||
});
|
||||
|
||||
test('normalizeMachineCurve: converts pressure mbar -> Pa and flow m3/h -> m3/s', () => {
|
||||
const policy = makePolicy();
|
||||
const raw = {
|
||||
nq: {
|
||||
1000: { x: [0, 100], y: [0, 3600] }, // 3600 m3/h = 1 m3/s
|
||||
},
|
||||
np: {
|
||||
1000: { x: [0, 100], y: [0, 1] }, // 1 kW = 1000 W
|
||||
},
|
||||
};
|
||||
const out = normalizeMachineCurve(raw, policy);
|
||||
// 1000 mbar = 100000 Pa
|
||||
const pressureKey = Object.keys(out.nq)[0];
|
||||
assert.equal(Number(pressureKey), 100000);
|
||||
assert.ok(Math.abs(out.nq[pressureKey].y[1] - 1) < 1e-9, `expected 1 m3/s got ${out.nq[pressureKey].y[1]}`);
|
||||
assert.ok(Math.abs(out.np[pressureKey].y[1] - 1000) < 1e-6, `expected 1000 W got ${out.np[pressureKey].y[1]}`);
|
||||
});
|
||||
|
||||
test('normalizeCurveSection: warns on cross-pressure median > 3x jump', () => {
|
||||
const logger = captureLogger();
|
||||
const section = {
|
||||
1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5
|
||||
1100: { x: [0, 50, 100], y: [0, 50, 100] }, // median 50 (10x jump)
|
||||
};
|
||||
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
|
||||
const hit = logger.warns.find((w) => /Curve anomaly/.test(w));
|
||||
assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`);
|
||||
assert.match(hit, /pressure 1100/);
|
||||
});
|
||||
|
||||
test('normalizeCurveSection: does not warn on smooth progressions', () => {
|
||||
const logger = captureLogger();
|
||||
const section = {
|
||||
1000: { x: [0, 50, 100], y: [0, 5, 10] },
|
||||
1100: { x: [0, 50, 100], y: [0, 6, 11] },
|
||||
};
|
||||
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
|
||||
assert.equal(logger.warns.filter((w) => /Curve anomaly/.test(w)).length, 0);
|
||||
});
|
||||
|
||||
test('normalizeCurveSection: throws when x/y length mismatch', () => {
|
||||
assert.throws(
|
||||
() => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
|
||||
/Invalid nq section/
|
||||
);
|
||||
});
|
||||
|
||||
test('convertUnitValue: identity when units match or missing', () => {
|
||||
assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42);
|
||||
assert.equal(convertUnitValue(42, null, null), 42);
|
||||
});
|
||||
|
||||
test('convertUnitValue: throws on non-finite input', () => {
|
||||
assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/);
|
||||
});
|
||||
130
test/basic/driftAssessor.basic.test.js
Normal file
130
test/basic/driftAssessor.basic.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const DriftAssessor = require('../../src/drift/driftAssessor');
|
||||
|
||||
/* ---- fakes ---- */
|
||||
function fakeMeasurements(predictedValue) {
|
||||
return {
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position() { return this; },
|
||||
getCurrentValue() { return predictedValue; },
|
||||
getAllValues() { return { values: [predictedValue], timestamps: [1] }; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeErrorMetrics(driftFactory) {
|
||||
return {
|
||||
assessPoint: (metricId, predicted, measured, opts) => driftFactory(metricId, predicted, measured, opts),
|
||||
assessDrift: () => ({ nrmse: 0.1, valid: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const SILENT = { warn() {}, debug() {} };
|
||||
|
||||
test('updateMetricDrift returns drift object when predicted+measured both finite', () => {
|
||||
const drift = { valid: true, nrmse: 0.05, immediateLevel: 0, longTermLevel: 0 };
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: makeErrorMetrics(() => drift),
|
||||
measurements: fakeMeasurements(10),
|
||||
driftProfiles: { flow: {} },
|
||||
logger: SILENT,
|
||||
});
|
||||
|
||||
const out = assessor.updateMetricDrift('flow', 11);
|
||||
assert.deepEqual(out, drift);
|
||||
assert.equal(assessor.latest.flow, drift);
|
||||
});
|
||||
|
||||
test('updateMetricDrift returns null when predicted is non-finite', () => {
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
|
||||
measurements: fakeMeasurements(NaN),
|
||||
driftProfiles: {},
|
||||
logger: SILENT,
|
||||
});
|
||||
assert.equal(assessor.updateMetricDrift('flow', 5), null);
|
||||
});
|
||||
|
||||
test('updateMetricDrift catches errorMetrics throw and logs', () => {
|
||||
const warns = [];
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: { assessPoint() { throw new Error('boom'); } },
|
||||
measurements: fakeMeasurements(10),
|
||||
driftProfiles: {},
|
||||
logger: { warn(m) { warns.push(m); }, debug() {} },
|
||||
});
|
||||
const out = assessor.updateMetricDrift('flow', 11);
|
||||
assert.equal(out, null);
|
||||
assert.match(warns[0], /Drift update failed for metric 'flow'/);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty leaves confidence unchanged for null/invalid drift', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
assert.equal(assessor.applyDriftPenalty(null, 0.9, flags, 'flow'), 0.9);
|
||||
assert.equal(assessor.applyDriftPenalty({ valid: false }, 0.9, flags, 'flow'), 0.9);
|
||||
assert.deepEqual(flags, []);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty level 1 reduces confidence by 0.1 + flag', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.1, immediateLevel: 1, longTermLevel: 0 },
|
||||
0.9, flags, 'flow',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.8) < 1e-9);
|
||||
assert.deepEqual(flags, ['flow_low_immediate_drift']);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty level 2 reduces confidence by 0.2 + flag', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.2, immediateLevel: 2, longTermLevel: 0 },
|
||||
0.9, flags, 'power',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.7) < 1e-9);
|
||||
assert.deepEqual(flags, ['power_medium_immediate_drift']);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty level 3 reduces confidence by 0.3 + flag', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.5, immediateLevel: 3, longTermLevel: 0 },
|
||||
0.9, flags, 'flow',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.6) < 1e-9);
|
||||
assert.deepEqual(flags, ['flow_high_immediate_drift']);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty stacks long-term penalty', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.4, immediateLevel: 2, longTermLevel: 2 },
|
||||
0.9, flags, 'flow',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.6) < 1e-9);
|
||||
assert.deepEqual(flags, ['flow_medium_immediate_drift', 'flow_long_term_drift']);
|
||||
});
|
||||
|
||||
test('assessDrift returns null if no stored series', () => {
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
|
||||
measurements: {
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position() { return this; },
|
||||
getAllValues() { return {}; },
|
||||
},
|
||||
driftProfiles: {},
|
||||
logger: SILENT,
|
||||
});
|
||||
assert.equal(assessor.assessDrift('flow', 0, 1), null);
|
||||
});
|
||||
132
test/basic/flowController.basic.test.js
Normal file
132
test/basic/flowController.basic.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const FlowController = require('../../src/flow/flowController');
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { debug: [], info: [], warn: [], error: [] };
|
||||
return {
|
||||
calls,
|
||||
debug: (m) => calls.debug.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
warn: (m) => calls.warn.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost({
|
||||
mode = 'auto',
|
||||
allowedActions = new Set(['execsequence', 'execmovement', 'flowmovement', 'emergencystop', 'statuscheck', 'entermaintenance', 'exitmaintenance']),
|
||||
allowedSources = true,
|
||||
setpointError,
|
||||
} = {}) {
|
||||
const logger = makeLogger();
|
||||
const host = {
|
||||
logger,
|
||||
currentMode: mode,
|
||||
unitPolicy: {
|
||||
canonical: { flow: 'm3/s' },
|
||||
output: { flow: 'm3/h' },
|
||||
},
|
||||
isValidActionForMode: (action) => allowedActions.has(action),
|
||||
isValidSourceForMode: () => allowedSources,
|
||||
calls: { executeSequence: [], setpoint: [], calcCtrl: [], convertUnit: [] },
|
||||
async executeSequence(seq) { host.calls.executeSequence.push(seq); return { ran: seq }; },
|
||||
async setpoint(sp) {
|
||||
host.calls.setpoint.push(sp);
|
||||
if (setpointError) throw setpointError;
|
||||
return { moved: sp };
|
||||
},
|
||||
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; },
|
||||
_convertUnitValue: (val, from, to, label) => {
|
||||
host.calls.convertUnit.push({ val, from, to, label });
|
||||
return val * 1000; // pretend m3/h -> m3/s factor
|
||||
},
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
test('handle("parent","execSequence","startup") triggers executeSequence', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
const result = await fc.handle('parent', 'execSequence', 'startup');
|
||||
assert.deepEqual(host.calls.executeSequence, ['startup']);
|
||||
assert.deepEqual(result, { ran: 'startup' });
|
||||
});
|
||||
|
||||
test('handle("parent","execMovement",50) invokes setpoint(50)', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
const result = await fc.handle('parent', 'execMovement', 50);
|
||||
assert.deepEqual(host.calls.setpoint, [50]);
|
||||
assert.deepEqual(result, { moved: 50 });
|
||||
});
|
||||
|
||||
test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'flowMovement', 36);
|
||||
assert.equal(host.calls.convertUnit.length, 1);
|
||||
assert.equal(host.calls.convertUnit[0].from, 'm3/h');
|
||||
assert.equal(host.calls.convertUnit[0].to, 'm3/s');
|
||||
assert.deepEqual(host.calls.calcCtrl, [36 * 1000]);
|
||||
assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]);
|
||||
});
|
||||
|
||||
test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'emergencyStop');
|
||||
assert.deepEqual(host.calls.executeSequence, ['emergencystop']);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m)));
|
||||
});
|
||||
|
||||
test('handle rejects non-string action', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 123, 'x');
|
||||
assert.deepEqual(host.calls.executeSequence, []);
|
||||
assert.deepEqual(host.calls.setpoint, []);
|
||||
assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m)));
|
||||
});
|
||||
|
||||
test('handle bails out when action not allowed for mode', async () => {
|
||||
const host = makeHost({ allowedActions: new Set(['statuscheck']) });
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'execSequence', 'startup');
|
||||
assert.deepEqual(host.calls.executeSequence, []);
|
||||
});
|
||||
|
||||
test('handle bails out when source not allowed for mode', async () => {
|
||||
const host = makeHost({ allowedSources: false });
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('externalApi', 'execSequence', 'startup');
|
||||
assert.deepEqual(host.calls.executeSequence, []);
|
||||
});
|
||||
|
||||
test('handle catches downstream errors and logs them (does not propagate)', async () => {
|
||||
const host = makeHost({ setpointError: new Error('boom') });
|
||||
const fc = new FlowController({ host });
|
||||
const result = await fc.handle('parent', 'execMovement', 12);
|
||||
assert.equal(result, undefined);
|
||||
assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m)));
|
||||
});
|
||||
|
||||
test('handle returns a success envelope for statuscheck', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
const out = await fc.handle('parent', 'statusCheck');
|
||||
assert.equal(out.status, true);
|
||||
assert.ok(out.feedback.includes('statuscheck'));
|
||||
});
|
||||
|
||||
test('handle warns on unimplemented action', async () => {
|
||||
const host = makeHost({ allowedActions: new Set(['weirdaction']) });
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'weirdAction');
|
||||
assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m)));
|
||||
});
|
||||
|
||||
test('constructor validates host', () => {
|
||||
assert.throws(() => new FlowController({}), /ctx\.host is required/);
|
||||
});
|
||||
51
test/basic/groupPredictors.basic.test.js
Normal file
51
test/basic/groupPredictors.basic.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { predict } = require('generalFunctions');
|
||||
const { buildPredictors } = require('../../src/prediction/predictors');
|
||||
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
|
||||
|
||||
function makeCanonicalCurve() {
|
||||
return {
|
||||
nq: {
|
||||
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
|
||||
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
|
||||
},
|
||||
np: {
|
||||
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
|
||||
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildGroupPredictors: returns null when source predictors absent', () => {
|
||||
assert.equal(buildGroupPredictors(null), null);
|
||||
assert.equal(buildGroupPredictors({ predictFlow: null, predictPower: null, predictCtrl: null }), null);
|
||||
});
|
||||
|
||||
test('buildGroupPredictors: returns three group-scope Predict instances', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
assert.ok(group);
|
||||
assert.ok(group.groupPredictFlow instanceof predict);
|
||||
assert.ok(group.groupPredictPower instanceof predict);
|
||||
assert.ok(group.groupPredictCtrl instanceof predict);
|
||||
});
|
||||
|
||||
test('buildGroupPredictors: group instances share input curves with individuals', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
// Predict._adoptInputsFrom copies these refs from the source.
|
||||
assert.equal(group.groupPredictFlow.inputCurve, predictors.predictFlow.inputCurve);
|
||||
assert.equal(group.groupPredictPower.inputCurve, predictors.predictPower.inputCurve);
|
||||
assert.equal(group.groupPredictCtrl.inputCurve, predictors.predictCtrl.inputCurve);
|
||||
});
|
||||
|
||||
test('buildGroupPredictors: group operating-point state is independent of individual', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
predictors.predictFlow.fDimension = 100000;
|
||||
group.groupPredictFlow.fDimension = 120000;
|
||||
assert.equal(predictors.predictFlow.currentF, 100000);
|
||||
assert.equal(group.groupPredictFlow.currentF, 120000);
|
||||
});
|
||||
149
test/basic/measurementHandlers.basic.test.js
Normal file
149
test/basic/measurementHandlers.basic.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MeasurementHandlers = require('../../src/measurement/measurementHandlers');
|
||||
|
||||
function makeChainable(sink) {
|
||||
const builder = {
|
||||
_path: {},
|
||||
type(t) { this._path.type = t; return this; },
|
||||
variant(v) { this._path.variant = v; return this; },
|
||||
position(p){ this._path.position = p; return this; },
|
||||
child(id) { this._path.child = id; return this; },
|
||||
value(v, ts, unit) {
|
||||
sink.push({ ...this._path, value: v, ts, unit });
|
||||
this._path = {};
|
||||
},
|
||||
getCurrentValue(unit) {
|
||||
return sink._currentValue != null ? sink._currentValue : 0;
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { debug: [], info: [], warn: [], error: [] };
|
||||
return {
|
||||
calls,
|
||||
debug: (m) => calls.debug.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
warn: (m) => calls.warn.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost({ operational = true } = {}) {
|
||||
const writes = [];
|
||||
const logger = makeLogger();
|
||||
const host = {
|
||||
logger,
|
||||
writes,
|
||||
measurementUnits: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
|
||||
unitPolicy: {
|
||||
canonical: { flow: 'm3/s', power: 'W', temperature: 'K', pressure: 'Pa' },
|
||||
output: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
|
||||
},
|
||||
predictFlow: { outputY: 7 },
|
||||
predictPower: { outputY: 1234 },
|
||||
measurements: makeChainable(writes),
|
||||
_isOperationalState: () => operational,
|
||||
_resolveMeasurementUnit: (type, unit) => {
|
||||
if (!unit) throw new Error(`Missing unit for ${type} measurement.`);
|
||||
return unit;
|
||||
},
|
||||
_updateMetricDrift: (...args) => { host.driftCalls.push(args); },
|
||||
_updatePredictionHealth: () => { host.healthCalls++; },
|
||||
driftCalls: [],
|
||||
healthCalls: 0,
|
||||
updateMeasuredPressure: (...args) => { host.pressureCalls.push(args); },
|
||||
pressureCalls: [],
|
||||
updatePosition: () => { host.positionCalls++; },
|
||||
positionCalls: 0,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
test('dispatch("flow", …) routes to updateMeasuredFlow', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h', childId: 'c1', childName: 'FT-1' });
|
||||
|
||||
const flowWrite = host.writes.find((w) => w.type === 'flow' && w.variant === 'measured');
|
||||
assert.ok(flowWrite, 'expected measured flow write');
|
||||
assert.equal(flowWrite.value, 5);
|
||||
assert.equal(flowWrite.position, 'downstream');
|
||||
assert.equal(flowWrite.child, 'c1');
|
||||
|
||||
const predictedWrites = host.writes.filter((w) => w.type === 'flow' && w.variant === 'predicted');
|
||||
assert.equal(predictedWrites.length, 2, 'two predicted writes (downstream+atEquipment)');
|
||||
assert.equal(host.driftCalls.length, 1);
|
||||
assert.equal(host.driftCalls[0][0], 'flow');
|
||||
assert.equal(host.healthCalls, 1);
|
||||
});
|
||||
|
||||
test('dispatch("temperature", …) writes to measurements (works in non-operational state too)', () => {
|
||||
const host = makeHost({ operational: false });
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('temperature', 22.5, 'atEquipment', { unit: 'C', childId: 'tc', childName: 'TT-1', timestamp: 111 });
|
||||
|
||||
const write = host.writes.find((w) => w.type === 'temperature');
|
||||
assert.ok(write);
|
||||
assert.equal(write.value, 22.5);
|
||||
assert.equal(write.unit, 'C');
|
||||
assert.equal(write.ts, 111);
|
||||
});
|
||||
|
||||
test('dispatch("power", …) routes to updateMeasuredPower and respects unit', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('power', 1500, 'atEquipment', { unit: 'kW', childId: 'pwr', childName: 'P-1' });
|
||||
|
||||
const measured = host.writes.find((w) => w.type === 'power' && w.variant === 'measured');
|
||||
assert.ok(measured);
|
||||
assert.equal(measured.unit, 'kW');
|
||||
const predicted = host.writes.find((w) => w.type === 'power' && w.variant === 'predicted');
|
||||
assert.ok(predicted);
|
||||
assert.equal(host.driftCalls.length, 1);
|
||||
assert.equal(host.driftCalls[0][0], 'power');
|
||||
});
|
||||
|
||||
test('flow/power updates are skipped when machine is not operational', () => {
|
||||
const host = makeHost({ operational: false });
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h' });
|
||||
mh.dispatch('power', 99, 'atEquipment', { unit: 'kW' });
|
||||
|
||||
assert.equal(host.writes.length, 0);
|
||||
assert.equal(host.driftCalls.length, 0);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /Machine not operational/.test(m)));
|
||||
});
|
||||
|
||||
test('dispatch("pressure", …) delegates to host.updateMeasuredPressure (pressureRouter)', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('pressure', 1013, 'upstream', { unit: 'mbar', childId: 'PT-1' });
|
||||
|
||||
assert.equal(host.pressureCalls.length, 1);
|
||||
assert.deepEqual(host.pressureCalls[0][0], 1013);
|
||||
});
|
||||
|
||||
test('dispatch(unknown, …) logs warn and falls back to updatePosition', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('vibration', 1, 'atEquipment', {});
|
||||
|
||||
assert.equal(host.positionCalls, 1);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /No handler for measurement type/.test(m)));
|
||||
});
|
||||
|
||||
test('handler rejects update when unit resolution throws', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('flow', 5, 'downstream', { /* no unit */ });
|
||||
assert.equal(host.writes.length, 0);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /Rejected flow update/.test(m)));
|
||||
});
|
||||
|
||||
test('constructor validates host', () => {
|
||||
assert.throws(() => new MeasurementHandlers({}), /ctx\.host is required/);
|
||||
});
|
||||
73
test/basic/operatingPoint.basic.test.js
Normal file
73
test/basic/operatingPoint.basic.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { buildPredictors } = require('../../src/prediction/predictors');
|
||||
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
|
||||
const OperatingPoint = require('../../src/prediction/operatingPoint');
|
||||
|
||||
function makeCanonicalCurve() {
|
||||
return {
|
||||
nq: {
|
||||
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
|
||||
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
|
||||
},
|
||||
np: {
|
||||
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
|
||||
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('OperatingPoint.setIndividual: updates working pressure on all three predictors', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors);
|
||||
const ok = op.setIndividual(100000);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(predictors.predictFlow.currentF, 100000);
|
||||
assert.equal(predictors.predictPower.currentF, 100000);
|
||||
assert.equal(predictors.predictCtrl.currentF, 100000);
|
||||
});
|
||||
|
||||
test('OperatingPoint.setIndividual: rejects non-finite pressure', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors);
|
||||
assert.equal(op.setIndividual(NaN), false);
|
||||
assert.equal(op.setIndividual('not-a-number'), false);
|
||||
});
|
||||
|
||||
test('OperatingPoint.setGroup: no-op when group predictors absent', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors, null);
|
||||
assert.equal(op.setGroup(100000), false);
|
||||
});
|
||||
|
||||
test('OperatingPoint.setGroup: updates only group predictors', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
const op = new OperatingPoint(predictors, group);
|
||||
predictors.predictFlow.fDimension = 120000;
|
||||
op.setGroup(100000);
|
||||
assert.equal(group.groupPredictFlow.currentF, 100000);
|
||||
assert.equal(predictors.predictFlow.currentF, 120000);
|
||||
});
|
||||
|
||||
test('OperatingPoint.flowFor: returns a finite predicted flow', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors);
|
||||
op.setIndividual(100000);
|
||||
const flow = op.flowFor(50);
|
||||
assert.ok(Number.isFinite(flow), `expected finite flow, got ${flow}`);
|
||||
assert.ok(flow > 0);
|
||||
});
|
||||
|
||||
test('OperatingPoint.useGroup: switches getters to group predictors', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
const op = new OperatingPoint(predictors, group);
|
||||
op.setIndividual(100000);
|
||||
op.setGroup(120000);
|
||||
const indivFlow = op.useIndividual().flowFor(50);
|
||||
const groupFlow = op.useGroup().flowFor(50);
|
||||
assert.ok(Number.isFinite(indivFlow));
|
||||
assert.ok(Number.isFinite(groupFlow));
|
||||
});
|
||||
93
test/basic/predictionHealth.basic.test.js
Normal file
93
test/basic/predictionHealth.basic.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PredictionHealth = require('../../src/drift/predictionHealth');
|
||||
const DriftAssessor = require('../../src/drift/driftAssessor');
|
||||
|
||||
function makeHealth(overrides = {}) {
|
||||
return new PredictionHealth({
|
||||
getPressureInitializationStatus: () => ({
|
||||
initialized: true, hasDifferential: true, source: 'differential',
|
||||
}),
|
||||
isOperational: () => true,
|
||||
applyDriftPenalty: new DriftAssessor({}).applyDriftPenalty.bind(new DriftAssessor({})),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('empty snapshots + differential pressure → nominal health, confidence=0.9', () => {
|
||||
const ph = makeHealth();
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: null,
|
||||
power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.equal(health.level, 0);
|
||||
assert.ok(Math.abs(confidence - 0.9) < 1e-9);
|
||||
assert.equal(typeof health.message, 'string');
|
||||
});
|
||||
|
||||
test('pressure not initialized + flow drift level 2 → composite level >= 2 and multiple flags', () => {
|
||||
const ph = makeHealth({
|
||||
getPressureInitializationStatus: () => ({
|
||||
initialized: false, hasDifferential: false, source: null,
|
||||
}),
|
||||
});
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: { valid: true, nrmse: 0.3, immediateLevel: 2, longTermLevel: 0 },
|
||||
power: null,
|
||||
pressure: { level: 2, flags: ['no_pressure_input'], source: null },
|
||||
});
|
||||
assert.ok(health.level >= 2);
|
||||
assert.ok(health.flags.includes('no_pressure_input'));
|
||||
assert.ok(health.flags.includes('flow_medium_immediate_drift'));
|
||||
assert.ok(confidence < 0.5);
|
||||
});
|
||||
|
||||
test('returned object has both health and confidence', () => {
|
||||
const ph = makeHealth();
|
||||
const out = ph.evaluate({ flow: null, power: null, pressure: { level: 0, flags: [], source: 'differential' } });
|
||||
assert.ok('health' in out);
|
||||
assert.ok('confidence' in out);
|
||||
assert.equal(typeof out.confidence, 'number');
|
||||
assert.equal(typeof out.health.level, 'number');
|
||||
});
|
||||
|
||||
test('non-operational forces confidence=0 and bumps level >=2', () => {
|
||||
const ph = makeHealth({ isOperational: () => false });
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: null, power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.equal(confidence, 0);
|
||||
assert.ok(health.flags.includes('not_operational'));
|
||||
assert.ok(health.level >= 2);
|
||||
});
|
||||
|
||||
test('curve-edge penalty applies when current position is near min/max', () => {
|
||||
const ph = makeHealth({
|
||||
getCurrentPosition: () => 0.01,
|
||||
resolveSetpointBounds: () => ({ min: 0, max: 1 }),
|
||||
});
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: null, power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.ok(health.flags.includes('near_curve_edge'));
|
||||
assert.ok(confidence < 0.9);
|
||||
});
|
||||
|
||||
test('HealthStatus shape — has the standardised five fields', () => {
|
||||
const ph = makeHealth();
|
||||
const { health } = ph.evaluate({
|
||||
flow: null, power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.ok('level' in health);
|
||||
assert.ok('flags' in health);
|
||||
assert.ok('message' in health);
|
||||
assert.ok('source' in health);
|
||||
assert.ok(Array.isArray(health.flags));
|
||||
});
|
||||
49
test/basic/predictors.basic.test.js
Normal file
49
test/basic/predictors.basic.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { predict } = require('generalFunctions');
|
||||
const { buildPredictors } = require('../../src/prediction/predictors');
|
||||
|
||||
function makeCanonicalCurve() {
|
||||
// Canonical units already applied: pressure Pa, flow m3/s, power W,
|
||||
// x-axis is control %. Two pressure levels, monotonically rising y.
|
||||
return {
|
||||
nq: {
|
||||
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
|
||||
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
|
||||
},
|
||||
np: {
|
||||
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
|
||||
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildPredictors: returns three Predict instances', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
assert.ok(predictors.predictFlow instanceof predict);
|
||||
assert.ok(predictors.predictPower instanceof predict);
|
||||
assert.ok(predictors.predictCtrl instanceof predict);
|
||||
});
|
||||
|
||||
test('buildPredictors: predictFlow yMax/yMin reflect input range', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
// After buildAllFxyCurves the fDimension is initialised to fValues.min.
|
||||
// currentFxyYMin/Max are the y-range at that pressure curve.
|
||||
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMax));
|
||||
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMin));
|
||||
assert.ok(predictors.predictFlow.currentFxyYMax > predictors.predictFlow.currentFxyYMin);
|
||||
});
|
||||
|
||||
test('buildPredictors: predictCtrl is built from reversed nq (flow->ctrl mapping)', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
// predictCtrl's x-axis values must come from y-values in nq.
|
||||
// sanity-check via currentFxyXMax being in the flow range
|
||||
assert.ok(predictors.predictCtrl.currentFxyXMax <= 0.02, // flow range upper bound
|
||||
`expected predictCtrl xMax in flow-range, got ${predictors.predictCtrl.currentFxyXMax}`);
|
||||
});
|
||||
|
||||
test('buildPredictors: throws when machineCurve is missing nq or np', () => {
|
||||
assert.throws(() => buildPredictors(null), /machineCurve\.nq and \.np are required/);
|
||||
assert.throws(() => buildPredictors({ nq: {} }), /required/);
|
||||
});
|
||||
103
test/basic/pressureInitialization.basic.test.js
Normal file
103
test/basic/pressureInitialization.basic.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PressureInitialization = require('../../src/pressure/pressureInitialization');
|
||||
|
||||
const SILENT = { warn() {}, debug() {} };
|
||||
|
||||
/* A tiny in-memory stand-in for MeasurementContainer's chained API. */
|
||||
function makeFakeMeasurements() {
|
||||
const store = new Map();
|
||||
const key = (pos, childId) => `${pos}::${childId == null ? '*' : childId}`;
|
||||
return {
|
||||
_write(pos, childId, value) { store.set(key(pos, childId), value); },
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position(p) { this._pos = p; return this; },
|
||||
child(c) { this._child = c; return this; },
|
||||
getCurrentValue() {
|
||||
const k = key(this._pos, this._child);
|
||||
this._child = null;
|
||||
const v = store.get(k);
|
||||
if (v != null) return v;
|
||||
// fallback to bare position when no child specified
|
||||
return store.get(key(this._pos, null));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('getStatus reports initialized:false when neither real nor virtual data present', () => {
|
||||
const init = new PressureInitialization({
|
||||
measurements: makeFakeMeasurements(),
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.initialized, false);
|
||||
assert.equal(s.hasDifferential, false);
|
||||
assert.equal(s.source, null);
|
||||
});
|
||||
|
||||
test('registerReal then getStatus reports initialized:true for that position', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const init = new PressureInitialization({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
|
||||
init.registerReal('upstream', 'pt-101');
|
||||
meas._write('upstream', 'pt-101', 5000);
|
||||
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.initialized, true);
|
||||
assert.equal(s.hasUpstream, true);
|
||||
assert.equal(s.hasDownstream, false);
|
||||
assert.equal(s.hasDifferential, false);
|
||||
assert.equal(s.source, 'upstream');
|
||||
});
|
||||
|
||||
test('hasDifferential true only when both upstream + downstream have data', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const init = new PressureInitialization({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
init.registerReal('upstream', 'pt-1');
|
||||
meas._write('upstream', 'pt-1', 5000);
|
||||
assert.equal(init.getStatus().hasDifferential, false);
|
||||
|
||||
init.registerReal('downstream', 'pt-2');
|
||||
meas._write('downstream', 'pt-2', 7000);
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.hasDifferential, true);
|
||||
assert.equal(s.source, 'differential');
|
||||
});
|
||||
|
||||
test('virtual fallback when no real children registered', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const init = new PressureInitialization({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
meas._write('upstream', 'sim-u', 5000);
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.hasUpstream, true);
|
||||
assert.equal(s.source, 'upstream');
|
||||
});
|
||||
|
||||
test('unregisterReal removes a tracked child id', () => {
|
||||
const init = new PressureInitialization({
|
||||
measurements: makeFakeMeasurements(),
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
init.registerReal('upstream', 'pt-1');
|
||||
assert.ok(init.realPressureChildIds.upstream.has('pt-1'));
|
||||
init.unregisterReal('upstream', 'pt-1');
|
||||
assert.ok(!init.realPressureChildIds.upstream.has('pt-1'));
|
||||
});
|
||||
101
test/basic/pressureRouter.basic.test.js
Normal file
101
test/basic/pressureRouter.basic.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PressureRouter = require('../../src/pressure/pressureRouter');
|
||||
|
||||
const SILENT = { warn() {}, debug() {} };
|
||||
|
||||
function makeFakeMeasurements() {
|
||||
const writes = [];
|
||||
return {
|
||||
writes,
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position(p) { this._pos = p; return this; },
|
||||
child(c) { this._child = c; return this; },
|
||||
value(v, t, u) { writes.push({ pos: this._pos, child: this._child, value: v, t, u }); },
|
||||
};
|
||||
}
|
||||
|
||||
test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 1, { childId: 'real-1', unit: 'mbar', timestamp: 1234 });
|
||||
assert.equal(meas.writes.length, 1);
|
||||
assert.equal(meas.writes[0].pos, 'upstream');
|
||||
assert.equal(meas.writes[0].child, 'real-1');
|
||||
assert.equal(meas.writes[0].value, 1);
|
||||
assert.equal(meas.writes[0].u, 'mbar');
|
||||
});
|
||||
|
||||
test('virtual source: refresh hooks NOT called', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
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);
|
||||
});
|
||||
|
||||
test('real source: all refresh hooks called', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
updatePosition: () => { posCalled++; },
|
||||
refreshDrift: () => { driftCalled++; },
|
||||
refreshHealth: () => { healthCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
|
||||
assert.equal(posCalled, 1);
|
||||
assert.equal(driftCalled, 1);
|
||||
assert.equal(healthCalled, 1);
|
||||
});
|
||||
|
||||
test('rejected unit returns false and skips the write', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const warns = [];
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: {},
|
||||
resolveMeasurementUnit: () => { throw new Error('bad unit'); },
|
||||
logger: { warn(m) { warns.push(m); }, debug() {} },
|
||||
});
|
||||
const ok = router.route('upstream', 1, { childId: 'x', unit: 'wat' });
|
||||
assert.equal(ok, false);
|
||||
assert.equal(meas.writes.length, 0);
|
||||
assert.match(warns[0], /Rejected pressure update/);
|
||||
});
|
||||
|
||||
test('childId null is treated as not-virtual', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
updatePosition: () => { posCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 2, { unit: 'mbar' });
|
||||
assert.equal(posCalled, 1);
|
||||
});
|
||||
29
test/basic/reverseCurve.basic.test.js
Normal file
29
test/basic/reverseCurve.basic.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { reverseCurve } = require('../../src/curves/reverseCurve');
|
||||
|
||||
test('reverseCurve: swaps x and y for each pressure key', () => {
|
||||
const input = {
|
||||
700: { x: [0, 50, 100], y: [0, 10, 20] },
|
||||
800: { x: [0, 50, 100], y: [0, 11, 22] },
|
||||
};
|
||||
const out = reverseCurve(input);
|
||||
assert.deepEqual(out['700'].x, [0, 10, 20]);
|
||||
assert.deepEqual(out['700'].y, [0, 50, 100]);
|
||||
assert.deepEqual(out['800'].x, [0, 11, 22]);
|
||||
assert.deepEqual(out['800'].y, [0, 50, 100]);
|
||||
});
|
||||
|
||||
test('reverseCurve: returns a fresh object with cloned arrays', () => {
|
||||
const input = { 700: { x: [1, 2], y: [3, 4] } };
|
||||
const out = reverseCurve(input);
|
||||
out['700'].x.push(999);
|
||||
assert.deepEqual(input['700'].x, [1, 2]);
|
||||
assert.deepEqual(input['700'].y, [3, 4]);
|
||||
});
|
||||
|
||||
test('reverseCurve: handles empty input', () => {
|
||||
assert.deepEqual(reverseCurve({}), {});
|
||||
assert.deepEqual(reverseCurve(null), {});
|
||||
});
|
||||
91
test/basic/stateBindings.basic.test.js
Normal file
91
test/basic/stateBindings.basic.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const { bindStateEvents, isOperationalState, OPERATIONAL_STATES } =
|
||||
require('../../src/state/stateBindings');
|
||||
|
||||
function makeFakeState() {
|
||||
const emitter = new EventEmitter();
|
||||
let current = 'idle';
|
||||
return {
|
||||
emitter,
|
||||
setState(s) { current = s; },
|
||||
getCurrentState() { return current; },
|
||||
};
|
||||
}
|
||||
|
||||
test('bindStateEvents attaches both listeners and they fire on emit', () => {
|
||||
const state = makeFakeState();
|
||||
let posCalls = 0;
|
||||
let stateCalls = 0;
|
||||
let lastStateArg = null;
|
||||
|
||||
bindStateEvents({
|
||||
state,
|
||||
onPositionChange: () => { posCalls++; },
|
||||
onStateChange: (newState) => { stateCalls++; lastStateArg = newState; },
|
||||
});
|
||||
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 1);
|
||||
assert.equal(state.emitter.listenerCount('stateChange'), 1);
|
||||
|
||||
state.emitter.emit('positionChange', 42);
|
||||
state.emitter.emit('stateChange', 'operational');
|
||||
|
||||
assert.equal(posCalls, 1);
|
||||
assert.equal(stateCalls, 1);
|
||||
assert.equal(lastStateArg, 'operational');
|
||||
});
|
||||
|
||||
test('bindStateEvents teardown removes both listeners and is idempotent', () => {
|
||||
const state = makeFakeState();
|
||||
const teardown = bindStateEvents({
|
||||
state,
|
||||
onPositionChange: () => {},
|
||||
onStateChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 1);
|
||||
assert.equal(state.emitter.listenerCount('stateChange'), 1);
|
||||
|
||||
teardown();
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 0);
|
||||
assert.equal(state.emitter.listenerCount('stateChange'), 0);
|
||||
|
||||
teardown();
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 0);
|
||||
});
|
||||
|
||||
test('bindStateEvents validates context shape', () => {
|
||||
assert.throws(() => bindStateEvents(null), /ctx\.state\.emitter is required/);
|
||||
assert.throws(
|
||||
() => bindStateEvents({ state: makeFakeState() }),
|
||||
/handlers are required/,
|
||||
);
|
||||
});
|
||||
|
||||
test('isOperationalState returns true for operational/accelerating/decelerating/warmingup', () => {
|
||||
const state = makeFakeState();
|
||||
for (const s of ['operational', 'accelerating', 'decelerating', 'warmingup']) {
|
||||
state.setState(s);
|
||||
assert.equal(isOperationalState(state), true, `expected ${s} to be operational`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isOperationalState returns false for non-operational states and bad input', () => {
|
||||
const state = makeFakeState();
|
||||
for (const s of ['idle', 'starting', 'stopping', 'coolingdown', 'emergencystopped']) {
|
||||
state.setState(s);
|
||||
assert.equal(isOperationalState(state), false, `expected ${s} not to be operational`);
|
||||
}
|
||||
assert.equal(isOperationalState(null), false);
|
||||
assert.equal(isOperationalState({}), false);
|
||||
});
|
||||
|
||||
test('OPERATIONAL_STATES list is exported and frozen-ish (no extras beyond contract)', () => {
|
||||
assert.deepEqual(
|
||||
[...OPERATIONAL_STATES].sort(),
|
||||
['accelerating', 'decelerating', 'operational', 'warmingup'],
|
||||
);
|
||||
});
|
||||
70
test/basic/virtualChildren.basic.test.js
Normal file
70
test/basic/virtualChildren.basic.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const VirtualPressureChildren = require('../../src/pressure/virtualChildren');
|
||||
|
||||
const SILENT = { warn() {}, debug() {}, info() {}, error() {} };
|
||||
|
||||
const UNIT_POLICY = {
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K', atmPressure: 'Pa' },
|
||||
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
|
||||
};
|
||||
|
||||
test('build() returns two children with the expected config shape', () => {
|
||||
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
|
||||
const { upstream, downstream } = factory.build();
|
||||
|
||||
for (const child of [upstream, downstream]) {
|
||||
assert.ok(child.config.general.id);
|
||||
assert.ok(child.config.general.name);
|
||||
assert.equal(child.config.functionality.softwareType, 'measurement');
|
||||
assert.ok(['upstream', 'downstream'].includes(child.config.functionality.positionVsParent));
|
||||
assert.equal(child.config.asset.type, 'pressure');
|
||||
assert.equal(child.config.asset.unit, 'mbar');
|
||||
}
|
||||
|
||||
assert.equal(upstream.config.functionality.positionVsParent, 'upstream');
|
||||
assert.equal(downstream.config.functionality.positionVsParent, 'downstream');
|
||||
});
|
||||
|
||||
test('each child has its own MeasurementContainer instance', () => {
|
||||
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
|
||||
const { upstream, downstream } = factory.build();
|
||||
assert.ok(upstream.measurements);
|
||||
assert.ok(downstream.measurements);
|
||||
assert.notStrictEqual(upstream.measurements, downstream.measurements);
|
||||
});
|
||||
|
||||
test('the MeasurementContainer accepts pressure writes (unit policy applied)', () => {
|
||||
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
|
||||
const { upstream } = factory.build();
|
||||
upstream.measurements
|
||||
.type('pressure').variant('measured').position('upstream')
|
||||
.value(1000, Date.now(), 'mbar');
|
||||
const v = upstream.measurements
|
||||
.type('pressure').variant('measured').position('upstream').getCurrentValue();
|
||||
assert.ok(v != null);
|
||||
});
|
||||
|
||||
test('setParentRef wires children to the supplied parent ref', () => {
|
||||
const parent = { id: 'parent-machine' };
|
||||
const factory = new VirtualPressureChildren({
|
||||
logger: SILENT, unitPolicy: UNIT_POLICY, parentRef: parent,
|
||||
});
|
||||
const { upstream, downstream } = factory.build();
|
||||
assert.equal(typeof upstream.measurements.setParentRef, 'function');
|
||||
assert.equal(typeof downstream.measurements.setParentRef, 'function');
|
||||
});
|
||||
|
||||
test('custom ids are honoured', () => {
|
||||
const factory = new VirtualPressureChildren({
|
||||
logger: SILENT,
|
||||
unitPolicy: UNIT_POLICY,
|
||||
ids: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
});
|
||||
const { upstream, downstream } = factory.build();
|
||||
assert.equal(upstream.config.general.id, 'sim-u');
|
||||
assert.equal(downstream.config.general.id, 'sim-d');
|
||||
});
|
||||
83
test/basic/workingCurves.basic.test.js
Normal file
83
test/basic/workingCurves.basic.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { showWorkingCurves, showCoG } = require('../../src/display/workingCurves');
|
||||
|
||||
function makePredictors(overrides = {}) {
|
||||
return {
|
||||
hasCurve: true,
|
||||
cog: 0.65,
|
||||
cogIndex: 7,
|
||||
NCog: 0.5,
|
||||
minEfficiency: 0.4,
|
||||
currentEfficiencyCurve: { x: [0, 1], y: [0.4, 0.8] },
|
||||
absDistFromPeak: 0.15,
|
||||
relDistFromPeak: 0.3,
|
||||
calcCog: () => ({ cog: 0.65, cogIndex: 7, NCog: 0.5, minEfficiency: 0.4 }),
|
||||
getCurrentCurves: () => ({
|
||||
powerCurve: { x: [0, 1], y: [10, 20] },
|
||||
flowCurve: { x: [0, 1], y: [0, 5] },
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('showWorkingCurves returns the expected shape when curves exist', () => {
|
||||
const p = makePredictors();
|
||||
const out = showWorkingCurves(p);
|
||||
assert.deepEqual(out.powerCurve, { x: [0, 1], y: [10, 20] });
|
||||
assert.deepEqual(out.flowCurve, { x: [0, 1], y: [0, 5] });
|
||||
assert.equal(out.cog, 0.65);
|
||||
assert.equal(out.cogIndex, 7);
|
||||
assert.equal(out.NCog, 0.5);
|
||||
assert.equal(out.minEfficiency, 0.4);
|
||||
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
|
||||
assert.equal(out.absDistFromPeak, 0.15);
|
||||
assert.equal(out.relDistFromPeak, 0.3);
|
||||
});
|
||||
|
||||
test('showWorkingCurves returns error envelope when hasCurve is false', () => {
|
||||
const out = showWorkingCurves(makePredictors({ hasCurve: false }));
|
||||
assert.deepEqual(out, { error: 'No curve data available' });
|
||||
});
|
||||
|
||||
test('showWorkingCurves handles null predictors safely', () => {
|
||||
const out = showWorkingCurves(null);
|
||||
assert.equal(out.error, 'No curve data available');
|
||||
});
|
||||
|
||||
test('showCoG returns CoG data with rounded NCogPercent when curves exist', () => {
|
||||
const p = makePredictors();
|
||||
const out = showCoG(p);
|
||||
assert.equal(out.cog, 0.65);
|
||||
assert.equal(out.cogIndex, 7);
|
||||
assert.equal(out.NCog, 0.5);
|
||||
// 0.5 * 100 = 50.0, rounded *100 /100 still 50
|
||||
assert.equal(out.NCogPercent, 50);
|
||||
assert.equal(out.minEfficiency, 0.4);
|
||||
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
|
||||
assert.equal(out.absDistFromPeak, 0.15);
|
||||
assert.equal(out.relDistFromPeak, 0.3);
|
||||
});
|
||||
|
||||
test('showCoG rounds NCogPercent to 2 decimal places', () => {
|
||||
const p = makePredictors({
|
||||
calcCog: () => ({ cog: 0.1, cogIndex: 1, NCog: 0.123456, minEfficiency: 0.2 }),
|
||||
});
|
||||
const out = showCoG(p);
|
||||
assert.equal(out.NCogPercent, 12.35);
|
||||
});
|
||||
|
||||
test('showCoG returns degraded shape when hasCurve is false', () => {
|
||||
const out = showCoG(makePredictors({ hasCurve: false }));
|
||||
assert.equal(out.error, 'No curve data available');
|
||||
assert.equal(out.cog, 0);
|
||||
assert.equal(out.NCog, 0);
|
||||
assert.equal(out.cogIndex, 0);
|
||||
});
|
||||
|
||||
test('showCoG handles null predictors safely', () => {
|
||||
const out = showCoG(null);
|
||||
assert.equal(out.error, 'No curve data available');
|
||||
assert.equal(out.cog, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user