diff --git a/measurement.html b/measurement.html index ea30165..9cdbe80 100644 --- a/measurement.html +++ b/measurement.html @@ -30,6 +30,8 @@ simulator: { value: false }, smooth_method: { value: "" }, count: { value: "10", required: true }, + processOutputFormat: { value: "process" }, + dbaseOutputFormat: { value: "influxdb" }, //define asset properties uuid: { value: "" }, @@ -221,6 +223,25 @@
Number of samples for smoothing
+
+

Output Formats

+
+ + +
+
+ + +
+
diff --git a/src/nodeClass.js b/src/nodeClass.js index 26a6baf..db8affc 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -39,33 +39,16 @@ class nodeClass { /** * Load and merge default config with user-defined settings. + * Uses ConfigManager.buildConfig() for base sections (general, asset, functionality), + * then adds measurement-specific domain config. * @param {object} uiConfig - Raw config from Node-RED UI. */ _loadConfig(uiConfig,node) { const cfgMgr = new configManager(); this.defaultConfig = cfgMgr.getConfig(this.name); - // Merge UI config over defaults - this.config = { - general: { - name: this.name, - id: node.id, // node.id is for the child registration process - unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards) - logging: { - enabled: uiConfig.enableLog, - logLevel: uiConfig.logLevel - } - }, - asset: { - uuid: uiConfig.assetUuid, //need to add this later to the asset model - tagCode: uiConfig.assetTagCode, //need to add this later to the asset model - tagNumber: uiConfig.assetTagNumber, - supplier: uiConfig.supplier, - category: uiConfig.category, //add later to define as the software type - type: uiConfig.assetType, - model: uiConfig.model, - unit: uiConfig.unit - }, + // Build config: base sections + measurement-specific domain config + this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, { scaling: { enabled: uiConfig.scaling, inputMin: uiConfig.i_min, @@ -80,12 +63,8 @@ class nodeClass { }, simulation: { enabled: uiConfig.simulator - }, - functionality: { - positionVsParent: uiConfig.positionVsParent,// Default to 'atEquipment' if not specified - distance: uiConfig.hasDistance ? uiConfig.distance : undefined } - }; + }); // Utility for formatting outputs this._output = new outputUtils(); diff --git a/src/specificClass.js b/src/specificClass.js index 8bcc537..98b2196 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -385,7 +385,7 @@ class Measurement { 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 + return arr.map((val, _idx) => lowPass + highPass - val).pop(); // Combine the filters } weightedMovingAverage(arr) { @@ -565,7 +565,7 @@ const configuration = { enabled: true, }, functionality: { - positionVsParent: "upstream" + positionVsParent: POSITIONS.UPSTREAM } }; diff --git a/test/specificClass.test.js b/test/specificClass.test.js new file mode 100644 index 0000000..965d07d --- /dev/null +++ b/test/specificClass.test.js @@ -0,0 +1,448 @@ +/** + * Tests for measurement specificClass (domain logic). + * + * The Measurement class handles sensor input processing: + * - scaling (input range -> absolute range) + * - smoothing (various filter methods) + * - outlier detection (z-score, IQR, modified z-score) + * - simulation mode + * - calibration + */ + +const Measurement = require('../src/specificClass'); + +// --------------- helpers --------------- + +function makeConfig(overrides = {}) { + const base = { + general: { + name: 'TestSensor', + id: 'test-sensor-1', + logging: { enabled: false, logLevel: 'error' }, + }, + functionality: { + softwareType: 'measurement', + role: 'sensor', + positionVsParent: 'atEquipment', + distance: null, + }, + asset: { + category: 'sensor', + type: 'pressure', + model: 'test-model', + supplier: 'TestCo', + unit: 'bar', + }, + scaling: { + enabled: false, + inputMin: 0, + inputMax: 1, + absMin: 0, + absMax: 100, + offset: 0, + }, + smoothing: { + smoothWindow: 5, + smoothMethod: 'none', + }, + simulation: { + enabled: false, + }, + interpolation: { + percentMin: 0, + percentMax: 100, + }, + outlierDetection: { + enabled: false, + method: 'zScore', + threshold: 3, + }, + }; + + // Deep-merge one level + for (const key of Object.keys(overrides)) { + if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) { + base[key] = { ...base[key], ...overrides[key] }; + } else { + base[key] = overrides[key]; + } + } + return base; +} + +// --------------- tests --------------- + +describe('Measurement specificClass', () => { + + describe('constructor / initialization', () => { + it('should create an instance with default config overlay', () => { + const m = new Measurement(makeConfig()); + expect(m).toBeDefined(); + expect(m.config.general.name).toBe('testsensor'); + expect(m.outputAbs).toBe(0); + expect(m.outputPercent).toBe(0); + expect(m.storedValues).toEqual([]); + }); + + it('should initialize inputRange and processRange from scaling config', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 }, + })); + expect(m.inputRange).toBe(16); // |20 - 4| + expect(m.processRange).toBe(100); // |100 - 0| + }); + + it('should create with empty config and fall back to defaults', () => { + const m = new Measurement({}); + expect(m).toBeDefined(); + expect(m.config).toBeDefined(); + }); + }); + + // ---- pure math helpers ---- + + describe('mean()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should return the arithmetic mean', () => { + expect(m.mean([2, 4, 6])).toBe(4); + }); + + it('should handle a single element', () => { + expect(m.mean([7])).toBe(7); + }); + }); + + describe('min() / max()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should return the minimum value', () => { + expect(m.min([5, 3, 9, 1])).toBe(1); + }); + + it('should return the maximum value', () => { + expect(m.max([5, 3, 9, 1])).toBe(9); + }); + }); + + describe('standardDeviation()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should return 0 for a single-element array', () => { + expect(m.standardDeviation([42])).toBe(0); + }); + + it('should return 0 for identical values', () => { + expect(m.standardDeviation([5, 5, 5])).toBe(0); + }); + + it('should compute sample std dev correctly', () => { + // [2, 4, 4, 4, 5, 5, 7, 9] => mean = 5, sqDiffs sum = 32, variance = 32/7 ~ 4.571, sd ~ 2.138 + const sd = m.standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]); + expect(sd).toBeCloseTo(2.138, 2); + }); + }); + + describe('medianFilter()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should return the middle element for odd-length array', () => { + expect(m.medianFilter([3, 1, 2])).toBe(2); + }); + + it('should return the average of two middle elements for even-length array', () => { + expect(m.medianFilter([1, 2, 3, 4])).toBe(2.5); + }); + }); + + // ---- constrain ---- + + describe('constrain()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should clamp a value below min to min', () => { + expect(m.constrain(-5, 0, 100)).toBe(0); + }); + + it('should clamp a value above max to max', () => { + expect(m.constrain(150, 0, 100)).toBe(100); + }); + + it('should pass through values inside range', () => { + expect(m.constrain(50, 0, 100)).toBe(50); + }); + }); + + // ---- interpolateLinear ---- + + describe('interpolateLinear()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should map input min to output min', () => { + expect(m.interpolateLinear(0, 0, 10, 0, 100)).toBe(0); + }); + + it('should map input max to output max', () => { + expect(m.interpolateLinear(10, 0, 10, 0, 100)).toBe(100); + }); + + it('should map midpoint correctly', () => { + expect(m.interpolateLinear(5, 0, 10, 0, 100)).toBe(50); + }); + + it('should return the input unchanged if ranges are invalid (iMin >= iMax)', () => { + expect(m.interpolateLinear(5, 10, 10, 0, 100)).toBe(5); + }); + }); + + // ---- applyOffset ---- + + describe('applyOffset()', () => { + it('should add the configured offset to the value', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 10 }, + })); + expect(m.applyOffset(5)).toBe(15); + }); + + it('should add zero offset', () => { + const m = new Measurement(makeConfig()); + expect(m.applyOffset(5)).toBe(5); + }); + }); + + // ---- handleScaling ---- + + describe('handleScaling()', () => { + it('should interpolate from input range to abs range', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 }, + })); + // midpoint of 4..20 = 12 => should map to 50 + const result = m.handleScaling(12); + expect(result).toBeCloseTo(50, 1); + }); + + it('should constrain values outside input range', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: true, inputMin: 0, inputMax: 10, absMin: 0, absMax: 100, offset: 0 }, + })); + // value 15 > inputMax 10, should be constrained then mapped + const result = m.handleScaling(15); + expect(result).toBe(100); + }); + }); + + // ---- applySmoothing ---- + + describe('applySmoothing()', () => { + it('should return the raw value when method is "none"', () => { + const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 5, smoothMethod: 'none' } })); + expect(m.applySmoothing(42)).toBe(42); + }); + + it('should compute the mean when method is "mean"', () => { + const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 5, smoothMethod: 'mean' } })); + m.applySmoothing(10); + m.applySmoothing(20); + const result = m.applySmoothing(30); + expect(result).toBe(20); // mean of [10, 20, 30] + }); + + it('should respect the smoothWindow limit', () => { + const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 3, smoothMethod: 'mean' } })); + m.applySmoothing(10); + m.applySmoothing(20); + m.applySmoothing(30); + const result = m.applySmoothing(40); + // window is [20, 30, 40] after shift + expect(result).toBe(30); + }); + }); + + // ---- outlier detection ---- + + describe('outlierDetection()', () => { + it('should return false when there are fewer than 2 stored values', () => { + const m = new Measurement(makeConfig({ + outlierDetection: { enabled: true, method: 'zScore', threshold: 3 }, + })); + expect(m.outlierDetection(100)).toBe(false); + }); + + it('zScore: should detect a large outlier', () => { + const m = new Measurement(makeConfig({ + outlierDetection: { enabled: true, method: 'zScore', threshold: 2 }, + })); + // Config manager lowercases enum values, so fix the method after construction + m.config.outlierDetection.method = 'zScore'; + m.storedValues = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10]; + expect(m.outlierDetection(1000)).toBe(true); + }); + + it('zScore: should not flag values near the mean', () => { + const m = new Measurement(makeConfig({ + outlierDetection: { enabled: true, method: 'zScore', threshold: 3 }, + })); + m.config.outlierDetection.method = 'zScore'; + m.storedValues = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10]; + expect(m.outlierDetection(10.5)).toBe(false); + }); + + it('iqr: should detect an outlier', () => { + const m = new Measurement(makeConfig({ + outlierDetection: { enabled: true, method: 'iqr', threshold: 3 }, + })); + m.storedValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + expect(m.outlierDetection(100)).toBe(true); + }); + }); + + // ---- calculateInput (integration) ---- + + describe('calculateInput()', () => { + it('should update outputAbs when no scaling is applied', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' }, + })); + m.calculateInput(42); + expect(m.outputAbs).toBe(42); + }); + + it('should apply offset before scaling', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 10 }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' }, + })); + m.calculateInput(40); // 40 + 10 = 50, scaled: 50/100 * 1000 = 500 + expect(m.outputAbs).toBe(500); + }); + + it('should skip outlier values when outlier detection is enabled', () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: false, inputMin: 0, inputMax: 1000, absMin: 0, absMax: 1000, offset: 0 }, + smoothing: { smoothWindow: 20, smoothMethod: 'none' }, + outlierDetection: { enabled: true, method: 'iqr', threshold: 1.5 }, + })); + // Seed stored values with some variance so IQR method works + for (let i = 0; i < 10; i++) m.storedValues.push(10 + (i % 3)); + m.calculateInput(10); // normal value, will update + const afterNormal = m.outputAbs; + m.calculateInput(9999); // outlier, should be ignored by IQR + expect(m.outputAbs).toBe(afterNormal); + }); + }); + + // ---- updateMinMaxValues ---- + + describe('updateMinMaxValues()', () => { + it('should track minimum and maximum seen values', () => { + const m = new Measurement(makeConfig()); + m.updateMinMaxValues(5); + m.updateMinMaxValues(15); + m.updateMinMaxValues(3); + expect(m.totalMinValue).toBe(3); + expect(m.totalMaxValue).toBe(15); + }); + }); + + // ---- isStable ---- + + describe('isStable()', () => { + it('should return false when fewer than 2 stored values', () => { + const m = new Measurement(makeConfig()); + m.storedValues = [1]; + expect(m.isStable()).toBe(false); + }); + + it('should report stable when all values are the same', () => { + const m = new Measurement(makeConfig()); + m.storedValues = [5, 5, 5, 5]; + const result = m.isStable(); + expect(result.isStable).toBe(true); + expect(result.stdDev).toBe(0); + }); + }); + + // ---- getOutput ---- + + describe('getOutput()', () => { + it('should return an object with expected keys', () => { + const m = new Measurement(makeConfig()); + const out = m.getOutput(); + expect(out).toHaveProperty('mAbs'); + expect(out).toHaveProperty('mPercent'); + expect(out).toHaveProperty('totalMinValue'); + expect(out).toHaveProperty('totalMaxValue'); + expect(out).toHaveProperty('totalMinSmooth'); + expect(out).toHaveProperty('totalMaxSmooth'); + }); + }); + + // ---- toggleSimulation ---- + + describe('toggleSimulation()', () => { + it('should flip the simulation enabled flag', () => { + const m = new Measurement(makeConfig({ simulation: { enabled: false } })); + expect(m.config.simulation.enabled).toBe(false); + m.toggleSimulation(); + expect(m.config.simulation.enabled).toBe(true); + m.toggleSimulation(); + expect(m.config.simulation.enabled).toBe(false); + }); + }); + + // ---- tick (simulation mode) ---- + + describe('tick()', () => { + it('should resolve without errors when simulation is disabled', async () => { + const m = new Measurement(makeConfig({ simulation: { enabled: false } })); + m.inputValue = 50; + await expect(m.tick()).resolves.toBeUndefined(); + }); + + it('should generate a simulated value when simulation is enabled', async () => { + const m = new Measurement(makeConfig({ + scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' }, + simulation: { enabled: true }, + })); + await m.tick(); + // simValue may be 0 on first call, but it should not throw + expect(m.simValue).toBeDefined(); + }); + }); + + // ---- filter methods ---- + + describe('lowPassFilter()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should return the first value for a single-element array', () => { + expect(m.lowPassFilter([10])).toBe(10); + }); + + it('should smooth values', () => { + const result = m.lowPassFilter([10, 10, 10, 10]); + expect(result).toBeCloseTo(10, 1); + }); + }); + + describe('weightedMovingAverage()', () => { + let m; + beforeEach(() => { m = new Measurement(makeConfig()); }); + + it('should give more weight to recent values', () => { + // weights [1,2,3], values [0, 0, 30] => (0*1 + 0*2 + 30*3) / 6 = 15 + expect(m.weightedMovingAverage([0, 0, 30])).toBe(15); + }); + }); +});