'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;