2025-05-14 10:31:50 +02:00
/ * *
* @ 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 :
2025-06-25 17:25:13 +02:00
* - r . de . ren @ brabantsedelta . nl
2025-05-14 10:31:50 +02:00
*
* 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' ) ;
2025-06-20 17:14:22 +02:00
const { logger , configUtils , configManager } = require ( 'generalFunctions' ) ;
2025-05-14 10:31:50 +02:00
class Measurement {
constructor ( config = { } ) {
this . emitter = new EventEmitter ( ) ; // Own EventEmitter
2025-06-20 17:14:22 +02:00
this . configManager = new configManager ( ) ;
this . defaultConfig = this . configManager . getConfig ( 'measurement' ) ;
this . configUtils = new configUtils ( this . defaultConfig ) ;
2025-05-14 10:31:50 +02:00
this . config = this . configUtils . initConfig ( config ) ;
// Init after config is set
2025-06-12 17:05:28 +02:00
this . logger = new logger ( this . config . general . logging . enabled , this . config . general . logging . logLevel , this . config . general . name ) ;
2025-05-14 10:31:50 +02:00
// 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;
}
// */