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>
133 lines
4.8 KiB
JavaScript
133 lines
4.8 KiB
JavaScript
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);
|
|
});
|