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>
99 lines
3.6 KiB
JavaScript
99 lines
3.6 KiB
JavaScript
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');
|
|
});
|