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>
150 lines
5.5 KiB
JavaScript
150 lines
5.5 KiB
JavaScript
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/);
|
|
});
|