feat: digital (MQTT) mode + fix silent dispatcher bug for camelCase methods

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) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-13 13:43:03 +02:00
parent 0918be7705
commit 495b4cf400
10 changed files with 1367 additions and 45 deletions

119
README.md
View File

@@ -1,3 +1,118 @@
# convert
# measurement
Makes unit conversions
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: {<key>: {mAbs, mPercent, ...}}}`. Delta-compressed — only changed fields emit each tick. |
| 1 | `dbase` | InfluxDB line-protocol telemetry |
| 2 | `parent` | `{topic:"registerChild", payload:<nodeId>, 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. 420 mA) |
| `Input Offset` | additive bias applied before scaling |
| `Process Min / Max` | output-side range (e.g. 03000 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 `<type>.<variant>.<position>` 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.

View File

@@ -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 @@
<script type="text/html" data-template-name="measurement">
<!-- Input mode -->
<div class="form-row">
<label for="node-input-mode"><i class="fa fa-exchange"></i> Input Mode</label>
<select id="node-input-mode" style="width:60%;">
<option value="analog">analog one scalar per msg.payload (classic PLC)</option>
<option value="digital">digital object payload with many channel keys (MQTT/IoT)</option>
</select>
</div>
<div class="form-row" id="row-input-channels">
<label for="node-input-channels"><i class="fa fa-list"></i> Channels (JSON)</label>
<textarea id="node-input-channels" rows="6" style="width:60%; font-family:monospace;" placeholder='[{"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"}}]'></textarea>
<div class="form-tips">Digital mode only. One entry per payload key. See README for schema.</div>
</div>
<hr>
<!-- Scaling Checkbox -->
<div class="form-row">
<label for="node-input-scaling"
@@ -256,20 +277,49 @@
<script type="text/html" data-help-name="measurement">
<p><b>Measurement Node</b>: Scales, smooths, and simulates measurement data.</p>
<p>Use this node to scale, smooth, and simulate measurement data. The node can be configured to scale input data to a specified range, smooth the data using a variety of methods, and simulate data for testing purposes.</p>
<li><b>Supplier:</b> Select a supplier to populate machine options.</li>
<li><b>SubType:</b> Select a subtype if applicable to further categorize the asset.</li>
<li><b>Model:</b> Define the specific model for more granular asset configuration.</li>
<li><b>Unit:</b> Assign a unit to standardize measurements or operations.</li>
<li><b>Scaling:</b> Enable or disable input scaling. When enabled, you must provide the source min and max values.</li>
<li><b>Source Min/Max:</b> Define the minimum and maximum values for the input range when scaling is enabled.</li>
<li><b>Input Offset:</b> Specify an offset value to be added to the input measurement.</li>
<li><b>Process Min/Max:</b> Define the minimum and maximum values for the output range after processing.</li>
<li><b>Simulator:</b> Activate internal simulation for testing purposes.</li>
<li><b>Smoothing:</b> Select a smoothing method to apply to the measurement data.</li>
<li><b>Window:</b> Define the number of samples to use for smoothing.</li>
<li><b>Enable Log:</b> Enable or disable logging for this node.</li>
<li><b>Log Level:</b> Select the log level (Info, Debug, Warn, Error) for logging messages.</li>
<p><b>Measurement</b>: signal conditioning for a sensor or a bundle of sensors. Runs offset scaling smoothing outlier filtering on each incoming value and publishes into the shared <code>MeasurementContainer</code>.</p>
<h3>Input modes</h3>
<ul>
<li><b>analog</b> (default) <code>msg.payload</code> is a single number (PLC / 4-20 mA style). One pipeline, one output measurement.</li>
<li><b>digital</b> <code>msg.payload</code> is an object with many keys (MQTT / JSON IoT). Each key maps to its own <i>channel</i> with independent scaling, smoothing, outlier detection, type, position, unit. One message N measurements.</li>
</ul>
<h3>Topics (<code>msg.topic</code>)</h3>
<ul>
<li><code>measurement</code> main input. analog: number; digital: object keyed by channel names.</li>
<li><code>simulator</code> toggle the internal random-walk source.</li>
<li><code>outlierDetection</code> toggle the outlier filter.</li>
<li><code>calibrate</code> set offset so current output matches <code>Source Min</code> (scaling on) / <code>Process Min</code> (scaling off). Requires a stable window.</li>
</ul>
<h3>Output ports</h3>
<ol>
<li><b>process</b> delta-compressed payload. analog: <code>{mAbs, mPercent, totalMinValue, totalMaxValue, totalMinSmooth, totalMaxSmooth}</code>. digital: <code>{channels: { key: {...} }}</code>.</li>
<li><b>dbase</b> InfluxDB line-protocol telemetry.</li>
<li><b>parent</b> <code>registerChild</code> handshake for the parent equipment node.</li>
</ol>
<h3>Analog configuration</h3>
<ul>
<li><b>Scaling</b>: enables linear interpolation from <code>[Source Min, Source Max]</code> to <code>[Process Min, Process Max]</code>.</li>
<li><b>Input Offset</b>: additive bias applied before scaling.</li>
<li><b>Smoothing</b>: <code>none</code> | <code>mean</code> | <code>min</code> | <code>max</code> | <code>sd</code> | <code>lowPass</code> | <code>highPass</code> | <code>weightedMovingAverage</code> | <code>bandPass</code> | <code>median</code> | <code>kalman</code> | <code>savitzkyGolay</code>.</li>
<li><b>Window</b>: sample count for the smoothing window.</li>
<li><b>Outlier detection</b> (via <code>outlierDetection</code> topic toggle): <code>zScore</code>, <code>iqr</code>, <code>modifiedZScore</code>.</li>
</ul>
<h3>Digital configuration</h3>
<p>Populate the <b>Channels (JSON)</b> field with an array. Each entry:</p>
<pre>{
"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 }
}</pre>
<p><code>scaling</code>, <code>smoothing</code>, <code>outlierDetection</code> are optional missing sections fall back to the analog-mode fields above.</p>
<p>Unknown <code>type</code> values (anything not in <code>pressure/flow/power/temperature/volume/length/mass/energy</code>) are accepted without unit compatibility checks, so user-defined channels like <code>humidity</code>, <code>co2</code>, <code>voc</code> work out of the box.</p>
</script>

311
src/channel.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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