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:
znetsixe
2026-05-10 21:38:45 +02:00
parent 8f9150e160
commit c5bb375dd0
34 changed files with 3036 additions and 0 deletions

View 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/);
});