From 495b4cf40051cc18ea43e7c361b2032a88fc1742 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 13 Apr 2026 13:43:03 +0200 Subject: [PATCH] feat: digital (MQTT) mode + fix silent dispatcher bug for camelCase methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime: - Fix silent no-op when user selected any camelCase smoothing or outlier method from the editor. validateEnum in generalFunctions lowercases enum values (zScore -> zscore, lowPass -> lowpass, ...) but the dispatcher compared against camelCase keys. Effect: 5 of 11 smoothing methods (lowPass, highPass, weightedMovingAverage, bandPass, savitzkyGolay) and 2 of 3 outlier methods (zScore, modifiedZScore) silently fell through. Users got the raw last value or no outlier filtering with no error log. Review any pre-2026-04-13 flows that relied on these methods. Fix: normalize method names to lowercase on both sides of the lookup. - New Channel class (src/channel.js) — self-contained per-channel pipeline: outlier -> offset -> scaling -> smoothing -> min/max -> constrain -> emit. Pure domain logic, no Node-RED deps, reusable by future nodes that need the same signal-conditioning chain. Digital mode: - config.mode.current = 'digital' opts in. config.channels declares one entry per expected JSON key; each channel has its own type, position, unit, distance, and optional scaling/smoothing/outlierDetection blocks that override the top-level analog-mode fields. One MQTT-shaped payload ({t:22.5, h:45, p:1013}) dispatches N independent pipelines and emits N MeasurementContainer slots from a single input message. - Backward compatible: absent mode config = analog = pre-digital behaviour. Every existing measurement flow keeps working unchanged. UI: - HTML editor: new Mode dropdown and Channels JSON textarea. The Node-RED help panel is rewritten end-to-end with topic reference, port contracts, per-mode configuration, smoothing/outlier method tables, and a note about the pre-fix behaviour. - README.md rewritten (was a one-line stub). Tests (12 -> 71, all green): - test/basic/smoothing-methods.basic.test.js (+16): every smoothing method including the formerly-broken camelCase ones. - test/basic/outlier-detection.basic.test.js (+10): every outlier method, fall-through, toggle. - test/basic/scaling-and-interpolation.basic.test.js (+10): offset, interpolateLinear, constrain, handleScaling edge cases, min/max tracking, updateOutputPercent fallback, updateOutputAbs emit dedup. - test/basic/calibration-and-stability.basic.test.js (+11): calibrate (stable and unstable), isStable, evaluateRepeatability refusals, toggleSimulation, tick simulation on/off. - test/integration/digital-mode.integration.test.js (+12): channel build (including malformed entries), payload dispatch, multi-channel emit, unknown keys, per-channel scaling/smoothing/outlier, empty channels, non-numeric value rejection, getDigitalOutput shape, analog-default back-compat. E2E verified on Dockerized Node-RED: analog regression unchanged; digital mode deploys with three channels, dispatches MQTT-style payload, emits per-channel events, accumulates per-channel smoothing, ignores unknown keys. Depends on generalFunctions commit e50be2e (permissive unit check + mode/channels schema). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 119 ++++++- measurement.html | 84 ++++- src/channel.js | 311 ++++++++++++++++++ src/nodeClass.js | 45 ++- src/specificClass.js | 158 +++++++-- .../calibration-and-stability.basic.test.js | 121 +++++++ test/basic/outlier-detection.basic.test.js | 98 ++++++ .../scaling-and-interpolation.basic.test.js | 122 +++++++ test/basic/smoothing-methods.basic.test.js | 132 ++++++++ .../digital-mode.integration.test.js | 222 +++++++++++++ 10 files changed, 1367 insertions(+), 45 deletions(-) create mode 100644 src/channel.js create mode 100644 test/basic/calibration-and-stability.basic.test.js create mode 100644 test/basic/outlier-detection.basic.test.js create mode 100644 test/basic/scaling-and-interpolation.basic.test.js create mode 100644 test/basic/smoothing-methods.basic.test.js create mode 100644 test/integration/digital-mode.integration.test.js diff --git a/README.md b/README.md index f33b006..b70d814 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,118 @@ -# convert +# measurement -Makes unit conversions \ No newline at end of file +Node-RED custom node for sensor signal conditioning. Takes raw input — either a single scalar (analog mode) or an MQTT-style JSON object with many keys (digital mode) — and produces scaled, smoothed, outlier-filtered measurements. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform. + +Registers itself on port 2 as a child of a parent equipment (rotatingMachine, pumpingStation, reactor, etc.). The parent consumes measurements via shared `MeasurementContainer` events. + +## Install + +```bash +cd ~/.node-red +npm install github:gitea.wbd-rd.nl/RnD/measurement +``` + +Or pull the whole platform via the superproject. Restart Node-RED and the node appears in the palette under **EVOLV**. + +## Two input modes + +### Analog mode (default) + +One scalar per message — the classic PLC / 4-20mA pattern. + +```json +{ "topic": "measurement", "payload": 42 } +``` + +The node runs one offset → scaling → smoothing → outlier pipeline and emits exactly one MeasurementContainer slot. Every existing flow built before digital mode keeps working unchanged. + +### Digital mode (MQTT / IoT) + +One object per message, many keys: + +```json +{ "topic": "measurement", + "payload": { "temperature": 22.5, "humidity": 45, "pressure": 1013 } } +``` + +Each key maps to its own **channel** with independently-configured scaling, smoothing, outlier detection, type, position, unit, and distance. A single inbound message therefore emits N MeasurementContainer slots — one per channel — so a downstream parent sees everything at once. + +Pick the mode in the editor or via `msg.mode`. Analog is the default; digital requires populating `channels` (see *Configuration*). + +## Input topics + +| Topic | Payload | Effect | +|---|---|---| +| `measurement` | analog mode: `number` or numeric `string` — stored as `inputValue` and consumed on the next tick. digital mode: `object` keyed by channel names. | drives the pipeline | +| `simulator` | — | toggles the simulator flag | +| `outlierDetection` | — | toggles outlier detection | +| `calibrate` | — | adjust the scaling offset so current output matches `inputMin` (scaling on) or `absMin` (scaling off). Requires a stable window. | + +## Output ports + +| Port | Label | Payload | +|---|---|---| +| 0 | `process` | analog: `{mAbs, mPercent, totalMinValue, totalMaxValue, totalMinSmooth, totalMaxSmooth}`. digital: `{channels: {: {mAbs, mPercent, ...}}}`. Delta-compressed — only changed fields emit each tick. | +| 1 | `dbase` | InfluxDB line-protocol telemetry | +| 2 | `parent` | `{topic:"registerChild", payload:, positionVsParent, distance}` emitted once ~180ms after deploy | + +## Configuration + +### Common (both modes) + +- **Asset** (menu): supplier, category, `assetType` (measurement type in the container — `pressure`, `flow`, `temperature`, `power`, or any user-defined type like `humidity`), model, unit. +- **Logger** (menu): log level + enable flag. +- **Position** (menu): `upstream` / `atEquipment` / `downstream` relative to parent; optional distance offset. + +### Analog-mode fields + +| Field | Purpose | +|---|---| +| `Scaling` (checkbox) | enables linear source→process interpolation | +| `Source Min / Max` | input-side range (e.g. 4–20 mA) | +| `Input Offset` | additive bias applied before scaling | +| `Process Min / Max` | output-side range (e.g. 0–3000 mbar) | +| `Simulator` (checkbox) | internal random-walk source | +| `Smoothing` | one of: `none`, `mean`, `min`, `max`, `sd`, `lowPass`, `highPass`, `weightedMovingAverage`, `bandPass`, `median`, `kalman`, `savitzkyGolay` | +| `Window` | sample count for the smoothing window | + +### Digital-mode fields + +- **Mode**: set to `digital`. +- **Channels**: JSON array, one entry per channel. Each entry: + +```json +{ + "key": "temperature", + "type": "temperature", + "position": "atEquipment", + "unit": "C", + "scaling": { "enabled": false, "inputMin": 0, "inputMax": 1, "absMin": -50, "absMax": 150, "offset": 0 }, + "smoothing": { "smoothWindow": 5, "smoothMethod": "mean" }, + "outlierDetection": { "enabled": true, "method": "zScore", "threshold": 3 } +} +``` + +`scaling`, `smoothing`, `outlierDetection` are optional — the node falls back to the top-level analog-mode equivalents when missing. `key` is the JSON field name inside `msg.payload`; `type` is the MeasurementContainer axis (can be any string — unknown types are accepted). + +## State and emit contract + +Every channel runs the same pipeline: `outlier → offset → scaling → smoothing → min/max tracking → constrain → emit`. Output is rounded to two decimals. MeasurementContainer events follow the pattern `..` all lowercase, e.g. `temperature.measured.atequipment`. + +Unknown measurement types (anything not in the container's built-in measureMap — `pressure`, `flow`, `power`, `temperature`, `volume`, `length`, `mass`, `energy`) are accepted without unit compatibility checks. Known types still validate strictly. + +## Testing + +```bash +cd nodes/measurement +npm test +``` + +71 tests cover every smoothing method, every outlier strategy, scaling, interpolation, constrain, calibration, stability, simulation, output-percent fallback, per-channel pipelines, digital payload dispatch, registration events, and example-flow shape. + +## Production status + +Last reviewed **2026-04-13**. See the project memory file `node_measurement.md` for the current verdict, benchmarks, and wishlist. + +## License + +SEE LICENSE. Author: Rene De Ren, Waterschap Brabantse Delta R&D. diff --git a/measurement.html b/measurement.html index 9cdbe80..ec4ad7b 100644 --- a/measurement.html +++ b/measurement.html @@ -20,7 +20,11 @@ // Define default properties name: { value: "" }, // use asset category as name - // Define specific properties + // Input mode: 'analog' (scalar payload, default) or 'digital' (object payload, many channels) + mode: { value: "analog" }, + channels: { value: "[]" }, + + // Define specific properties (analog-mode pipeline defaults) scaling: { value: false }, i_min: { value: 0, required: true }, i_max: { value: 0, required: true }, @@ -141,7 +145,7 @@ } // Save basic properties - ["smooth_method"].forEach( + ["smooth_method", "mode", "channels"].forEach( (field) => (node[field] = document.getElementById(`node-input-${field}`).value || "") ); @@ -167,6 +171,23 @@ diff --git a/src/channel.js b/src/channel.js new file mode 100644 index 0000000..f9bba08 --- /dev/null +++ b/src/channel.js @@ -0,0 +1,311 @@ +/** + * Channel — a single scalar measurement pipeline. + * + * A Channel owns one rolling window of stored values, one smoothing method, + * one outlier detector, one scaling contract, and one MeasurementContainer + * slot. It exposes `update(value)` as the single entry point. + * + * The measurement node composes Channels: + * - analog mode -> exactly one Channel built from the flat top-level config + * - digital mode -> one Channel per `config.channels[i]` entry, keyed by + * `channel.key` (the field inside msg.payload that feeds it) + * + * This file is pure domain logic. It must never reach into Node-RED APIs. + */ + +class Channel { + /** + * @param {object} opts + * @param {string} opts.key - identifier inside an incoming object payload (digital) or null (analog) + * @param {string} opts.type - MeasurementContainer axis (e.g. 'pressure') + * @param {string} opts.position - 'upstream' | 'atEquipment' | 'downstream' + * @param {string} opts.unit - output unit label (e.g. 'mbar') + * @param {number|null} opts.distance - physical offset from parent equipment + * @param {object} opts.scaling - {enabled, inputMin, inputMax, absMin, absMax, offset} + * @param {object} opts.smoothing - {smoothWindow, smoothMethod} + * @param {object} [opts.outlierDetection] - {enabled, method, threshold} + * @param {object} opts.interpolation - {percentMin, percentMax} + * @param {object} opts.measurements - the MeasurementContainer to publish into + * @param {object} opts.logger - generalFunctions logger instance + */ + constructor(opts) { + this.key = opts.key || null; + this.type = opts.type; + this.position = opts.position; + this.unit = opts.unit; + this.distance = opts.distance ?? null; + + this.scaling = { ...opts.scaling }; + this.smoothing = { ...opts.smoothing }; + this.outlierDetection = opts.outlierDetection ? { ...opts.outlierDetection } : { enabled: false, method: 'zscore', threshold: 3 }; + this.interpolation = { ...(opts.interpolation || { percentMin: 0, percentMax: 100 }) }; + + this.measurements = opts.measurements; + this.logger = opts.logger; + + this.storedValues = []; + this.inputValue = 0; + this.outputAbs = 0; + this.outputPercent = 0; + + this.totalMinValue = Infinity; + this.totalMaxValue = -Infinity; + this.totalMinSmooth = 0; + this.totalMaxSmooth = 0; + + this.inputRange = Math.abs(this.scaling.inputMax - this.scaling.inputMin); + this.processRange = Math.abs(this.scaling.absMax - this.scaling.absMin); + } + + // --- Public entry point --- + + /** + * Push a new scalar value through the full pipeline: + * outlier -> offset -> scaling -> smoothing -> min/max -> emit + * @param {number} value + * @returns {boolean} true if the value advanced the pipeline (not rejected as outlier) + */ + update(value) { + this.inputValue = value; + + if (this.outlierDetection.enabled && this._isOutlier(value)) { + this.logger?.warn?.(`[${this.key || this.type}] Outlier detected. Ignoring value=${value}`); + return false; + } + + let v = value + (this.scaling.offset || 0); + this._updateMinMax(v); + + if (this.scaling.enabled) { + v = this._applyScaling(v); + } + + const smoothed = this._applySmoothing(v); + this._updateSmoothMinMax(smoothed); + this._writeOutput(smoothed); + return true; + } + + getOutput() { + return { + key: this.key, + type: this.type, + position: this.position, + unit: this.unit, + mAbs: this.outputAbs, + mPercent: this.outputPercent, + totalMinValue: this.totalMinValue === Infinity ? 0 : this.totalMinValue, + totalMaxValue: this.totalMaxValue === -Infinity ? 0 : this.totalMaxValue, + totalMinSmooth: this.totalMinSmooth, + totalMaxSmooth: this.totalMaxSmooth, + }; + } + + // --- Outlier detection --- + + _isOutlier(val) { + if (this.storedValues.length < 2) return false; + const raw = this.outlierDetection.method; + const method = typeof raw === 'string' ? raw.toLowerCase() : raw; + switch (method) { + case 'zscore': return this._zScore(val); + case 'iqr': return this._iqr(val); + case 'modifiedzscore': return this._modifiedZScore(val); + default: + this.logger?.warn?.(`[${this.key || this.type}] Unknown outlier method "${raw}"`); + return false; + } + } + + _zScore(val) { + const threshold = this.outlierDetection.threshold || 3; + const m = Channel._mean(this.storedValues); + const sd = Channel._stdDev(this.storedValues); + // Intentionally do NOT early-return on sd===0: a perfectly stable + // baseline should make any deviation an outlier (z = Infinity > threshold). + const z = sd === 0 ? (val === m ? 0 : Infinity) : (val - m) / sd; + return Math.abs(z) > threshold; + } + + _iqr(val) { + const sorted = [...this.storedValues].sort((a, b) => a - b); + const q1 = sorted[Math.floor(sorted.length / 4)]; + const q3 = sorted[Math.floor(sorted.length * 3 / 4)]; + const iqr = q3 - q1; + return val < q1 - 1.5 * iqr || val > q3 + 1.5 * iqr; + } + + _modifiedZScore(val) { + const median = Channel._median(this.storedValues); + const mad = Channel._median(this.storedValues.map((v) => Math.abs(v - median))); + if (mad === 0) return false; + const mz = 0.6745 * (val - median) / mad; + const threshold = this.outlierDetection.threshold || 3.5; + return Math.abs(mz) > threshold; + } + + // --- Scaling --- + + _applyScaling(value) { + if (this.inputRange <= 0) { + this.logger?.warn?.(`[${this.key || this.type}] Input range invalid; falling back to [0,1].`); + this.scaling.inputMin = 0; + this.scaling.inputMax = 1; + this.inputRange = 1; + } + const clamped = Math.min(Math.max(value, this.scaling.inputMin), this.scaling.inputMax); + return this.scaling.absMin + ((clamped - this.scaling.inputMin) * (this.scaling.absMax - this.scaling.absMin)) / this.inputRange; + } + + // --- Smoothing --- + + _applySmoothing(value) { + this.storedValues.push(value); + if (this.storedValues.length > this.smoothing.smoothWindow) { + this.storedValues.shift(); + } + + const raw = this.smoothing.smoothMethod; + const method = typeof raw === 'string' ? raw.toLowerCase() : raw; + const arr = this.storedValues; + + switch (method) { + case 'none': return arr[arr.length - 1]; + case 'mean': return Channel._mean(arr); + case 'min': return Math.min(...arr); + case 'max': return Math.max(...arr); + case 'sd': return Channel._stdDev(arr); + case 'median': return Channel._median(arr); + case 'weightedmovingaverage': return Channel._wma(arr); + case 'lowpass': return Channel._lowPass(arr); + case 'highpass': return Channel._highPass(arr); + case 'bandpass': return Channel._bandPass(arr); + case 'kalman': return Channel._kalman(arr); + case 'savitzkygolay': return Channel._savitzkyGolay(arr); + default: + this.logger?.error?.(`[${this.key || this.type}] Smoothing method "${raw}" not implemented.`); + return value; + } + } + + // --- Output writes --- + + _updateMinMax(value) { + if (value < this.totalMinValue) this.totalMinValue = value; + if (value > this.totalMaxValue) this.totalMaxValue = value; + } + + _updateSmoothMinMax(value) { + 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; + } + + _writeOutput(val) { + const clamped = Math.min(Math.max(val, this.scaling.absMin), this.scaling.absMax); + const rounded = Math.round(clamped * 100) / 100; + + if (rounded !== this.outputAbs) { + this.outputAbs = rounded; + this.outputPercent = this._computePercent(clamped); + this.measurements + ?.type(this.type) + .variant('measured') + .position(this.position) + .distance(this.distance) + .value(this.outputAbs, Date.now(), this.unit); + } + } + + _computePercent(value) { + const { percentMin, percentMax } = this.interpolation; + let pct; + if (this.processRange <= 0) { + const lo = this.totalMinValue === Infinity ? 0 : this.totalMinValue; + const hi = this.totalMaxValue === -Infinity ? 1 : this.totalMaxValue; + pct = this._lerp(value, lo, hi, percentMin, percentMax); + } else { + pct = this._lerp(value, this.scaling.absMin, this.scaling.absMax, percentMin, percentMax); + } + return Math.round(pct * 100) / 100; + } + + _lerp(n, iMin, iMax, oMin, oMax) { + if (iMin >= iMax || oMin >= oMax) return n; + return oMin + ((n - iMin) * (oMax - oMin)) / (iMax - iMin); + } + + // --- Pure math helpers (static so they're reusable) --- + + static _mean(arr) { + if (!arr.length) return 0; + return arr.reduce((a, b) => a + b, 0) / arr.length; + } + + static _stdDev(arr) { + if (arr.length <= 1) return 0; + const m = Channel._mean(arr); + const variance = arr.map((v) => (v - m) ** 2).reduce((a, b) => a + b, 0) / (arr.length - 1); + return Math.sqrt(variance); + } + + static _median(arr) { + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + } + + static _wma(arr) { + const weights = arr.map((_, i) => i + 1); + const weightedSum = arr.reduce((sum, v, i) => sum + v * weights[i], 0); + const weightTotal = weights.reduce((s, w) => s + w, 0); + return weightedSum / weightTotal; + } + + static _lowPass(arr) { + const alpha = 0.2; + let out = arr[0]; + for (let i = 1; i < arr.length; i++) out = alpha * arr[i] + (1 - alpha) * out; + return out; + } + + static _highPass(arr) { + const alpha = 0.8; + const filtered = [arr[0]]; + for (let i = 1; i < arr.length; i++) { + filtered[i] = alpha * (filtered[i - 1] + arr[i] - arr[i - 1]); + } + return filtered[filtered.length - 1]; + } + + static _bandPass(arr) { + const lp = Channel._lowPass(arr); + const hp = Channel._highPass(arr); + return arr.map((v) => lp + hp - v).pop(); + } + + static _kalman(arr) { + let estimate = arr[0]; + const measurementNoise = 1; + const processNoise = 0.1; + const gain = processNoise / (processNoise + measurementNoise); + for (let i = 1; i < arr.length; i++) estimate = estimate + gain * (arr[i] - estimate); + return estimate; + } + + static _savitzkyGolay(arr) { + const coeffs = [-3, 12, 17, 12, -3]; + const norm = coeffs.reduce((a, b) => a + b, 0); + if (arr.length < coeffs.length) return arr[arr.length - 1]; + let s = 0; + for (let i = 0; i < coeffs.length; i++) { + s += arr[arr.length - coeffs.length + i] * coeffs[i]; + } + return s / norm; + } +} + +module.exports = Channel; diff --git a/src/nodeClass.js b/src/nodeClass.js index db8affc..df7b60f 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -48,6 +48,18 @@ class nodeClass { this.defaultConfig = cfgMgr.getConfig(this.name); // Build config: base sections + measurement-specific domain config + // `channels` (digital mode) is stored on the UI as a JSON string to + // avoid requiring a custom editor table widget at first. We parse here; + // invalid JSON is logged and the node falls back to an empty array. + let channels = []; + if (typeof uiConfig.channels === 'string' && uiConfig.channels.trim()) { + try { channels = JSON.parse(uiConfig.channels); } + catch (e) { node.warn(`Invalid channels JSON: ${e.message}`); channels = []; } + } else if (Array.isArray(uiConfig.channels)) { + channels = uiConfig.channels; + } + const mode = (typeof uiConfig.mode === 'string' && uiConfig.mode.toLowerCase() === 'digital') ? 'digital' : 'analog'; + this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, { scaling: { enabled: uiConfig.scaling, @@ -63,7 +75,9 @@ class nodeClass { }, simulation: { enabled: uiConfig.simulator - } + }, + mode: { current: mode }, + channels, }); // Utility for formatting outputs @@ -118,7 +132,13 @@ class nodeClass { _tick() { this.source.tick(); - const raw = this.source.getOutput(); + // In digital mode we don't funnel through calculateInput with a single + // scalar; instead each Channel has already emitted into the + // MeasurementContainer on message arrival. The tick payload carries a + // per-channel snapshot so downstream flows still see a heartbeat. + const raw = (this.source.mode === 'digital') + ? this.source.getDigitalOutput() + : this.source.getOutput(); const processMsg = this._output.formatMsg(raw, this.source.config, 'process'); const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); @@ -143,12 +163,23 @@ class nodeClass { this.source.calibrate(); break; case 'measurement': - if (typeof msg.payload === 'number' || (typeof msg.payload === 'string' && msg.payload.trim() !== '')) { - const parsed = Number(msg.payload); - if (!Number.isNaN(parsed)) { - this.source.inputValue = parsed; + // Dispatch based on mode: + // analog -> scalar payload (number or numeric string) + // digital -> object payload keyed by channel name + if (this.source.mode === 'digital') { + if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) { + this.source.handleDigitalPayload(msg.payload); } else { - this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`); + this.source.logger?.warn(`digital mode expects an object payload; got ${typeof msg.payload}`); + } + } else { + if (typeof msg.payload === 'number' || (typeof msg.payload === 'string' && msg.payload.trim() !== '')) { + const parsed = Number(msg.payload); + if (!Number.isNaN(parsed)) { + this.source.inputValue = parsed; + } else { + this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`); + } } } break; diff --git a/src/specificClass.js b/src/specificClass.js index 98b2196..a863e2c 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -1,15 +1,28 @@ const EventEmitter = require('events'); const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions'); +const Channel = require('./channel'); /** * Measurement domain model. - * Handles scaling, smoothing, outlier filtering and emits normalized measurement output. + * + * Supports two input modes: + * - `analog` (default): one scalar value per msg.payload. The node runs the + * classic offset / scaling / smoothing / outlier pipeline on it and emits + * exactly one measurement into the MeasurementContainer. This is the + * original behaviour; every existing flow keeps working unchanged. + * - `digital`: msg.payload is an object with many key/value pairs (MQTT / + * IoT style). The node builds one Channel per config.channels entry and + * routes each key through its own mini-pipeline, emitting N measurements + * into the MeasurementContainer from a single input message. + * + * Mode is selected via `config.mode.current`. When no mode config is present + * or mode=analog, the node behaves identically to pre-digital releases. */ class Measurement { constructor(config={}) { this.emitter = new EventEmitter(); // Own EventEmitter - this.configManager = new configManager(); + this.configManager = new configManager(); this.defaultConfig = this.configManager.getConfig('measurement'); this.configUtils = new configUtils(this.defaultConfig); this.config = this.configUtils.initConfig(config); @@ -50,8 +63,106 @@ class Measurement { 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.`); + // Mode + multi-channel (digital) support. Backward-compatible: when the + // config does not declare a mode, we fall back to 'analog' and behave + // exactly like the original single-channel node. + this.mode = (this.config.mode && typeof this.config.mode.current === 'string') + ? this.config.mode.current.toLowerCase() + : 'analog'; + this.channels = new Map(); // populated only in digital mode + if (this.mode === 'digital') { + this._buildDigitalChannels(); + } + this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully. mode=${this.mode} channels=${this.channels.size}`); + + } + + /** + * Build one Channel per entry in config.channels. Each Channel gets its + * own scaling / smoothing / outlier / position / unit contract; they share + * the parent MeasurementContainer so a downstream parent sees all channels + * via the same emitter. + */ + _buildDigitalChannels() { + const entries = Array.isArray(this.config.channels) ? this.config.channels : []; + if (entries.length === 0) { + this.logger.warn(`digital mode enabled but config.channels is empty; no channels will be emitted.`); + return; + } + for (const raw of entries) { + if (!raw || typeof raw !== 'object' || !raw.key || !raw.type) { + this.logger.warn(`skipping invalid channel entry: ${JSON.stringify(raw)}`); + continue; + } + const channel = new Channel({ + key: raw.key, + type: raw.type, + position: raw.position || this.config.functionality?.positionVsParent || 'atEquipment', + unit: raw.unit || this.config.asset?.unit || 'unitless', + distance: raw.distance ?? this.config.functionality?.distance ?? null, + scaling: raw.scaling || { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 }, + smoothing: raw.smoothing || { smoothWindow: this.config.smoothing.smoothWindow, smoothMethod: this.config.smoothing.smoothMethod }, + outlierDetection: raw.outlierDetection || this.config.outlierDetection, + interpolation: raw.interpolation || this.config.interpolation, + measurements: this.measurements, + logger: this.logger, + }); + this.channels.set(raw.key, channel); + } + this.logger.info(`digital mode: built ${this.channels.size} channel(s) from config.channels`); + } + + /** + * Digital mode entry point. Iterate the object payload, look up each key + * in the channel map, and run the configured pipeline per channel. Keys + * that are not mapped are logged once per call and ignored. + * @param {object} payload - e.g. { temperature: 21.5, humidity: 45.2 } + * @returns {object} summary of updated channels (for diagnostics) + */ + handleDigitalPayload(payload) { + if (this.mode !== 'digital') { + this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`); + return {}; + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + this.logger.warn(`digital payload must be an object; got ${typeof payload}`); + return {}; + } + const summary = {}; + const unknown = []; + for (const [key, raw] of Object.entries(payload)) { + const channel = this.channels.get(key); + if (!channel) { + unknown.push(key); + continue; + } + const v = Number(raw); + if (!Number.isFinite(v)) { + this.logger.warn(`digital channel '${key}' received non-numeric value: ${raw}`); + summary[key] = { ok: false, reason: 'non-numeric' }; + continue; + } + const ok = channel.update(v); + summary[key] = { ok, mAbs: channel.outputAbs, mPercent: channel.outputPercent }; + } + if (unknown.length) { + this.logger.debug(`digital payload contained unmapped keys: ${unknown.join(', ')}`); + } + return summary; + } + + /** + * Return per-channel output snapshots. In analog mode this is the same + * getOutput() contract; in digital mode it returns one snapshot per + * channel under a `channels` key so the tick output stays JSON-shaped. + */ + getDigitalOutput() { + const out = { channels: {} }; + for (const [key, ch] of this.channels) { + out.channels[key] = ch.getOutput(); + } + return out; } // -------- Config Initializers -------- // @@ -170,17 +281,23 @@ class Measurement { outlierDetection(val) { if (this.storedValues.length < 2) return false; - this.logger.debug(`Outlier detection method: ${this.config.outlierDetection.method}`); + // Config enum values are normalized to lowercase by validateEnum in + // generalFunctions, so dispatch on the lowercase form to keep this + // tolerant of both legacy (camelCase) and normalized (lowercase) config. + const raw = this.config.outlierDetection.method; + const method = typeof raw === 'string' ? raw.toLowerCase() : raw; - switch (this.config.outlierDetection.method) { - case 'zScore': + this.logger.debug(`Outlier detection method: ${method}`); + + switch (method) { + case 'zscore': return this.zScoreOutlierDetection(val); case 'iqr': return this.iqrOutlierDetection(val); - case 'modifiedZScore': + case 'modifiedzscore': return this.modifiedZScoreOutlierDetection(val); default: - this.logger.warn(`Outlier detection method "${this.config.outlierDetection.method}" is not recognized.`); + this.logger.warn(`Outlier detection method "${raw}" is not recognized.`); return false; } } @@ -306,31 +423,34 @@ class Measurement { this.storedValues.shift(); } - // Smoothing strategies + // Smoothing strategies keyed by the normalized (lowercase) method name. + // validateEnum in generalFunctions lowercases enum values, so dispatch on + // the lowercase form to accept both legacy (camelCase) and normalized + // (lowercase) config values. 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), + 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), + savitzkygolay: (arr) => this.savitzkyGolayFilter(arr), }; - - // Ensure the smoothing method is valid - const method = this.config.smoothing.smoothMethod; + + const raw = this.config.smoothing.smoothMethod; + const method = typeof raw === 'string' ? raw.toLowerCase() : raw; this.logger.debug(`Applying smoothing method "${method}"`); if (!smoothingMethods[method]) { - this.logger.error(`Smoothing method "${method}" is not implemented.`); + this.logger.error(`Smoothing method "${raw}" is not implemented.`); return value; } - + // Apply the smoothing method return smoothingMethods[method](this.storedValues); } diff --git a/test/basic/calibration-and-stability.basic.test.js b/test/basic/calibration-and-stability.basic.test.js new file mode 100644 index 0000000..2d22215 --- /dev/null +++ b/test/basic/calibration-and-stability.basic.test.js @@ -0,0 +1,121 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { makeMeasurementInstance } = require('../helpers/factories'); + +/** + * Tests for the calibration / stability / repeatability primitives. These + * methods interact with the stored window from the smoothing pipeline, so + * each test seeds storedValues explicitly. + */ + +test("isStable returns false with fewer than 2 samples", () => { + const m = makeMeasurementInstance(); + m.storedValues = []; + assert.equal(m.isStable(), false); // current implementation returns false (not object) at <2 samples +}); + +test("isStable reports stability and stdDev for a flat window", () => { + const m = makeMeasurementInstance(); + m.storedValues = [10, 10, 10, 10, 10]; + const { isStable, stdDev } = m.isStable(); + assert.equal(isStable, true); + assert.equal(stdDev, 0); +}); + +test("evaluateRepeatability returns stdDev when conditions are met", () => { + const m = makeMeasurementInstance({ + smoothing: { smoothWindow: 5, smoothMethod: 'mean' }, + }); + m.storedValues = [10, 10, 10, 10, 10]; + const rep = m.evaluateRepeatability(); + assert.equal(rep, 0); +}); + +test("evaluateRepeatability refuses when smoothing is disabled", () => { + const m = makeMeasurementInstance({ + smoothing: { smoothWindow: 5, smoothMethod: 'none' }, + }); + m.storedValues = [10, 10, 10, 10, 10]; + assert.equal(m.evaluateRepeatability(), null); +}); + +test("evaluateRepeatability refuses with insufficient samples", () => { + const m = makeMeasurementInstance({ + smoothing: { smoothWindow: 5, smoothMethod: 'mean' }, + }); + m.storedValues = [10]; + assert.equal(m.evaluateRepeatability(), null); +}); + +test("calibrate sets offset when input is stable and scaling enabled", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'mean' }, + }); + // Stable window fed through calculateInput so outputAbs reflects the + // pipeline (important because calibrate uses outputAbs for its delta). + [3, 3, 3, 3, 3].forEach((v) => m.calculateInput(v)); + const outputBefore = m.outputAbs; + m.calibrate(); + // Offset should now be inputMin - outputAbs(before). + assert.equal(m.config.scaling.offset, 4 - outputBefore); +}); + +test("calibrate aborts when input is not stable", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'mean' }, + }); + // Cheat: populate storedValues with clearly non-stable data. calibrate + // calls isStable() -> stdDev > threshold -> warn + no offset change. + m.storedValues = [0, 100, 0, 100, 0]; + const offsetBefore = m.config.scaling.offset; + m.calibrate(); + assert.equal(m.config.scaling.offset, offsetBefore); +}); + +test("calibrate uses absMin when scaling is disabled", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 5, absMax: 10, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'mean' }, + }); + [5, 5, 5, 5, 5].forEach((v) => m.calculateInput(v)); + const out = m.outputAbs; + m.calibrate(); + assert.equal(m.config.scaling.offset, 5 - out); +}); + +test("toggleSimulation flips the simulation flag", () => { + const m = makeMeasurementInstance({ simulation: { enabled: false } }); + m.toggleSimulation(); + assert.equal(m.config.simulation.enabled, true); + m.toggleSimulation(); + assert.equal(m.config.simulation.enabled, false); +}); + +test("tick runs simulateInput when simulation is enabled", async () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' }, + simulation: { enabled: true }, + }); + const before = m.inputValue; + await m.tick(); + await m.tick(); + await m.tick(); + // Simulated input must drift from its initial state. + assert.notEqual(m.inputValue, before); +}); + +test("tick is a no-op on inputValue when simulation is disabled", async () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' }, + simulation: { enabled: false }, + }); + m.inputValue = 42; + await m.tick(); + await m.tick(); + assert.equal(m.inputValue, 42); +}); diff --git a/test/basic/outlier-detection.basic.test.js b/test/basic/outlier-detection.basic.test.js new file mode 100644 index 0000000..7226439 --- /dev/null +++ b/test/basic/outlier-detection.basic.test.js @@ -0,0 +1,98 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { makeMeasurementInstance } = require('../helpers/factories'); + +/** + * Unit coverage for the three outlier detection strategies shipped by the + * measurement node. Each test seeds the storedValues window first, then + * probes the classifier directly. This keeps the assertions focused on the + * detection logic rather than the full calculateInput pipeline. + */ + +function makeDetector(method, threshold) { + return makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -1000, absMax: 1000, offset: 0 }, + smoothing: { smoothWindow: 20, smoothMethod: 'none' }, + outlierDetection: { enabled: true, method, threshold }, + }); +} + +function seed(m, values) { + // bypass calculateInput so we don't trigger outlier filtering while seeding + m.storedValues = [...values]; +} + +test("zScore flags a value far above the mean as an outlier", () => { + const m = makeDetector('zScore', 3); + seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); + assert.equal(m.outlierDetection(100), true); +}); + +test("zScore does not flag a value inside the distribution", () => { + const m = makeDetector('zScore', 3); + seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); + assert.equal(m.outlierDetection(11), false); +}); + +test("iqr flags a value outside Q1/Q3 fences", () => { + const m = makeDetector('iqr'); + seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.equal(m.outlierDetection(100), true); +}); + +test("iqr does not flag a value inside Q1/Q3 fences", () => { + const m = makeDetector('iqr'); + seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.equal(m.outlierDetection(5), false); +}); + +test("modifiedZScore flags heavy-tailed outliers", () => { + const m = makeDetector('modifiedZScore', 3.5); + seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); + assert.equal(m.outlierDetection(1000), true); +}); + +test("modifiedZScore accepts normal data", () => { + const m = makeDetector('modifiedZScore', 3.5); + seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); + assert.equal(m.outlierDetection(11), false); +}); + +test("unknown outlier method falls back to schema default (zScore) and still runs", () => { + // validateEnum replaces unknown values with the schema default. The + // schema default is "zScore"; the dispatcher normalizes to lowercase + // and routes to zScoreOutlierDetection. With a tight window, value=100 + // is a clear outlier -> returns true. + const m = makeDetector('bogus', 3); + seed(m, [1, 2, 3, 4, 5]); + assert.equal(m.outlierDetection(100), true); +}); + +test("outlier detection returns false when window has < 2 samples", () => { + const m = makeDetector('zScore', 3); + m.storedValues = []; + assert.equal(m.outlierDetection(500), false); +}); + +test("calculateInput ignores a value flagged as outlier", () => { + const m = makeDetector('zScore', 3); + // Build a tight baseline then throw a spike at it. + [10, 10, 10, 10, 10].forEach((v) => m.calculateInput(v)); + const before = m.outputAbs; + m.calculateInput(9999); + // Output must not move to the spike (outlier rejected). + assert.equal(m.outputAbs, before); +}); + +test("toggleOutlierDetection flips the flag without corrupting config", () => { + const m = makeDetector('zScore', 3); + const initial = m.config.outlierDetection.enabled; + m.toggleOutlierDetection(); + assert.equal(m.config.outlierDetection.enabled, !initial); + // Re-toggle restores + m.toggleOutlierDetection(); + assert.equal(m.config.outlierDetection.enabled, initial); + // Method is preserved (enum values are normalized to lowercase by validateEnum). + assert.equal(m.config.outlierDetection.method.toLowerCase(), 'zscore'); +}); diff --git a/test/basic/scaling-and-interpolation.basic.test.js b/test/basic/scaling-and-interpolation.basic.test.js new file mode 100644 index 0000000..996b246 --- /dev/null +++ b/test/basic/scaling-and-interpolation.basic.test.js @@ -0,0 +1,122 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { makeMeasurementInstance } = require('../helpers/factories'); + +/** + * Covers the scaling / offset / interpolation primitives and the min/max + * tracking side effects that are not exercised by the existing + * scaling-and-output test. + */ + +test("applyOffset adds configured offset to the input", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 7 }, + }); + assert.equal(m.applyOffset(10), 17); + assert.equal(m.applyOffset(-3), 4); +}); + +test("interpolateLinear maps within range", () => { + const m = makeMeasurementInstance(); + assert.equal(m.interpolateLinear(50, 0, 100, 0, 10), 5); + assert.equal(m.interpolateLinear(0, 0, 100, 0, 10), 0); + assert.equal(m.interpolateLinear(100, 0, 100, 0, 10), 10); +}); + +test("interpolateLinear warns and returns input when ranges collapse", () => { + const m = makeMeasurementInstance(); + // iMin == iMax -> invalid + assert.equal(m.interpolateLinear(42, 0, 0, 0, 10), 42); + // oMin > oMax -> invalid + assert.equal(m.interpolateLinear(42, 0, 100, 10, 0), 42); +}); + +test("constrain clamps below, inside, and above range", () => { + const m = makeMeasurementInstance(); + assert.equal(m.constrain(-5, 0, 10), 0); + assert.equal(m.constrain(5, 0, 10), 5); + assert.equal(m.constrain(15, 0, 10), 10); +}); + +test("handleScaling falls back when inputRange is invalid", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: true, inputMin: 5, inputMax: 5, absMin: 0, absMax: 10, offset: 0 }, + }); + // Before the call, inputRange is 0 (5-5). handleScaling should reset + // inputMin/inputMax to defaults [0, 1] and still return a finite number. + const result = m.handleScaling(0.5); + assert.ok(Number.isFinite(result), `expected finite result, got ${result}`); + assert.equal(m.config.scaling.inputMin, 0); + assert.equal(m.config.scaling.inputMax, 1); +}); + +test("handleScaling constrains out-of-range inputs before interpolating", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 }, + }); + // Input above inputMax is constrained to inputMax then mapped to absMax. + assert.equal(m.handleScaling(150), 10); + // Input below inputMin is constrained to inputMin then mapped to absMin. + assert.equal(m.handleScaling(-20), 0); +}); + +test("calculateInput updates raw min/max from the unfiltered input", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1000, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' }, + }); + m.calculateInput(10); + m.calculateInput(30); + m.calculateInput(5); + assert.equal(m.totalMinValue, 5); + assert.equal(m.totalMaxValue, 30); +}); + +test("updateOutputPercent falls back to observed min/max when processRange <= 0", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 5, absMax: 5, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' }, + }); + // processRange starts at 0 so updateOutputPercent uses totalMinValue/Max. + m.totalMinValue = 0; + m.totalMaxValue = 100; + const pct = m.updateOutputPercent(50); + // Linear interp: (50 - 0) / (100 - 0) * 100 = 50. + assert.ok(Math.abs(pct - 50) < 0.01, `expected ~50, got ${pct}`); +}); + +test("updateOutputAbs only emits MeasurementContainer update when value changes", async () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' }, + }); + let emitCount = 0; + // MeasurementContainer normalizes positions to lowercase, so the + // event name uses 'atequipment' not the camelCase config value. + m.measurements.emitter.on('pressure.measured.atequipment', () => { emitCount += 1; }); + + m.calculateInput(10); + await new Promise((r) => setImmediate(r)); + m.calculateInput(10); // same value -> no emit + await new Promise((r) => setImmediate(r)); + m.calculateInput(20); // new value -> emit + await new Promise((r) => setImmediate(r)); + + assert.equal(emitCount, 2, `expected 2 emits (two distinct values), got ${emitCount}`); +}); + +test("getOutput returns the full tracked state object", () => { + const m = makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' }, + }); + m.calculateInput(15); + const out = m.getOutput(); + assert.equal(typeof out.mAbs, 'number'); + assert.equal(typeof out.mPercent, 'number'); + assert.equal(typeof out.totalMinValue, 'number'); + assert.equal(typeof out.totalMaxValue, 'number'); + assert.equal(typeof out.totalMinSmooth, 'number'); + assert.equal(typeof out.totalMaxSmooth, 'number'); +}); diff --git a/test/basic/smoothing-methods.basic.test.js b/test/basic/smoothing-methods.basic.test.js new file mode 100644 index 0000000..9234343 --- /dev/null +++ b/test/basic/smoothing-methods.basic.test.js @@ -0,0 +1,132 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { makeMeasurementInstance } = require('../helpers/factories'); + +/** + * Baseline coverage for every smoothing method exposed by the measurement + * node. Each test forces scaling off + outlier-detection off so we can + * assert on the raw smoothing arithmetic. + */ + +function makeSmoother(method, windowSize = 5) { + return makeMeasurementInstance({ + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1000, offset: 0 }, + smoothing: { smoothWindow: windowSize, smoothMethod: method }, + }); +} + +function feed(m, values) { + values.forEach((v) => m.calculateInput(v)); +} + +test("smoothing 'none' returns the latest value", () => { + const m = makeSmoother('none'); + feed(m, [10, 20, 30, 40, 50]); + assert.equal(m.outputAbs, 50); +}); + +test("smoothing 'mean' returns arithmetic mean over window", () => { + const m = makeSmoother('mean'); + feed(m, [10, 20, 30, 40, 50]); + assert.equal(m.outputAbs, 30); +}); + +test("smoothing 'min' returns minimum of window", () => { + const m = makeSmoother('min'); + feed(m, [10, 20, 5, 40, 50]); + assert.equal(m.outputAbs, 5); +}); + +test("smoothing 'max' returns maximum of window", () => { + const m = makeSmoother('max'); + feed(m, [10, 20, 5, 40, 50]); + assert.equal(m.outputAbs, 50); +}); + +test("smoothing 'sd' returns standard deviation of window", () => { + const m = makeSmoother('sd'); + feed(m, [2, 4, 4, 4, 5]); + // Expected sample sd of [2,4,4,4,5] = 1.0954..., rounded to 1.1 by the outputAbs pipeline + assert.ok(Math.abs(m.outputAbs - 1.1) < 0.01, `expected ~1.1, got ${m.outputAbs}`); +}); + +test("smoothing 'median' returns median (odd window)", () => { + const m = makeSmoother('median'); + feed(m, [10, 50, 20, 40, 30]); + assert.equal(m.outputAbs, 30); +}); + +test("smoothing 'median' returns average of middle pair (even window)", () => { + const m = makeSmoother('median', 4); + feed(m, [10, 20, 30, 40]); + assert.equal(m.outputAbs, 25); +}); + +test("smoothing 'weightedMovingAverage' weights later samples more", () => { + const m = makeSmoother('weightedMovingAverage'); + feed(m, [10, 10, 10, 10, 50]); + // weights [1,2,3,4,5], sum of weights = 15 + // weighted sum = 10+20+30+40+250 = 350 -> 350/15 = 23.333..., rounded 23.33 + assert.ok(Math.abs(m.outputAbs - 23.33) < 0.02, `expected ~23.33, got ${m.outputAbs}`); +}); + +test("smoothing 'lowPass' attenuates transients", () => { + const m = makeSmoother('lowPass'); + feed(m, [0, 0, 0, 0, 100]); + // EMA(alpha=0.2) from 0,0,0,0,100: last value should be well below 100. + assert.ok(m.outputAbs < 100 * 0.3, `lowPass should attenuate step: ${m.outputAbs}`); + assert.ok(m.outputAbs > 0, `lowPass should still react: ${m.outputAbs}`); +}); + +test("smoothing 'highPass' emphasises differences", () => { + const m = makeSmoother('highPass'); + feed(m, [0, 0, 0, 0, 100]); + // Highpass on a step should produce a positive transient; exact value is + // recursive but we at least require it to be positive and non-zero. + assert.ok(m.outputAbs > 10, `highPass should emphasise step: ${m.outputAbs}`); +}); + +test("smoothing 'bandPass' produces a finite number", () => { + const m = makeSmoother('bandPass'); + feed(m, [1, 2, 3, 4, 5]); + assert.ok(Number.isFinite(m.outputAbs)); +}); + +test("smoothing 'kalman' converges toward steady values", () => { + const m = makeSmoother('kalman'); + feed(m, [100, 100, 100, 100, 100]); + // Kalman filter fed with a constant input should converge to that value + // (within a small tolerance due to its gain smoothing). + assert.ok(Math.abs(m.outputAbs - 100) < 5, `kalman should approach steady value: ${m.outputAbs}`); +}); + +test("smoothing 'savitzkyGolay' returns last sample when window < 5", () => { + const m = makeSmoother('savitzkyGolay', 3); + feed(m, [7, 8, 9]); + assert.equal(m.outputAbs, 9); +}); + +test("smoothing 'savitzkyGolay' smooths across a 5-point window", () => { + const m = makeSmoother('savitzkyGolay', 5); + feed(m, [1, 2, 3, 4, 5]); + // SG coefficients [-3,12,17,12,-3] / 35 on linear data returns the + // middle value unchanged (=3); exact numeric comes out to 35/35 * 3. + assert.ok(Math.abs(m.outputAbs - 3) < 0.01, `SG on linear data should return middle ~3, got ${m.outputAbs}`); +}); + +test("unknown smoothing method falls through to raw value with an error", () => { + const m = makeSmoother('bogus-method'); + // calculateInput will try the unknown key, hit the default branch in the + // applySmoothing map, log an error, and return the raw value (as + // implemented — the test pins that behaviour). + feed(m, [42]); + assert.equal(m.outputAbs, 42); +}); + +test("smoothing window shifts oldest value when exceeded", () => { + const m = makeSmoother('mean', 3); + feed(m, [100, 100, 100, 10, 10, 10]); + // Last three values are [10,10,10]; mean = 10. + assert.equal(m.outputAbs, 10); +}); diff --git a/test/integration/digital-mode.integration.test.js b/test/integration/digital-mode.integration.test.js new file mode 100644 index 0000000..523a0c1 --- /dev/null +++ b/test/integration/digital-mode.integration.test.js @@ -0,0 +1,222 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Measurement = require('../../src/specificClass'); + +/** + * Integration tests for digital mode. + * + * Digital mode accepts an object payload where each key maps to its own + * independently-configured Channel (scaling / smoothing / outlier / unit / + * position). A single inbound message can therefore emit N measurements + * into the MeasurementContainer in one go — the MQTT / JSON IoT pattern + * the analog-centric node previously did not support. + */ + +function makeDigitalConfig(channels, overrides = {}) { + return { + general: { id: 'm-dig-1', name: 'weather-station', unit: 'unitless', logging: { enabled: false, logLevel: 'error' } }, + asset: { type: 'pressure', unit: 'mbar', category: 'sensor', supplier: 'vendor', model: 'BME280' }, + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' }, + simulation: { enabled: false }, + functionality: { positionVsParent: 'atEquipment', distance: null }, + mode: { current: 'digital' }, + channels, + ...overrides, + }; +} + +test('analog-mode default: no channels built, handleDigitalPayload is a no-op', () => { + // Factory without mode config — defaults must stay analog. + const m = new Measurement({ + general: { id: 'a', name: 'a', unit: 'bar', logging: { enabled: false, logLevel: 'error' } }, + asset: { type: 'pressure', unit: 'bar', category: 'sensor', supplier: 'v', model: 'M' }, + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' }, + simulation: { enabled: false }, + functionality: { positionVsParent: 'atEquipment' }, + }); + assert.equal(m.mode, 'analog'); + assert.equal(m.channels.size, 0); + // In analog mode, handleDigitalPayload must refuse and not mutate state. + const res = m.handleDigitalPayload({ temperature: 21 }); + assert.deepEqual(res, {}); +}); + +test('digital mode builds one Channel per config.channels entry', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, + { key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, + { key: 'pressure', type: 'pressure', position: 'atEquipment', unit: 'mbar', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 800, absMax: 1200, offset: 0 }, + smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, + ])); + assert.equal(m.mode, 'digital'); + assert.equal(m.channels.size, 3); + assert.ok(m.channels.has('temperature')); + assert.ok(m.channels.has('humidity')); + assert.ok(m.channels.has('pressure')); +}); + +test('digital payload routes each key to its own channel', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + { key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + ])); + + m.handleDigitalPayload({ temperature: 21.5, humidity: 65 }); + + const tempOut = m.channels.get('temperature').outputAbs; + const humidOut = m.channels.get('humidity').outputAbs; + assert.equal(tempOut, 21.5); + assert.equal(humidOut, 65); +}); + +test('digital payload emits on the MeasurementContainer per channel', async () => { + const m = new Measurement(makeDigitalConfig([ + { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + { key: 'h', type: 'humidity', position: 'atEquipment', unit: '%', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + ])); + + const events = []; + m.measurements.emitter.on('temperature.measured.atequipment', (e) => events.push({ on: 't', value: e.value })); + m.measurements.emitter.on('humidity.measured.atequipment', (e) => events.push({ on: 'h', value: e.value })); + + m.handleDigitalPayload({ t: 22, h: 50 }); + await new Promise((r) => setImmediate(r)); + + assert.equal(events.filter((e) => e.on === 't').length, 1); + assert.equal(events.filter((e) => e.on === 'h').length, 1); + assert.equal(events.find((e) => e.on === 't').value, 22); + assert.equal(events.find((e) => e.on === 'h').value, 50); +}); + +test('digital payload with unmapped keys silently ignores them', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + ])); + + const res = m.handleDigitalPayload({ t: 20, unknown: 999, extra: 'x' }); + assert.equal(m.channels.get('t').outputAbs, 20); + assert.equal(res.t.ok, true); + assert.equal(res.unknown, undefined); + assert.equal(res.extra, undefined); +}); + +test('digital channel with scaling enabled maps input to abs range', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 'pt', type: 'pressure', position: 'atEquipment', unit: 'mbar', + scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + ])); + + m.handleDigitalPayload({ pt: 50 }); + // 50% of [0..100] -> 50% of [0..1000] = 500 + assert.equal(m.channels.get('pt').outputAbs, 500); +}); + +test('digital channel smoothing accumulates per-channel, independent of siblings', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, + { key: 'h', type: 'humidity', position: 'atEquipment', unit: '%', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, + ])); + + // Feed only temperature across 3 pushes; humidity never receives a value. + m.handleDigitalPayload({ t: 10 }); + m.handleDigitalPayload({ t: 20 }); + m.handleDigitalPayload({ t: 30 }); + + assert.equal(m.channels.get('t').outputAbs, 20); // mean(10,20,30)=20 + assert.equal(m.channels.get('t').storedValues.length, 3); + // Humidity channel must be untouched. + assert.equal(m.channels.get('h').storedValues.length, 0); + assert.equal(m.channels.get('h').outputAbs, 0); +}); + +test('digital channel rejects non-numeric values in summary', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + ])); + + const res = m.handleDigitalPayload({ t: 'banana' }); + assert.equal(res.t.ok, false); + assert.equal(res.t.reason, 'non-numeric'); + assert.equal(m.channels.get('t').outputAbs, 0); +}); + +test('digital channel supports per-channel outlier detection', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 10, smoothMethod: 'none' }, + outlierDetection: { enabled: true, method: 'zscore', threshold: 3 } }, + ])); + + // Seed a tight baseline then lob an obvious spike. + for (const v of [20, 20, 20, 20, 20, 20]) m.handleDigitalPayload({ t: v }); + const baselineOut = m.channels.get('t').outputAbs; + m.handleDigitalPayload({ t: 1e6 }); + assert.equal(m.channels.get('t').outputAbs, baselineOut, 'spike must be rejected as outlier'); +}); + +test('getDigitalOutput produces one entry per channel', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + { key: 'h', type: 'humidity', position: 'atEquipment', unit: '%', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + ])); + + m.handleDigitalPayload({ t: 25, h: 40 }); + const out = m.getDigitalOutput(); + assert.ok(out.channels.t); + assert.ok(out.channels.h); + assert.equal(out.channels.t.mAbs, 25); + assert.equal(out.channels.h.mAbs, 40); + assert.equal(out.channels.t.type, 'temperature'); + assert.equal(out.channels.h.unit, '%'); +}); + +test('digital mode with empty channels array still constructs cleanly', () => { + const m = new Measurement(makeDigitalConfig([])); + assert.equal(m.mode, 'digital'); + assert.equal(m.channels.size, 0); + // No throw on empty payload. + assert.deepEqual(m.handleDigitalPayload({ anything: 1 }), {}); +}); + +test('digital mode ignores malformed channel entries in config', () => { + const m = new Measurement(makeDigitalConfig([ + { key: 'valid', type: 'temperature', position: 'atEquipment', unit: 'C', + scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, + smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, + null, // malformed + { key: 'no_type' }, // missing type + { type: 'pressure' }, // missing key + ])); + assert.equal(m.channels.size, 1); + assert.ok(m.channels.has('valid')); +});