Files
measurement/test/basic/scaling-and-interpolation.basic.test.js

123 lines
4.8 KiB
JavaScript
Raw Normal View History

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>
2026-04-13 13:43:03 +02:00
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');
});