diff --git a/src/nodeClass.js b/src/nodeClass.js index 2893b47..ed55510 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -1,229 +1,41 @@ -/** - * measurement.class.js - * - * Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use. - * This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers. - */ -const { outputUtils, configManager } = require('generalFunctions'); -const Specific = require("./specificClass"); +'use strict'; +const { BaseNodeAdapter } = require('generalFunctions'); +const Measurement = require('./specificClass'); +const commands = require('./commands'); -class nodeClass { - /** - * Create a MeasurementNode. - * @param {object} uiConfig - Node-RED node configuration. - * @param {object} RED - Node-RED runtime API. - * @param {object} nodeInstance - The Node-RED node instance. - * @param {string} nameOfNode - The name of the node, used for - */ - constructor(uiConfig, RED, nodeInstance, nameOfNode) { +class nodeClass extends BaseNodeAdapter { + static DomainClass = Measurement; + static commands = commands; + // Tick drives the simulator's random walk when enabled. Disabled mode is + // event-driven via the `output-changed` emit from the analog Channel. + static tickInterval = 1000; + static statusInterval = 1000; - // Preserve RED reference for HTTP endpoints if needed - this.node = nodeInstance; - this.RED = RED; - this.name = nameOfNode; - - // Load default & UI config - this._loadConfig(uiConfig,this.node); - - // Instantiate core Measurement class - this._setupSpecificClass(); - - // Wire up event and lifecycle handlers - this._bindEvents(); - this._registerChild(); - this._startTickLoop(); - this._attachInputHandler(); - this._attachCloseHandler(); - } - - /** - * Load and merge default config with user-defined settings. - * Uses ConfigManager.buildConfig() for base sections (general, asset, functionality), - * then adds measurement-specific domain config. - * @param {object} uiConfig - Raw config from Node-RED UI. - */ - _loadConfig(uiConfig,node) { - const cfgMgr = new configManager(); - this.defaultConfig = cfgMgr.getConfig(this.name); - - // Build config: base sections + measurement-specific domain config - // `channels` (digital mode) is stored on the UI as a JSON string to - // avoid requiring a custom editor table widget at first. We parse here; - // invalid JSON is logged and the node falls back to an empty array. + buildDomainConfig(uiConfig, _nodeId) { let channels = []; if (typeof uiConfig.channels === 'string' && uiConfig.channels.trim()) { try { channels = JSON.parse(uiConfig.channels); } - catch (e) { node.warn(`Invalid channels JSON: ${e.message}`); channels = []; } + catch (e) { this.node.warn(`Invalid channels JSON: ${e.message}`); channels = []; } } else if (Array.isArray(uiConfig.channels)) { channels = uiConfig.channels; } const mode = (typeof uiConfig.mode === 'string' && uiConfig.mode.toLowerCase() === 'digital') ? 'digital' : 'analog'; - this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, { + return { scaling: { enabled: uiConfig.scaling, inputMin: uiConfig.i_min, inputMax: uiConfig.i_max, absMin: uiConfig.o_min, absMax: uiConfig.o_max, - offset: uiConfig.i_offset - }, - smoothing: { - smoothWindow: uiConfig.count, - smoothMethod: uiConfig.smooth_method - }, - simulation: { - enabled: uiConfig.simulator + offset: uiConfig.i_offset, }, + smoothing: { smoothWindow: uiConfig.count, smoothMethod: uiConfig.smooth_method }, + simulation: { enabled: uiConfig.simulator }, mode: { current: mode }, channels, - }); - - // Utility for formatting outputs - this._output = new outputUtils(); - } - - /** - * Instantiate the core logic and store as source. - */ - _setupSpecificClass() { - this.source = new Specific(this.config); - this.node.source = this.source; // Store the source in the node instance for easy access - } - - /** - * Bind Measurement events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES - */ - _bindEvents() { - - // Analog mode: the classic 'mAbs' event pushes a green dot with the - // current value + unit to the editor. - this.source.emitter.on('mAbs', (val) => { - this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` }); - }); - - // Digital mode: summarise how many channels have ticked a value. - // This runs on every accepted channel update so the editor shows live - // activity instead of staying blank when no single scalar exists. - if (this.source.mode === 'digital') { - this.node.status({ fill: 'blue', shape: 'ring', text: `digital · ${this.source.channels.size} channel(s)` }); - } - } - - /** - * Register this node as a child upstream and downstream. - * Delayed to avoid Node-RED startup race conditions. - */ - _registerChild() { - setTimeout(() => { - this.node.send([ - null, - null, - { topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null}, - ]); - }, 100); - } - - /** - * Start the periodic tick loop to drive the Measurement class. - */ - _startTickLoop() { - setTimeout(() => { - this._tickInterval = setInterval(() => this._tick(), 1000); - }, 1000); - } - - /** - * Execute a single tick: update measurement, format and send outputs. - */ - _tick() { - this.source.tick(); - - // In digital mode we don't funnel through calculateInput with a single - // scalar; instead each Channel has already emitted into the - // MeasurementContainer on message arrival. The tick payload carries a - // per-channel snapshot so downstream flows still see a heartbeat. - const raw = (this.source.mode === 'digital') - ? this.source.getDigitalOutput() - : this.source.getOutput(); - const processMsg = this._output.formatMsg(raw, this.source.config, 'process'); - const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); - - // Send only updated outputs on ports 0 & 1 - this.node.send([processMsg, influxMsg]); - } - - /** - * Attach the node's input handler, routing control messages to the class. - */ - _attachInputHandler() { - this.node.on('input', (msg, send, done) => { - try { - switch (msg.topic) { - case 'simulator': - this.source.toggleSimulation(); - break; - case 'outlierDetection': - this.source.toggleOutlierDetection(); - break; - case 'calibrate': - this.source.calibrate(); - break; - case 'measurement': - // Dispatch based on mode: - // analog -> scalar payload (number or numeric string) - // digital -> object payload keyed by channel name - if (this.source.mode === 'digital') { - if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) { - const summary = this.source.handleDigitalPayload(msg.payload); - // Summarise what actually got accepted on the node status so - // the editor shows a heartbeat per message. - const accepted = Object.values(summary).filter((s) => s.ok).length; - const total = Object.keys(summary).length; - this.node.status({ fill: 'green', shape: 'dot', - text: `digital · ${accepted}/${total} ch updated` }); - } else if (typeof msg.payload === 'number') { - // Helpful hint: the user probably configured the wrong mode. - this.source.logger?.warn(`digital mode received a number (${msg.payload}); expected an object like {key: value, ...}. Switch Input Mode to 'analog' in the editor or send an object payload.`); - } else { - this.source.logger?.warn(`digital mode expects an object payload; got ${typeof msg.payload}`); - } - } else { - if (typeof msg.payload === 'number' || (typeof msg.payload === 'string' && msg.payload.trim() !== '')) { - const parsed = Number(msg.payload); - if (!Number.isNaN(parsed)) { - this.source.inputValue = parsed; - } else { - this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`); - } - } else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) { - // Helpful hint: the payload is object-shaped but the node is - // configured analog. Most likely the user wanted digital mode. - const keys = Object.keys(msg.payload).slice(0, 3).join(', '); - this.source.logger?.warn(`analog mode received an object payload (keys: ${keys}). Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric payload.`); - } - } - break; - default: - this.source.logger?.warn(`Unknown topic: ${msg.topic}`); - } - } catch (error) { - this.source.logger?.error(`Input handler failure: ${error.message}`); - } - if (typeof done === 'function') done(); - }); - } - - /** - * Clean up timers and intervals when Node-RED stops the node. - */ - _attachCloseHandler() { - this.node.on('close', (done) => { - clearInterval(this._tickInterval); - //clearInterval(this._statusInterval); - if (typeof done === 'function') done(); - }); + }; } } diff --git a/src/specificClass.js b/src/specificClass.js index a863e2c..299e527 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -1,93 +1,82 @@ -const EventEmitter = require('events'); -const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions'); +'use strict'; + +const { BaseDomain, statusBadge } = require('generalFunctions'); const Channel = require('./channel'); +const Simulator = require('./simulation/simulator'); +const Calibrator = require('./calibration/calibrator'); -/** - * Measurement domain model. - * - * Supports two input modes: - * - `analog` (default): one scalar value per msg.payload. The node runs the - * classic offset / scaling / smoothing / outlier pipeline on it and emits - * exactly one measurement into the MeasurementContainer. This is the - * original behaviour; every existing flow keeps working unchanged. - * - `digital`: msg.payload is an object with many key/value pairs (MQTT / - * IoT style). The node builds one Channel per config.channels entry and - * routes each key through its own mini-pipeline, emitting N measurements - * into the MeasurementContainer from a single input message. - * - * Mode is selected via `config.mode.current`. When no mode config is present - * or mode=analog, the node behaves identically to pre-digital releases. - */ -class Measurement { - constructor(config={}) { +// 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'; - this.emitter = new EventEmitter(); // Own EventEmitter - this.configManager = new configManager(); - this.defaultConfig = this.configManager.getConfig('measurement'); - this.configUtils = new configUtils(this.defaultConfig); - this.config = this.configUtils.initConfig(config); + configure() { + this.mode = (this.config?.mode?.current || 'analog').toLowerCase(); + this.channels = new Map(); - // Init after config is set - this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); - - // General properties - this.measurements = new MeasurementContainer({ - autoConvert: true, - windowSize: this.config.smoothing.smoothWindow - }); - - this.measurements.setChildId(this.config.general.id); - this.measurements.setChildName(this.config.general.name); - - // Smoothing - this.storedValues = []; - - // Simulation - this.simValue = 0; - - // Internal tracking - this.inputValue = 0; - this.outputAbs = 0; - this.outputPercent = 0; - - // Stability - this.stableThreshold = null; - - //internal variables - this.totalMinValue = Infinity; - this.totalMaxValue = -Infinity; - this.totalMinSmooth = 0; - this.totalMaxSmooth = 0; - - // Scaling - this.inputRange = Math.abs(this.config.scaling.inputMax - this.config.scaling.inputMin); - this.processRange = Math.abs(this.config.scaling.absMax - this.config.scaling.absMin); - - // Mode + multi-channel (digital) support. Backward-compatible: when the - // config does not declare a mode, we fall back to 'analog' and behave - // exactly like the original single-channel node. - this.mode = (this.config.mode && typeof this.config.mode.current === 'string') - ? this.config.mode.current.toLowerCase() - : 'analog'; - this.channels = new Map(); // populated only in digital mode 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.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully. mode=${this.mode} channels=${this.channels.size}`); + 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, + }); } - /** - * Build one Channel per entry in config.channels. Each Channel gets its - * own scaling / smoothing / outlier / position / unit contract; they share - * the parent MeasurementContainer so a downstream parent sees all channels - * via the same emitter. - */ _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.`); + this.logger.warn('digital mode enabled but config.channels is empty; no channels will be emitted.'); return; } for (const raw of entries) { @@ -113,13 +102,8 @@ class Measurement { this.logger.info(`digital mode: built ${this.channels.size} channel(s) from config.channels`); } - /** - * Digital mode entry point. Iterate the object payload, look up each key - * in the channel map, and run the configured pipeline per channel. Keys - * that are not mapped are logged once per call and ignored. - * @param {object} payload - e.g. { temperature: 21.5, humidity: 45.2 } - * @returns {object} summary of updated channels (for diagnostics) - */ + // --- digital passthrough --- + handleDigitalPayload(payload) { if (this.mode !== 'digital') { this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`); @@ -133,10 +117,7 @@ class Measurement { const unknown = []; for (const [key, raw] of Object.entries(payload)) { const channel = this.channels.get(key); - if (!channel) { - unknown.push(key); - continue; - } + 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}`); @@ -146,571 +127,118 @@ class Measurement { 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(', ')}`); - } + if (unknown.length) this.logger.debug(`digital payload contained unmapped keys: ${unknown.join(', ')}`); return summary; } - /** - * Return per-channel output snapshots. In analog mode this is the same - * getOutput() contract; in digital mode it returns one snapshot per - * channel under a `channels` key so the tick output stays JSON-shaped. - */ getDigitalOutput() { const out = { channels: {} }; - for (const [key, ch] of this.channels) { - out.channels[key] = ch.getOutput(); - } + for (const [key, ch] of this.channels) out.channels[key] = ch.getOutput(); return out; } - // -------- Config Initializers -------- // - updateconfig(newConfig) { - this.config = this.configUtils.updateConfig(this.config, newConfig); - } + // --- public commands --- - async tick() { - if (this.config.simulation.enabled) { - this.simulateInput(); + set inputValue(v) { + this._inputValue = v; + if (this.mode === 'analog' && this.analogChannel) { + this.analogChannel.update(v); + this.notifyOutputChanged(); } + } + get inputValue() { return this._inputValue ?? 0; } - this.calculateInput(this.inputValue); + tick() { + if (this.config?.simulation?.enabled) { + this.inputValue = this._simulator.step(); + this.simValue = this._simulator.simValue; + } return Promise.resolve(); } - calibrate() { - - let offset = 0; - - const { isStable } = this.isStable(); - - //first check if the input is stable - if( !isStable ){ - this.logger.warn(`Large fluctuations detected between stored values. Calibration aborted.`); - }else{ - - this.logger.info(`Stable input value detected. Proceeding with calibration.`); - - // offset should be the difference between the input and the output - if(this.config.scaling.enabled){ - offset = this.config.scaling.inputMin - this.outputAbs; - } else { - offset = this.config.scaling.absMin - this.outputAbs; - } - - this.config.scaling.offset = offset; - this.logger.info(`Calibration completed. Offset set to ${offset}`); - } - } - - isStable() { - const marginFactor = 2; // or 3, depending on strictness - let stableThreshold = 0; - - if (this.storedValues.length < 2) return false; - const stdDev = this.standardDeviation(this.storedValues); - stableThreshold = stdDev * marginFactor; - - return { isStable: ( stdDev < stableThreshold || stdDev == 0) , stdDev} ; - } - - evaluateRepeatability() { - - const { isStable, stdDev } = this.isStable(); - - if(this.config.smoothing.smoothMethod == 'none'){ - this.logger.warn('Repeatability evaluation is not possible without smoothing.'); - return null; - } - - if (this.storedValues.length < 2) { - this.logger.warn('Not enough data to evaluate repeatability.'); - return null; - } - - if( isStable == false){ - this.logger.warn('Data not stable enough to evaluate repeatability.'); - return null; - } - - const standardDeviation = stdDev - - this.logger.info(`Repeatability evaluated. Standard Deviation: ${stdDev}`); - - return standardDeviation; - } - - simulateInput() { - - // Simulate input value - const absMax = this.config.scaling.absMax; - const absMin = this.config.scaling.absMin; - const inputMin = this.config.scaling.inputMin; - const inputMax = this.config.scaling.inputMax; - const sign = Math.random() < 0.5 ? -1 : 1; - let maxStep = 0; - - switch ( this.config.scaling.enabled ) { - case true: - - maxStep = this.inputRange > 0 ? this.inputRange * 0.05 : 1; - - if (this.simValue < inputMin || this.simValue > inputMax) { - this.logger.warn(`Simulated value ${this.simValue} is outside of input range constraining between min=${inputMin} and max=${inputMax}`); - this.simValue = this.constrain(this.simValue, inputMin, inputMax); - } - break; - case false: - - maxStep = this.processRange > 0 ? this.processRange * 0.05 : 1; - - if (this.simValue < absMin || this.simValue > absMax) { - this.logger.warn(`Simulated value ${this.simValue} is outside of abs range constraining between min=${absMin} and max=${absMax}`); - this.simValue = this.constrain(this.simValue, absMin, absMax); - } - break; - } - - this.simValue += sign * Math.random() * maxStep; - - this.inputValue = this.simValue; - - } - - outlierDetection(val) { - if (this.storedValues.length < 2) return false; - - // Config enum values are normalized to lowercase by validateEnum in - // generalFunctions, so dispatch on the lowercase form to keep this - // tolerant of both legacy (camelCase) and normalized (lowercase) config. - const raw = this.config.outlierDetection.method; - const method = typeof raw === 'string' ? raw.toLowerCase() : raw; - - this.logger.debug(`Outlier detection method: ${method}`); - - switch (method) { - case 'zscore': - return this.zScoreOutlierDetection(val); - case 'iqr': - return this.iqrOutlierDetection(val); - case 'modifiedzscore': - return this.modifiedZScoreOutlierDetection(val); - default: - this.logger.warn(`Outlier detection method "${raw}" is not recognized.`); - return false; - } - } - - zScoreOutlierDetection(val) { - const threshold = this.config.outlierDetection.threshold || 3; - const mean = this.mean(this.storedValues); - const stdDev = this.standardDeviation(this.storedValues); - const zScore = (val - mean) / stdDev; - - if (Math.abs(zScore) > threshold) { - this.logger.warn(`Outlier detected using Z-Score method. Z-score=${zScore}`); - return true; - } - - return false; - } - - iqrOutlierDetection(val) { - const sortedValues = [...this.storedValues].sort((a, b) => a - b); - const q1 = sortedValues[Math.floor(sortedValues.length / 4)]; - const q3 = sortedValues[Math.floor(sortedValues.length * 3 / 4)]; - const iqr = q3 - q1; - const lowerBound = q1 - 1.5 * iqr; - const upperBound = q3 + 1.5 * iqr; - - if (val < lowerBound || val > upperBound) { - this.logger.warn(`Outlier detected using IQR method. Value=${val}`); - return true; - } - - return false; - } - - modifiedZScoreOutlierDetection(val) { - const median = this.medianFilter(this.storedValues); - const mad = this.medianFilter(this.storedValues.map(v => Math.abs(v - median))); - const modifiedZScore = 0.6745 * (val - median) / mad; - const threshold = this.config.outlierDetection.threshold || 3.5; - - if (Math.abs(modifiedZScore) > threshold) { - this.logger.warn(`Outlier detected using Modified Z-Score method. Modified Z-Score=${modifiedZScore}`); - return true; - } - - return false; - } - - calculateInput(value) { - - // Check if the value is an outlier and check if outlier detection is enabled - if (this.config.outlierDetection.enabled) { - if ( this.outlierDetection(value) ){ - this.logger.warn(`Outlier detected. Ignoring value=${value}`); - return; - } - } - - // Apply offset - let val = this.applyOffset(value); - - // Track raw min/max - this.updateMinMaxValues(val); - - // Handle scaling if enabled - if (this.config.scaling.enabled) { - val = this.handleScaling(val); - } - - // Apply smoothing - const smoothed = this.applySmoothing(val); - - // Update smoothed min/max and output - this.updateSmoothMinMaxValues(smoothed); - this.updateOutputAbs(smoothed); - } - - applyOffset(value) { - return value + this.config.scaling.offset; - } - - handleScaling(value) { - // Check if input range is valid - if (this.inputRange <= 0) { - this.logger.warn(`Input range is invalid. Falling back to default range [0, 1].`); - this.config.scaling.inputMin = 0; - this.config.scaling.inputMax = 1; - this.inputRange = this.config.scaling.inputMax - this.config.scaling.inputMin; - } - - // Constrain value within input range - if (value < this.config.scaling.inputMin || value > this.config.scaling.inputMax) { - this.logger.warn(`Value=${value} is outside of INPUT range. Constraining.`); - value = this.constrain(value, this.config.scaling.inputMin, this.config.scaling.inputMax); - } - - // Interpolate value - this.logger.debug(`Interpolating value=${value} between min=${this.config.scaling.inputMin} and max=${this.config.scaling.inputMax} to absMin=${this.config.scaling.absMin} and absMax=${this.config.scaling.absMax}`); - return this.interpolateLinear(value, this.config.scaling.inputMin, this.config.scaling.inputMax, this.config.scaling.absMin, this.config.scaling.absMax); - } - - constrain(input, inputMin , inputMax) { - this.logger.warn(`New value=${input} is constrained to fit between min=${inputMin} and max=${inputMax}`); - return Math.min(Math.max(input, inputMin), inputMax); - } - - interpolateLinear(iNumber, iMin, iMax, oMin, oMax) { - if (iMin >= iMax || oMin >= oMax) { - this.logger.warn(`Invalid input for linear interpolation iMin=${JSON.stringify(iMin)} iMax=${iMax} oMin=${JSON.stringify(oMin)} oMax=${oMax}`); - return iNumber; - } - - const range = iMax - iMin; - return oMin + ((iNumber - iMin) * (oMax - oMin)) / range; - } - - applySmoothing(value) { - - this.storedValues.push(value); - - // Maintain only the latest 'smoothWindow' number of values - if (this.storedValues.length > this.config.smoothing.smoothWindow) { - this.storedValues.shift(); - } - - // Smoothing strategies keyed by the normalized (lowercase) method name. - // validateEnum in generalFunctions lowercases enum values, so dispatch on - // the lowercase form to accept both legacy (camelCase) and normalized - // (lowercase) config values. - const smoothingMethods = { - none: (arr) => arr[arr.length - 1], - mean: (arr) => this.mean(arr), - min: (arr) => this.min(arr), - max: (arr) => this.max(arr), - sd: (arr) => this.standardDeviation(arr), - lowpass: (arr) => this.lowPassFilter(arr), - highpass: (arr) => this.highPassFilter(arr), - weightedmovingaverage: (arr) => this.weightedMovingAverage(arr), - bandpass: (arr) => this.bandPassFilter(arr), - median: (arr) => this.medianFilter(arr), - kalman: (arr) => this.kalmanFilter(arr), - savitzkygolay: (arr) => this.savitzkyGolayFilter(arr), - }; - - const raw = this.config.smoothing.smoothMethod; - const method = typeof raw === 'string' ? raw.toLowerCase() : raw; - this.logger.debug(`Applying smoothing method "${method}"`); - - if (!smoothingMethods[method]) { - this.logger.error(`Smoothing method "${raw}" is not implemented.`); - return value; - } - - // Apply the smoothing method - return smoothingMethods[method](this.storedValues); - } - - standardDeviation(values) { - if (values.length <= 1) return 0; - const mean = values.reduce((a, b) => a + b, 0) / values.length; - const sqDiffs = values.map(v => (v - mean) ** 2); - const variance = sqDiffs.reduce((a, b) => a + b, 0) / (values.length - 1); - return Math.sqrt(variance); - } - - savitzkyGolayFilter(arr) { - const coefficients = [-3, 12, 17, 12, -3]; // Example coefficients for 5-point smoothing - const normFactor = coefficients.reduce((a, b) => a + b, 0); - - if (arr.length < coefficients.length) { - return arr[arr.length - 1]; // Return last value if array is too small - } - - let smoothed = 0; - for (let i = 0; i < coefficients.length; i++) { - smoothed += arr[arr.length - coefficients.length + i] * coefficients[i]; - } - - return smoothed / normFactor; - } - - kalmanFilter(arr) { - let estimate = arr[0]; - const measurementNoise = 1; // Adjust based on your sensor's characteristics - const processNoise = 0.1; // Adjust based on signal variability - const kalmanGain = processNoise / (processNoise + measurementNoise); - - for (let i = 1; i < arr.length; i++) { - estimate = estimate + kalmanGain * (arr[i] - estimate); - } - - return estimate; - } - - medianFilter(arr) { - const sorted = [...arr].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - - return sorted.length % 2 !== 0 - ? sorted[middle] - : (sorted[middle - 1] + sorted[middle]) / 2; - } - - bandPassFilter(arr) { - const lowPass = this.lowPassFilter(arr); // Apply low-pass filter - const highPass = this.highPassFilter(arr); // Apply high-pass filter - - return arr.map((val, _idx) => lowPass + highPass - val).pop(); // Combine the filters - } - - weightedMovingAverage(arr) { - const weights = arr.map((_, i) => i + 1); // Weights increase linearly - const weightedSum = arr.reduce((sum, val, idx) => sum + val * weights[idx], 0); - const weightTotal = weights.reduce((sum, weight) => sum + weight, 0); - - return weightedSum / weightTotal; - } - - highPassFilter(arr) { - const alpha = 0.8; // Smoothing factor (0 < alpha <= 1) - let filteredValues = []; - filteredValues[0] = arr[0]; - - for (let i = 1; i < arr.length; i++) { - filteredValues[i] = alpha * (filteredValues[i - 1] + arr[i] - arr[i - 1]); - } - - return filteredValues[filteredValues.length - 1]; - } - - lowPassFilter(arr) { - const alpha = 0.2; // Smoothing factor (0 < alpha <= 1) - let smoothedValue = arr[0]; - - for (let i = 1; i < arr.length; i++) { - smoothedValue = alpha * arr[i] + (1 - alpha) * smoothedValue; - } - - return smoothedValue; - } - - // Or also EMA called exponential moving average - recursiveLowpassFilter() { - - } - - mean(arr) { - return arr.reduce((a, b) => a + b, 0) / arr.length; - } - - min(arr) { - return Math.min(...arr); - } - - max(arr) { - return Math.max(...arr); - } - - updateMinMaxValues(value) { - if (value < this.totalMinValue) { - this.totalMinValue = value; - } - if (value > this.totalMaxValue) { - this.totalMaxValue = value; - } - } - - updateSmoothMinMaxValues(value) { - // If this is the first run, initialize them - if (this.totalMinSmooth === 0 && this.totalMaxSmooth === 0) { - this.totalMinSmooth = value; - this.totalMaxSmooth = value; - } - if (value < this.totalMinSmooth) { - this.totalMinSmooth = value; - } - if (value > this.totalMaxSmooth) { - this.totalMaxSmooth = value; - } - } - - updateOutputAbs(val) { - - // Constrain first, then check for changes - let constrainedVal = val; - - if (val < this.config.scaling.absMin || val > this.config.scaling.absMax) { - this.logger.warn(`Output value=${val} is outside of ABS range. Constraining.`); - constrainedVal = this.constrain(val, this.config.scaling.absMin, this.config.scaling.absMax); - } - - const roundedVal = Math.round(constrainedVal * 100) / 100; - - //only update on change - if (roundedVal != this.outputAbs) { - - // Constrain value within process range - if (val < this.config.scaling.absMin || val > this.config.scaling.absMax) { - this.logger.warn(`Output value=${val} is outside of ABS range. Constraining.`); - val = this.constrain(val, this.config.scaling.absMin, this.config.scaling.absMax); - } - - this.outputAbs = Math.round(val * 100) / 100; - this.outputPercent = this.updateOutputPercent(val); - - this.emitter.emit('mAbs', this.outputAbs);// DEPRECATED: Use measurements container instead - - this.logger.debug(`Updating type: ${this.config.asset.type}, variant: ${"measured"}, postition : ${this.config.functionality.positionVsParent} container with new value: ${this.outputAbs}`); - this.measurements.type(this.config.asset.type).variant("measured").position(this.config.functionality.positionVsParent).distance(this.config.functionality.distance).value(this.outputAbs, Date.now(),this.config.asset.unit ); - } - } - - updateOutputPercent(value) { - - let outputPercent; - - if (this.processRange <= 0) { - this.logger.debug(`Process range is smaller or equal to 0 interpolating between input range`); - outputPercent = this.interpolateLinear( value, this.totalMinValue, this.totalMaxValue, this.config.interpolation.percentMin, this.config.interpolation.percentMax ); - } - else { - outputPercent = this.interpolateLinear( value, this.config.scaling.absMin, this.config.scaling.absMax, this.config.interpolation.percentMin, this.config.interpolation.percentMax ); - } - - return Math.round(outputPercent * 100) / 100; - } - - toggleSimulation(){ + toggleSimulation() { + this.config.simulation = this.config.simulation || {}; this.config.simulation.enabled = !this.config.simulation.enabled; } toggleOutlierDetection() { - // Keep the outlier configuration shape stable and only toggle the enabled flag. - const currentState = Boolean(this.config?.outlierDetection?.enabled); this.config.outlierDetection = this.config.outlierDetection || {}; - this.config.outlierDetection.enabled = !currentState; + 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, - totalMaxValue: this.totalMaxValue, + 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; - -/* -// Testing the class -const configuration = { - general: { - name: "PT1", - logging: { - enabled: true, - logLevel: "debug", - }, - }, - scaling:{ - enabled: true, - inputMin: 0, - inputMax: 3000, - absMin: 500, - absMax: 4000, - offset: 1000 - }, - asset: { - type: "pressure", - unit: "bar", - category: "measurement", - model: "PT1", - uuid: "123e4567-e89b-12d3-a456-426614174000", - tagCode: "PT1-001", - supplier: "DeltaTech" - }, - smoothing: { - smoothWindow: 10, - smoothMethod: 'mean', - }, - simulation: { - enabled: true, - }, - functionality: { - positionVsParent: POSITIONS.UPSTREAM - } -}; - - -const m = new Measurement(configuration); - -m.logger.info(`Measurement created with config : ${JSON.stringify(m.config)}`); - -m.logger.setLogLevel("debug"); - -//look for flow updates -m.measurements.emitter.on('pressure.measured.upstream', (newVal) => { - m.logger.info(`Received : ${newVal.value} ${newVal.unit}`); - const repeatability = m.evaluateRepeatability(); - if (repeatability !== null) { - m.logger.info(`Current repeatability (standard deviation): ${repeatability}`); - } -}); - -const tickLoop = setInterval(changeInput,1000); - -function changeInput(){ - m.logger.info(`tick...`); - m.tick(); - //m.inputValue = 5; -} - -// */ diff --git a/test/basic/nodeclass-routing.basic.test.js b/test/basic/nodeclass-routing.basic.test.js index 416b87f..a255148 100644 --- a/test/basic/nodeclass-routing.basic.test.js +++ b/test/basic/nodeclass-routing.basic.test.js @@ -2,29 +2,40 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const NodeClass = require('../../src/nodeClass'); +const commands = require('../../src/commands'); +const { createRegistry } = require('generalFunctions'); const { makeNodeStub, makeREDStub } = require('../helpers/factories'); -test('_attachInputHandler routes known topics to source methods', () => { +// These tests pinned the old private methods (_attachInputHandler / +// _registerChild) on the pre-refactor nodeClass. After the BaseNodeAdapter +// migration the same wiring is provided by the base class, but we still +// exercise it from a prototype-derived instance to keep the surface covered +// without booting a full Node-RED runtime. + +test('input handler dispatches known topics to source methods', async () => { const inst = Object.create(NodeClass.prototype); const node = makeNodeStub(); const calls = []; - - inst.node = node; - inst.RED = makeREDStub(); - inst.source = { + const source = { + mode: 'analog', + logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }, toggleSimulation() { calls.push('simulator'); }, toggleOutlierDetection() { calls.push('outlierDetection'); }, calibrate() { calls.push('calibrate'); }, set inputValue(v) { calls.push(['measurement', v]); }, }; + inst.node = node; + inst.RED = makeREDStub(); + inst.source = source; + inst._commands = createRegistry(commands, { logger: source.logger }); inst._attachInputHandler(); - const onInput = node._handlers.input; - onInput({ topic: 'simulator' }, () => {}, () => {}); - onInput({ topic: 'outlierDetection' }, () => {}, () => {}); - onInput({ topic: 'calibrate' }, () => {}, () => {}); - onInput({ topic: 'measurement', payload: 12.3 }, () => {}, () => {}); + const onInput = node._handlers.input; + await onInput({ topic: 'simulator' }, () => {}, () => {}); + await onInput({ topic: 'outlierDetection' }, () => {}, () => {}); + await onInput({ topic: 'calibrate' }, () => {}, () => {}); + await onInput({ topic: 'measurement', payload: 12.3 }, () => {}, () => {}); assert.deepEqual(calls[0], 'simulator'); assert.deepEqual(calls[1], 'outlierDetection'); @@ -32,7 +43,7 @@ test('_attachInputHandler routes known topics to source methods', () => { assert.deepEqual(calls[3], ['measurement', 12.3]); }); -test('_registerChild emits delayed registerChild message on output 2', () => { +test('registration emits delayed child.register message on output 2', () => { const inst = Object.create(NodeClass.prototype); const node = makeNodeStub(); @@ -42,13 +53,13 @@ test('_registerChild emits delayed registerChild message on output 2', () => { const originalSetTimeout = global.setTimeout; global.setTimeout = (fn) => { fn(); return 1; }; try { - inst._registerChild(); + inst._scheduleRegistration(); } finally { global.setTimeout = originalSetTimeout; } assert.equal(node._sent.length, 1); - assert.equal(node._sent[0][2].topic, 'registerChild'); + assert.equal(node._sent[0][2].topic, 'child.register'); assert.equal(node._sent[0][2].positionVsParent, 'upstream'); assert.equal(node._sent[0][2].distance, 5); }); diff --git a/test/edge/invalid-payload.edge.test.js b/test/edge/invalid-payload.edge.test.js index 09a26f8..51a12c6 100644 --- a/test/edge/invalid-payload.edge.test.js +++ b/test/edge/invalid-payload.edge.test.js @@ -2,27 +2,32 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const NodeClass = require('../../src/nodeClass'); +const commands = require('../../src/commands'); +const { createRegistry } = require('generalFunctions'); const { makeNodeStub, makeREDStub } = require('../helpers/factories'); -test('measurement topic accepts numeric strings and ignores non-numeric objects', () => { +test('measurement topic accepts numeric strings and ignores non-numeric objects', async () => { const inst = Object.create(NodeClass.prototype); const node = makeNodeStub(); const calls = []; - - inst.node = node; - inst.RED = makeREDStub(); - inst.source = { + const source = { + mode: 'analog', + logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }, set inputValue(v) { calls.push(v); }, toggleSimulation() {}, toggleOutlierDetection() {}, calibrate() {}, }; + inst.node = node; + inst.RED = makeREDStub(); + inst.source = source; + inst._commands = createRegistry(commands, { logger: source.logger }); inst._attachInputHandler(); - const onInput = node._handlers.input; - onInput({ topic: 'measurement', payload: '42' }, () => {}, () => {}); - onInput({ topic: 'measurement', payload: { value: 42 } }, () => {}, () => {}); + const onInput = node._handlers.input; + await onInput({ topic: 'measurement', payload: '42' }, () => {}, () => {}); + await onInput({ topic: 'measurement', payload: { value: 42 } }, () => {}, () => {}); assert.deepEqual(calls, [42]); });