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:
119
README.md
119
README.md
@@ -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. 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 `<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.
|
||||
|
||||
@@ -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
311
src/channel.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
121
test/basic/calibration-and-stability.basic.test.js
Normal file
121
test/basic/calibration-and-stability.basic.test.js
Normal 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);
|
||||
});
|
||||
98
test/basic/outlier-detection.basic.test.js
Normal file
98
test/basic/outlier-detection.basic.test.js
Normal 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');
|
||||
});
|
||||
122
test/basic/scaling-and-interpolation.basic.test.js
Normal file
122
test/basic/scaling-and-interpolation.basic.test.js
Normal 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');
|
||||
});
|
||||
132
test/basic/smoothing-methods.basic.test.js
Normal file
132
test/basic/smoothing-methods.basic.test.js
Normal 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);
|
||||
});
|
||||
222
test/integration/digital-mode.integration.test.js
Normal file
222
test/integration/digital-mode.integration.test.js
Normal 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'));
|
||||
});
|
||||
Reference in New Issue
Block a user