first commit

This commit is contained in:
znetsixe
2025-05-14 10:31:50 +02:00
parent 59acfad357
commit a97326af08
6 changed files with 1478 additions and 3 deletions

12
LICENSE
View File

@@ -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...

595
dependencies/measurement/measurement.js vendored Normal file
View File

@@ -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;
}
// */

View File

@@ -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."
}
}
}
}

341
measurement.html Normal file
View File

@@ -0,0 +1,341 @@
<script type="module">
import * as menuUtils from "/generalFunctions/helper/menuUtils.js";
RED.nodes.registerType("measurement", {
category: "digital twin",
color: "#e4a363",
defaults: {
// Define default properties
name: { value: "", required: true },
enableLog: { value: false },
logLevel: { value: "error" },
// Define specific properties
scaling: { value: false },
i_min: { value: 0, required: true },
i_max: { value: 0, required: true },
i_offset: { value: 0 },
o_min: { value: 0, required: true },
o_max: { value: 1, required: true },
simulator: { value: false },
unit: { value: "unit", required: true },
smooth_method: { value: "" },
count: { value: "10", required: true },
//define asset properties
supplier: { value: "" },
subType: { value: "" },
model: { value: "" },
unit: { value: "" },
},
inputs: 1,
outputs: 4,
inputLabels: ["Measurement Input"],
outputLabels: ["process", "dbase", "upstreamParent", "downstreamParent"],
icon: "font-awesome/fa-tachometer",
label: function () {
return this.name || "Measurement";
},
oneditprepare: function() {
const node = this;
// Define UI html elements
const elements = {
// Basic fields
name: document.getElementById("node-input-name"),
// specific fields
scalingCheckbox: document.getElementById("node-input-scaling"),
rowInputMin: document.getElementById("row-input-i_min"),
rowInputMax: document.getElementById("row-input-i_max"),
smoothMethod: document.getElementById("node-input-smooth_method"),
count: document.getElementById("node-input-count"),
iOffset: document.getElementById("node-input-i_offset"),
oMin: document.getElementById("node-input-o_min"),
oMax: document.getElementById("node-input-o_max"),
// Logging fields
logCheckbox: document.getElementById("node-input-enableLog"),
logLevelSelect: document.getElementById("node-input-logLevel"),
rowLogLevel: document.getElementById("row-logLevel"),
// Asset fields
supplier: document.getElementById("node-input-supplier"),
subType: document.getElementById("node-input-subType"),
model: document.getElementById("node-input-model"),
unit: document.getElementById("node-input-unit"),
};
//this needs to live somewhere and for now we add it to every node file for simplicity
const projecSettingstURL = "http://localhost:1880/generalFunctions/settings/projectSettings.json";
try{
// Fetch project settings
menuUtils.fetchProjectData(projecSettingstURL)
.then((projectSettings) => {
//assign to node vars
node.configUrls = projectSettings.configUrls;
const { cloudConfigURL, localConfigURL } = menuUtils.getSpecificConfigUrl("measurement",node.configUrls.cloud.taggcodeAPI);
node.configUrls.cloud.config = cloudConfigURL; // first call
node.configUrls.local.config = localConfigURL; // backup call
node.locationId = projectSettings.locationId;
node.uuid = projectSettings.uuid;
// Gets the ID of the active workspace (Flow)
const activeFlowId = RED.workspaces.active(); //fetches active flow id
node.processId = 1;//activeFlowId;
// UI elements specific for node
menuUtils.initMeasurementToggles(elements);
menuUtils.populateSmoothingMethods(node.configUrls, elements, node);
// UI elements across all nodes
menuUtils.fetchAndPopulateDropdowns(node.configUrls, elements, node); // function for all assets
menuUtils.initBasicToggles(elements);
})
}catch(e){
console.log("Error fetching project settings", e);
}
if(node.d){
//this means node is disabled
console.log("Current status of node is disabled");
}
},
oneditsave: function () {
const node = this;
console.log(`------------ Saving changes to node ------------`);
console.log(`${node.uuid}`);
// Save basic properties
["name", "supplier", "subType", "model", "unit", "smooth_method"].forEach(
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
);
// Save numeric and boolean properties
["scaling", "enableLog", "simulator"].forEach(
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
);
["i_min", "i_max", "i_offset", "o_min", "o_max", "count"].forEach(
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
);
node.logLevel = document.getElementById("node-input-logLevel").value || "info";
// Validation checks
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
RED.notify("Scaling enabled, but input range is incomplete!", "error");
}
if (!node.unit) {
RED.notify("Unit selection is required.", "error");
}
if (node.subType && !node.unit) {
RED.notify("Unit must be set when specifying a subtype.", "error");
}
console.log("stored node modelData", node.modelMetadata);
console.log("------------ Changes saved to measurement node preparing to save to API ------------");
try{
// Fetch project settings
menuUtils.apiCall(node,node.configUrls)
.then((response) => {
//save response to node information
node.assetTagCode = response.asset_tag_number;
})
.catch((error) => {
console.log("Error during API call", error);
});
}catch(e){
console.log("Error saving assetID and tagnumber", e);
}
},
});
</script>
<!-- Main UI -->
<script type="text/html" data-template-name="measurement">
<!-- Node Name -->
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input
type="text"
id="node-input-name"
placeholder="Measurement Name"
style="width:70%;"
/>
</div>
<!-- Scaling Checkbox -->
<div class="form-row">
<label for="node-input-scaling"
><i class="fa fa-compress"></i> Scaling</label
>
<input
type="checkbox"
id="node-input-scaling"
style="width:20px; vertical-align:baseline;"
/>
<span>Enable input scaling?</span>
</div>
<!-- Source Min/Max (only if scaling is true) -->
<div class="form-row" id="row-input-i_min">
<label for="node-input-i_min"
><i class="fa fa-arrow-down"></i> Source Min</label>
<input type="number" id="node-input-i_min" placeholder="0" />
</div>
<div class="form-row" id="row-input-i_max">
<label for="node-input-i_max"
><i class="fa fa-arrow-up"></i> Source Max</label>
<input type="number" id="node-input-i_max" placeholder="3000" />
</div>
<!-- Offset -->
<div class="form-row">
<label for="node-input-i_offset"
><i class="fa fa-adjust"></i> Input Offset</label>
<input type="number" id="node-input-i_offset" placeholder="0" />
</div>
<!-- Output / Process Min/Max -->
<div class="form-row">
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
<input type="number" id="node-input-o_min" placeholder="0" />
</div>
<div class="form-row">
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
<input type="number" id="node-input-o_max" placeholder="1" />
</div>
<!-- Simulator Checkbox -->
<div class="form-row">
<label for="node-input-simulator"
><i class="fa fa-cog"></i> Simulator</label>
<input
type="checkbox"
id="node-input-simulator"
style="width:20px; vertical-align:baseline;"
/>
<span>Activate internal simulation?</span>
</div>
<!-- Smoothing Method -->
<div class="form-row">
<label for="node-input-smooth_method"
><i class="fa fa-line-chart"></i> Smoothing</label>
<select id="node-input-smooth_method" style="width:60%;">
<!-- Filled dynamically from measurementConfig.json or fallback -->
</select>
</div>
<!-- Smoothing Window -->
<div class="form-row">
<label for="node-input-count">Window</label>
<input
type="number"
id="node-input-count"
placeholder="10"
style="width:60px;"
/>
<div class="form-tips">Number of samples for smoothing</div>
</div>
<!-- Optional Extended Fields: supplier, type, subType, model -->
<hr />
<div class="form-row">
<label for="node-input-supplier"
><i class="fa fa-industry"></i> Supplier</label>
<select id="node-input-supplier" style="width:60%;">
<option value="">(optional)</option>
</select>
</div>
<div class="form-row">
<label for="node-input-subType"
><i class="fa fa-puzzle-piece"></i> SubType</label>
<select id="node-input-subType" style="width:60%;">
<option value="">(optional)</option>
</select>
</div>
<div class="form-row">
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
<select id="node-input-model" style="width:60%;">
<option value="">(optional)</option>
</select>
</div>
<div class="form-row">
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
<select id="node-input-unit" style="width:60%;"></select>
</div>
<hr />
<!-- loglevel checkbox -->
<div class="form-row">
<label for="node-input-enableLog"
><i class="fa fa-cog"></i> Enable Log</label>
<input
type="checkbox"
id="node-input-enableLog"
style="width:20px; vertical-align:baseline;"
/>
<span>Enable logging</span>
</div>
<div class="form-row" id="row-logLevel">
<label for="node-input-logLevel"><i class="fa fa-cog"></i> Log Level</label>
<select id="node-input-logLevel" style="width:60%;">
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="measurement">
<p><b>Measurement Node</b>: Scales, smooths, and simulates measurement data.</p>
<p>Use this node to scale, smooth, and simulate measurement data. The node can be configured to scale input data to a specified range, smooth the data using a variety of methods, and simulate data for testing purposes.</p>
<li><b>Supplier:</b> Select a supplier to populate machine options.</li>
<li><b>SubType:</b> Select a subtype if applicable to further categorize the asset.</li>
<li><b>Model:</b> Define the specific model for more granular asset configuration.</li>
<li><b>Unit:</b> Assign a unit to standardize measurements or operations.</li>
<li><b>Scaling:</b> Enable or disable input scaling. When enabled, you must provide the source min and max values.</li>
<li><b>Source Min/Max:</b> Define the minimum and maximum values for the input range when scaling is enabled.</li>
<li><b>Input Offset:</b> Specify an offset value to be added to the input measurement.</li>
<li><b>Process Min/Max:</b> Define the minimum and maximum values for the output range after processing.</li>
<li><b>Simulator:</b> Activate internal simulation for testing purposes.</li>
<li><b>Smoothing:</b> Select a smoothing method to apply to the measurement data.</li>
<li><b>Window:</b> Define the number of samples to use for smoothing.</li>
<li><b>Enable Log:</b> Enable or disable logging for this node.</li>
<li><b>Log Level:</b> Select the log level (Info, Debug, Warn, Error) for logging messages.</li>
</script>

148
measurement.js Normal file
View File

@@ -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);
};

28
package.json Normal file
View File

@@ -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"
}
}
}