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