/** * @file Measurement.js * * Permission is hereby granted to any person obtaining a copy of this software * and associated documentation files (the "Software"), to use it for personal * or non-commercial purposes, with the following restrictions: * * 1. **No Copying or Redistribution**: The Software or any of its parts may not * be copied, merged, distributed, sublicensed, or sold without explicit * prior written permission from the author. * * 2. **Commercial Use**: Any use of the Software for commercial purposes requires * a valid license, obtainable only with the explicit consent of the author. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, * OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * * Ownership of this code remains solely with the original author. Unauthorized * use of this Software is strictly prohibited. * * Author: * - Rene De Ren * Email: * - rene@thegoldenbasket.nl * * Future Improvements: * - Time-based stability checks * - Warmup handling * - Dynamic outlier detection thresholds * - Dynamic smoothing window and methods * - Alarm and threshold handling * - Maintenance mode * - Historical data and trend analysis */ const EventEmitter = require('events'); const {logger,configUtils,configManager} = require('generalFunctions'); class Measurement { constructor(config={}) { 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); // Init after config is set this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, 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); this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully.`); } // -------- Config Initializers -------- // updateconfig(newConfig) { this.config = this.configUtils.updateConfig(this.config, newConfig); } async tick() { if (this.config.simulation.enabled) { this.simulateInput(); } this.calculateInput(this.inputValue); 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; this.logger.debug(`Outlier detection method: ${this.config.outlierDetection.method}`); switch (this.config.outlierDetection.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 "${this.config.outlierDetection.method}" 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 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), }; // Ensure the smoothing method is valid const method = this.config.smoothing.smoothMethod; this.logger.debug(`Applying smoothing method "${method}"`); if (!smoothingMethods[method]) { this.logger.error(`Smoothing method "${method}" 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) { //only update on change if(val != 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.logger.debug(`[DEBUG] Emitting mAbs=${this.outputAbs}, Current listeners:`, this.emitter.eventNames()); this.emitter.emit('mAbs', this.outputAbs); } } 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(){ this.config.simulation.enabled = !this.config.simulation.enabled; } toggleOutlierDetection() { this.config.outlierDetection = !this.config.outlierDetection; } getOutput() { return { mAbs: this.outputAbs, mPercent: this.outputPercent, totalMinValue: this.totalMinValue, totalMaxValue: this.totalMaxValue, totalMinSmooth: this.totalMinSmooth, totalMaxSmooth: this.totalMaxSmooth, }; } } 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 }, smoothing: { smoothWindow: 10, smoothMethod: 'mean', }, simulation: { enabled: true, } }; const m = new Measurement(configuration); m.logger.info(`Measurement created with config : ${JSON.stringify(m.config)}`); m.logger.setLogLevel("debug"); m.emitter.on('mAbs', (val) => { m.logger.info(`Received : ${val}`); 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; } // */