Files
measurement/src/specificClass.js
2025-10-05 09:34:35 +02:00

629 lines
19 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:
* - r.de.ren@brabantsedelta.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,MeasurementContainer} = 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);
// 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);
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) {
// 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(){
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
},
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: "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;
}
// */