forked from RnD/measurement
595 lines
18 KiB
JavaScript
595 lines
18 KiB
JavaScript
/**
|
|
* @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;
|
|
}
|
|
|
|
// */ |