specificClass.js: 716 → 244 lines.
Measurement extends BaseDomain. Analog mode now routes through one
Channel (key=null) — eliminates ~400 lines of inline pipeline that
duplicated what Channel.update() already did.
Public surface preserved for tests:
- tick() runs the simulator (when enabled) — Simulator owns the
random walk, orchestrator just writes the output back.
- inputValue setter routes through analogChannel.update.
- calibrate() / evaluateRepeatability() delegate to Calibrator.
- toggleSimulation / toggleOutlierDetection unchanged.
- 'mAbs' emitter event re-emitted from the analog channel's
MeasurementContainer event — backwards compat (deprecated;
tracked in OPEN_QUESTIONS.md for removal in Phase 7/8.5).
nodeClass.js: 230 → 42 lines.
Extends BaseNodeAdapter. tickInterval=1000 (only meaningful when
simulator enabled; tick is a no-op otherwise — toggling simulation
shouldn't require a redeploy). buildDomainConfig parses channels
JSON + mode and shapes scaling/smoothing/simulation slices.
96 / 96 tests pass (basic 77 + integration 17 + edge 2).
Two routing tests adjusted to seed the new commandRegistry path
(legacy private wiring removed); domain-tier tests unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
9.3 KiB
JavaScript
245 lines
9.3 KiB
JavaScript
'use strict';
|
|
|
|
const { BaseDomain, statusBadge } = require('generalFunctions');
|
|
const Channel = require('./channel');
|
|
const Simulator = require('./simulation/simulator');
|
|
const Calibrator = require('./calibration/calibrator');
|
|
|
|
// Measurement domain. Analog mode = one Channel built from the flat config.
|
|
// Digital mode = one Channel per config.channels[] entry. Channel owns the
|
|
// outlier → offset → scaling → smoothing → minMax → emit pipeline; the
|
|
// delegates below preserve the pre-refactor public surface for tests.
|
|
class Measurement extends BaseDomain {
|
|
static name = 'measurement';
|
|
|
|
configure() {
|
|
this.mode = (this.config?.mode?.current || 'analog').toLowerCase();
|
|
this.channels = new Map();
|
|
|
|
if (this.mode === 'digital') {
|
|
this._buildDigitalChannels();
|
|
} else {
|
|
this.analogChannel = this._buildAnalogChannel();
|
|
// Legacy event: kept so existing nodeClass status binders still fire.
|
|
// Slated for removal in Phase 7 (OPEN_QUESTIONS 2026-05-10).
|
|
const eventName = `${this.config.asset.type}.measured.${this.analogChannel.position.toLowerCase()}`;
|
|
this.measurements.emitter.on(eventName, (data) => {
|
|
this.emitter.emit('mAbs', data.value);
|
|
});
|
|
}
|
|
|
|
this._simulator = new Simulator({ config: this.config, logger: this.logger });
|
|
this._calibrator = new Calibrator({
|
|
storedValuesRef: () => this.analogChannel?.storedValues ?? [],
|
|
configRef: () => this.config,
|
|
logger: this.logger,
|
|
});
|
|
|
|
this._inputValue = 0;
|
|
this.simValue = 0;
|
|
this._installChannelMirrors();
|
|
|
|
this.logger.debug(`Measurement id=${this.config.general.id} ready. mode=${this.mode} channels=${this.channels.size}`);
|
|
}
|
|
|
|
// Mirror the analog Channel's state as `m.xxx` so the legacy public surface
|
|
// (outputAbs, storedValues, totalMinValue, …) stays writable from tests.
|
|
_installChannelMirrors() {
|
|
const RW = ['storedValues', 'outputAbs', 'outputPercent', 'totalMinValue',
|
|
'totalMaxValue', 'totalMinSmooth', 'totalMaxSmooth'];
|
|
const RO = ['inputRange', 'processRange'];
|
|
const def = (k, setter) => Object.defineProperty(this, k, {
|
|
configurable: true, enumerable: true,
|
|
get: () => this.analogChannel?.[k] ?? (k === 'storedValues' ? [] : 0),
|
|
...(setter ? { set: setter } : {}),
|
|
});
|
|
for (const k of RW) def(k, (v) => { if (this.analogChannel) this.analogChannel[k] = (k === 'storedValues' && Array.isArray(v)) ? [...v] : v; });
|
|
for (const k of RO) def(k);
|
|
}
|
|
|
|
_buildAnalogChannel() {
|
|
return new Channel({
|
|
key: null,
|
|
type: this.config.asset.type,
|
|
position: this.config.functionality?.positionVsParent || 'atEquipment',
|
|
unit: this.config.asset?.unit || this.config.general?.unit || 'unitless',
|
|
distance: this.config.functionality?.distance ?? null,
|
|
scaling: this.config.scaling,
|
|
smoothing: this.config.smoothing,
|
|
outlierDetection: this.config.outlierDetection,
|
|
interpolation: this.config.interpolation,
|
|
measurements: this.measurements,
|
|
logger: this.logger,
|
|
});
|
|
}
|
|
|
|
_buildDigitalChannels() {
|
|
const entries = Array.isArray(this.config.channels) ? this.config.channels : [];
|
|
if (entries.length === 0) {
|
|
this.logger.warn('digital mode enabled but config.channels is empty; no channels will be emitted.');
|
|
return;
|
|
}
|
|
for (const raw of entries) {
|
|
if (!raw || typeof raw !== 'object' || !raw.key || !raw.type) {
|
|
this.logger.warn(`skipping invalid channel entry: ${JSON.stringify(raw)}`);
|
|
continue;
|
|
}
|
|
const channel = new Channel({
|
|
key: raw.key,
|
|
type: raw.type,
|
|
position: raw.position || this.config.functionality?.positionVsParent || 'atEquipment',
|
|
unit: raw.unit || this.config.asset?.unit || 'unitless',
|
|
distance: raw.distance ?? this.config.functionality?.distance ?? null,
|
|
scaling: raw.scaling || { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
|
smoothing: raw.smoothing || { smoothWindow: this.config.smoothing.smoothWindow, smoothMethod: this.config.smoothing.smoothMethod },
|
|
outlierDetection: raw.outlierDetection || this.config.outlierDetection,
|
|
interpolation: raw.interpolation || this.config.interpolation,
|
|
measurements: this.measurements,
|
|
logger: this.logger,
|
|
});
|
|
this.channels.set(raw.key, channel);
|
|
}
|
|
this.logger.info(`digital mode: built ${this.channels.size} channel(s) from config.channels`);
|
|
}
|
|
|
|
// --- digital passthrough ---
|
|
|
|
handleDigitalPayload(payload) {
|
|
if (this.mode !== 'digital') {
|
|
this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`);
|
|
return {};
|
|
}
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
this.logger.warn(`digital payload must be an object; got ${typeof payload}`);
|
|
return {};
|
|
}
|
|
const summary = {};
|
|
const unknown = [];
|
|
for (const [key, raw] of Object.entries(payload)) {
|
|
const channel = this.channels.get(key);
|
|
if (!channel) { unknown.push(key); continue; }
|
|
const v = Number(raw);
|
|
if (!Number.isFinite(v)) {
|
|
this.logger.warn(`digital channel '${key}' received non-numeric value: ${raw}`);
|
|
summary[key] = { ok: false, reason: 'non-numeric' };
|
|
continue;
|
|
}
|
|
const ok = channel.update(v);
|
|
summary[key] = { ok, mAbs: channel.outputAbs, mPercent: channel.outputPercent };
|
|
}
|
|
if (unknown.length) this.logger.debug(`digital payload contained unmapped keys: ${unknown.join(', ')}`);
|
|
return summary;
|
|
}
|
|
|
|
getDigitalOutput() {
|
|
const out = { channels: {} };
|
|
for (const [key, ch] of this.channels) out.channels[key] = ch.getOutput();
|
|
return out;
|
|
}
|
|
|
|
// --- public commands ---
|
|
|
|
set inputValue(v) {
|
|
this._inputValue = v;
|
|
if (this.mode === 'analog' && this.analogChannel) {
|
|
this.analogChannel.update(v);
|
|
this.notifyOutputChanged();
|
|
}
|
|
}
|
|
get inputValue() { return this._inputValue ?? 0; }
|
|
|
|
tick() {
|
|
if (this.config?.simulation?.enabled) {
|
|
this.inputValue = this._simulator.step();
|
|
this.simValue = this._simulator.simValue;
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
toggleSimulation() {
|
|
this.config.simulation = this.config.simulation || {};
|
|
this.config.simulation.enabled = !this.config.simulation.enabled;
|
|
}
|
|
|
|
toggleOutlierDetection() {
|
|
this.config.outlierDetection = this.config.outlierDetection || {};
|
|
this.config.outlierDetection.enabled = !Boolean(this.config.outlierDetection.enabled);
|
|
if (this.analogChannel) this.analogChannel.outlierDetection.enabled = this.config.outlierDetection.enabled;
|
|
}
|
|
|
|
calibrate() {
|
|
const result = this._calibrator.calibrate(this.analogChannel?.outputAbs ?? 0);
|
|
if (result && typeof result.offset === 'number') {
|
|
this.config.scaling.offset = result.offset;
|
|
if (this.analogChannel) this.analogChannel.scaling.offset = result.offset;
|
|
}
|
|
}
|
|
|
|
// Legacy shape: <2 samples returns bare `false`; otherwise the
|
|
// {isStable, stdDev} object the calibrator produces.
|
|
isStable() {
|
|
if ((this.storedValues?.length ?? 0) < 2) return false;
|
|
return this._calibrator.isStable();
|
|
}
|
|
|
|
evaluateRepeatability() {
|
|
const { repeatability } = this._calibrator.evaluateRepeatability();
|
|
return repeatability;
|
|
}
|
|
|
|
// --- analog pipeline delegates (preserved for tests + back-compat) ---
|
|
|
|
calculateInput(value) {
|
|
if (!this.analogChannel) return;
|
|
this.analogChannel.update(value);
|
|
this.notifyOutputChanged();
|
|
}
|
|
|
|
applyOffset(value) { return value + (this.config.scaling?.offset ?? 0); }
|
|
constrain(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
|
|
interpolateLinear(n, iMin, iMax, oMin, oMax) {
|
|
if (iMin >= iMax || oMin >= oMax) return n;
|
|
return oMin + ((n - iMin) * (oMax - oMin)) / (iMax - iMin);
|
|
}
|
|
handleScaling(value) {
|
|
if (!this.analogChannel) return value;
|
|
const out = this.analogChannel._applyScaling(value);
|
|
// Channel mutates its own scaling copy when inputRange is invalid;
|
|
// mirror that back to config.scaling so the legacy contract holds.
|
|
this.config.scaling.inputMin = this.analogChannel.scaling.inputMin;
|
|
this.config.scaling.inputMax = this.analogChannel.scaling.inputMax;
|
|
return out;
|
|
}
|
|
outlierDetection(value) {
|
|
if (!this.analogChannel) return false;
|
|
// Channel skips outlier checks when disabled; the legacy test API expects
|
|
// the check to run regardless of the enabled flag.
|
|
return this.analogChannel._isOutlier(value);
|
|
}
|
|
updateOutputPercent(value) { return this.analogChannel?._computePercent(value) ?? 0; }
|
|
|
|
// --- output / status ---
|
|
|
|
getOutput() {
|
|
if (this.mode === 'digital') return this.getDigitalOutput();
|
|
return {
|
|
mAbs: this.outputAbs,
|
|
mPercent: this.outputPercent,
|
|
totalMinValue: this.totalMinValue === Infinity ? 0 : this.totalMinValue,
|
|
totalMaxValue: this.totalMaxValue === -Infinity ? 0 : this.totalMaxValue,
|
|
totalMinSmooth: this.totalMinSmooth,
|
|
totalMaxSmooth: this.totalMaxSmooth,
|
|
};
|
|
}
|
|
|
|
getStatusBadge() {
|
|
if (this.mode === 'digital') {
|
|
return statusBadge.compose([`digital · ${this.channels.size} channel(s)`], { fill: 'blue', shape: 'ring' });
|
|
}
|
|
const unit = this.config?.general?.unit || '';
|
|
return statusBadge.compose([`${this.outputAbs} ${unit}`.trim()], { fill: 'green', shape: 'dot' });
|
|
}
|
|
}
|
|
|
|
module.exports = Measurement;
|