diff --git a/LICENSE b/LICENSE index 1fa1b39..a9c460f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,15 @@ MIT License -Copyright (c) 2025 RnD +Copyright (c) 2025 Rene De Ren -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to use, +copy, modify, merge, publish, and distribute the Software for **personal, scientific, or educational purposes**, subject to the following conditions: + +**Commercial use of the Software or any derivative work is explicitly prohibited without prior written consent from the authors.** +This includes but is not limited to resale, inclusion in paid products or services, and monetized distribution. +Any commercial usage must be governed by a shared license or explicit contractual agreement with the authors. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -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. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED... diff --git a/dependencies/measurement/measurement.js b/dependencies/measurement/measurement.js new file mode 100644 index 0000000..9acfb3b --- /dev/null +++ b/dependencies/measurement/measurement.js @@ -0,0 +1,595 @@ +/** + * @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 = require('../../../generalFunctions/helper/logger'); +const defaultConfig = require('./measurementConfig.json'); +const ConfigUtils = require('../../../generalFunctions/helper/configUtils'); + +class Measurement { + constructor(config={}) { + + this.emitter = new EventEmitter(); // Own EventEmitter + this.configUtils = new ConfigUtils(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; +} + +// */ \ No newline at end of file diff --git a/dependencies/measurement/measurementConfig.json b/dependencies/measurement/measurementConfig.json new file mode 100644 index 0000000..05b36bc --- /dev/null +++ b/dependencies/measurement/measurementConfig.json @@ -0,0 +1,357 @@ +{ + "general": { + "name": { + "default": "Measurement Configuration", + "rules": { + "type": "string", + "description": "A human-readable name or label for this measurement configuration." + } + }, + "id": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "A unique identifier for this configuration. If not provided, defaults to null." + } + }, + "unit": { + "default": "unitless", + "rules": { + "type": "string", + "description": "The unit of measurement for this configuration (e.g., 'meters', 'seconds', 'unitless')." + } + }, + "logging": { + "logLevel": { + "default": "info", + "rules": { + "type": "enum", + "values": [ + { + "value": "debug", + "description": "Log messages are printed for debugging purposes." + }, + { + "value": "info", + "description": "Informational messages are printed." + }, + { + "value": "warn", + "description": "Warning messages are printed." + }, + { + "value": "error", + "description": "Error messages are printed." + } + ] + } + }, + "enabled": { + "default": true, + "rules": { + "type": "boolean", + "description": "Indicates whether logging is active. If true, log messages will be generated." + } + } + } + }, + "functionality": { + "softwareType": { + "default": "measurement", + "rules": { + "type": "string", + "description": "Specified software type for this configuration." + } + }, + "role": { + "default": "Sensor", + "rules": { + "type": "string", + "description": "Indicates the role this configuration plays (e.g., sensor, controller, etc.)." + } + } + }, + "asset": { + "uuid": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "Asset tag number which is a universally unique identifier for this asset. May be null if not assigned." + } + }, + "tagCode":{ + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned." + } + }, + "geoLocation": { + "default": { + "x": 0, + "y": 0, + "z": 0 + }, + "rules": { + "type": "object", + "description": "An object representing the asset's physical coordinates or location.", + "schema": { + "x": { + "default": 0, + "rules": { + "type": "number", + "description": "X coordinate of the asset's location." + } + }, + "y": { + "default": 0, + "rules": { + "type": "number", + "description": "Y coordinate of the asset's location." + } + }, + "z": { + "default": 0, + "rules": { + "type": "number", + "description": "Z coordinate of the asset's location." + } + } + } + } + }, + "supplier": { + "default": "Unknown", + "rules": { + "type": "string", + "description": "The supplier or manufacturer of the asset." + } + }, + "type": { + "default": "sensor", + "rules": { + "type": "enum", + "values": [ + { + "value": "sensor", + "description": "A device that detects or measures a physical property and responds to it (e.g. temperature sensor)." + } + ] + } + }, + "subType": { + "default": "pressure", + "rules": { + "type": "string", + "description": "A more specific classification within 'type'. For example, 'pressure' for a pressure sensor." + } + }, + "model": { + "default": "Unknown", + "rules": { + "type": "string", + "description": "A user-defined or manufacturer-defined model identifier for the asset." + } + }, + "accuracy": { + "default": null, + "rules": { + "type": "number", + "nullable": true, + "description": "The accuracy of the sensor, typically represented as a percentage or absolute value." + } + }, + "repeatability": { + "default": null, + "rules": { + "type": "number", + "nullable": true, + "description": "The repeatability of the sensor, typically represented as a percentage or absolute value." + } + } + }, + "scaling": { + "enabled": { + "default": false, + "rules": { + "type": "boolean", + "description": "Indicates whether input scaling is active. If true, input values will be scaled according to the parameters below." + } + }, + "inputMin": { + "default": 0, + "rules": { + "type": "number", + "description": "The minimum expected input value before scaling." + } + }, + "inputMax": { + "default": 1, + "rules": { + "type": "number", + "description": "The maximum expected input value before scaling." + } + }, + "absMin": { + "default": 50, + "rules": { + "type": "number", + "description": "The absolute minimum value that can be read or displayed after scaling." + } + }, + "absMax": { + "default": 100, + "rules": { + "type": "number", + "description": "The absolute maximum value that can be read or displayed after scaling." + } + }, + "offset": { + "default": 0, + "rules": { + "type": "number", + "description": "A constant offset to apply to the scaled output (e.g., to calibrate zero-points)." + } + } + }, + "smoothing": { + "smoothWindow": { + "default": 10, + "rules": { + "type": "number", + "min": 1, + "description": "Determines the size of the data window (number of samples) used for smoothing operations." + } + }, + "smoothMethod": { + "default": "mean", + "rules": { + "type": "enum", + "values": [ + { + "value": "none", + "description": "No smoothing is applied; raw data is passed through." + }, + { + "value": "mean", + "description": "Calculates the simple arithmetic mean (average) of the data points in a window." + }, + { + "value": "min", + "description": "Selects the smallest (minimum) value among the data points in a window." + }, + { + "value": "max", + "description": "Selects the largest (maximum) value among the data points in a window." + }, + { + "value": "sd", + "description": "Computes the standard deviation to measure the variation or spread of the data." + }, + { + "value": "lowPass", + "description": "Filters out high-frequency components, allowing only lower frequencies to pass." + }, + { + "value": "highPass", + "description": "Filters out low-frequency components, allowing only higher frequencies to pass." + }, + { + "value": "weightedMovingAverage", + "description": "Applies varying weights to each data point in a window before averaging." + }, + { + "value": "bandPass", + "description": "Filters the signal to allow only frequencies within a specific range to pass." + }, + { + "value": "median", + "description": "Selects the median (middle) value in a window, minimizing the effect of outliers." + }, + { + "value": "kalman", + "description": "Applies a Kalman filter to combine noisy measurements over time for more accurate estimates." + }, + { + "value": "savitzkyGolay", + "description": "Uses a polynomial smoothing filter on a moving window, which can also provide derivative estimates." + } + ] + } + } + }, + "simulation": { + "enabled": { + "default": false, + "rules": { + "type": "boolean", + "description": "If true, the system operates in simulation mode, generating simulated values instead of using real inputs." + } + }, + "safeCalibrationTime": { + "default": 100, + "rules": { + "type": "number", + "min": 100, + "description": "Time to wait before finalizing calibration in simulation mode (in milliseconds or appropriate unit)." + } + } + }, + "interpolation": { + "percentMin": { + "default": 0, + "rules": { + "type": "number", + "min": 0, + "description": "Minimum percentage for interpolation or data scaling operations." + } + }, + "percentMax": { + "default": 100, + "rules": { + "type": "number", + "max": 100, + "description": "Maximum percentage for interpolation or data scaling operations." + } + } + }, + "outlierDetection": { + "enabled": { + "default": false, + "rules": { + "type": "boolean", + "description": "Indicates whether outlier detection is enabled. If true, outliers will be identified and handled according to the method specified." + } + }, + "method": { + "default": "zScore", + "rules": { + "type": "enum", + "values": [ + { + "value": "zScore", + "description": "Uses the Z-score method to identify outliers based on standard deviations from the mean." + }, + { + "value": "iqr", + "description": "Uses the Interquartile Range (IQR) method to identify outliers based on the spread of the middle 50% of the data." + }, + { + "value": "modifiedZScore", + "description": "Uses a modified Z-score method that is more robust to small sample sizes." + } + ] + } + }, + "threshold": { + "default": 3, + "rules": { + "type": "number", + "description": "The threshold value used by the selected outlier detection method. For example, a Z-score threshold of 3.0." + } + } + } +} \ No newline at end of file diff --git a/measurement.html b/measurement.html new file mode 100644 index 0000000..7a4b4e8 --- /dev/null +++ b/measurement.html @@ -0,0 +1,341 @@ + + + + + + + + diff --git a/measurement.js b/measurement.js new file mode 100644 index 0000000..9c9dae6 --- /dev/null +++ b/measurement.js @@ -0,0 +1,148 @@ +module.exports = function (RED) { + function measurement(config) { + //create node + RED.nodes.createNode(this, config); + //call this => node so whenver you want to call a node function type node and the function behind it + var node = this; + + try{ + + //fetch measurement object from source.js + const Measurement = require("./dependencies/measurement/measurement"); + const OutputUtils = require("../generalFunctions/helper/outputUtils"); + + //load user defined config in the node-red UI + const mConfig={ + general: { + name: config.name, + id: node.id, + unit: config.unit, + logging:{ + logLevel: config.logLevel, + enabled: config.enableLog, + }, + }, + asset: { + tagCode: config.assetTagCode, + supplier: config.supplier, + subType: config.subType, + model: config.model, + }, + scaling:{ + enabled: config.scaling, + inputMin: config.i_min, + inputMax: config.i_max, + absMin: config.o_min, + absMax: config.o_max, + offset: config.i_offset + }, + smoothing: { + smoothWindow: config.count, + smoothMethod: config.smooth_method, + }, + simulation: { + enabled: config.simulator, + }, + } + + //make new measurement on creation to work with. + const m = new Measurement(mConfig); + + // put m on node memory as source + node.source = m; + + //load output utils + const output = new OutputUtils(); + + function updateNodeStatus(val) { + //display status + node.status({ fill: "green", shape: "dot", text: val + " " + mConfig.general.unit }); + } + + //Update status only on event change + m.emitter.on('mAbs', (val) => { + updateNodeStatus(val); + }); + + //never ending functions + function tick(){ + + //kick class ticks for time move + m.tick(); + + //get output + const classOutput = m.getOutput(); + const dbOutput = output.formatMsg(classOutput, m.config, "influxdb"); + const pOutput = output.formatMsg(classOutput, m.config, "process"); + + //only send output on values that changed + let msgs = []; + msgs[0] = pOutput; + msgs[1] = dbOutput; + + node.send(msgs); + + } + + // register child on first output this timeout is needed because of node - red stuff + setTimeout( + () => { + + /*---execute code on first start----*/ + let msgs = []; + + msgs[2] = { topic : "registerChild" , payload: node.id, positionVsParent: "upstream" }; + msgs[3] = { topic : "registerChild" , payload: node.id, positionVsParent: "downstream" }; + + //send msg + this.send(msgs); + }, + 100 + ); + + //declare refresh interval internal node + setTimeout( + () => { + /*---execute code on first start----*/ + this.intervalId = setInterval(function(){ tick() },1000) + }, + 1000 + ); + + //-------------------------------------------------------------------->>what to do on input + node.on("input", function (msg,send,done) { + + if(msg.topic == "simulator" ){ + m.toggleSimulation(); + } + + if(msg.topic == "outlierDetection" ){ + m.toggleOutlierDetection(); + } + + if(msg.topic == "calibrate" ){ + m.calibrate(); + } + + if(msg.topic == "measurement" && typeof msg.payload == "number"){ + //feed input into the measurement node and calculate output + m.inputValue = parseFloat(msg.payload); + } + + done(); + + }); + + // tidy up any async code here - shutdown connections and so on. + node.on('close', function(done) { + if (node.intervalId) clearTimeout(node.intervalId); + if (node.tickInterval) clearInterval(node.tickInterval); + if (done) done(); + }); + + }catch(e){ + console.log(e); + } + } + RED.nodes.registerType("measurement", measurement); +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4762b20 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "measurement", + "version": "1.0.0", + "description": "Control module measurement", + "main": "measurement.js", + "scripts": { + "test": "node measurement.js" + }, + "repository": { + "type": "git", + "url": "https://gitea.centraal.wbd-rd.nl/RnD/measurement.git" + }, + "keywords": [ + "measurement", + "node-red" + ], + "author": "Rene De Ren", + "license": "SEE LICENSE", + "dependencies": { + "generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git", + "convert": "git+https://gitea.centraal.wbd-rd.nl/RnD/convert.git" + }, + "node-red": { + "nodes": { + "measurement": "measurement.js" + } + } +}