From 497f05d92c6bb18182baf0457c67d8a53afc2e9b Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 11 May 2026 17:29:15 +0200 Subject: [PATCH] B1.3: isStable real threshold (config-driven, replaces tautology) The legacy stdDev < stdDev*2 was always true. New behaviour: stdDev <= config.calibration.stabilityThreshold OR stdDev === 0. Default threshold 0.01 in scaling-units. Schema field + editor UI added. 4 BUG-PRESERVED tests rewritten + 4 new edge tests. 101/101 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- measurement.html | 15 +++++++ src/calibration/calibrator.js | 15 ++++--- src/nodeClass.js | 1 + test/basic/calibrator.basic.test.js | 62 ++++++++++++++++++++++++----- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/measurement.html b/measurement.html index 31c5594..03e44c3 100644 --- a/measurement.html +++ b/measurement.html @@ -34,6 +34,7 @@ simulator: { value: false }, smooth_method: { value: "" }, count: { value: "10", required: true }, + stabilityThreshold: { value: 0.01 }, processOutputFormat: { value: "process" }, dbaseOutputFormat: { value: "influxdb" }, @@ -227,6 +228,12 @@ (field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0) ); + // Calibration stability threshold: 0 is a valid (very strict) value, so + // fall back to the default 0.01 only when the field is empty / NaN. + const stRaw = document.getElementById('node-input-stabilityThreshold').value; + const stParsed = parseFloat(stRaw); + node.stabilityThreshold = Number.isFinite(stParsed) ? stParsed : 0.01; + // Mode-dependent validation. In digital mode we don't care about // scaling completeness (the channels have their own per-channel // scaling); in analog mode we still warn about half-filled ranges. @@ -329,6 +336,14 @@
Number of samples for smoothing
+ + +
+ + + (scaling-units) +
Maximum stdDev of the rolling window for calibrate() and evaluateRepeatability() to accept the buffer as stable. Default 0.01.
+

diff --git a/src/calibration/calibrator.js b/src/calibration/calibrator.js index 9b56688..56a8449 100644 --- a/src/calibration/calibrator.js +++ b/src/calibration/calibrator.js @@ -2,7 +2,7 @@ const { stats } = require('generalFunctions'); -const MARGIN_FACTOR = 2; +const DEFAULT_STABILITY_THRESHOLD = 0.01; /** * Calibration helper extracted from measurement/specificClass.js. @@ -23,8 +23,9 @@ class Calibrator { /** * Decide whether the rolling window is stable enough to trust. - * Mirrors the original threshold check; with `stdDev=0` (constant input) - * the comparison short-circuits to true. + * Compares the window's stdDev against config.calibration.stabilityThreshold + * (absolute, in scaling-units). A constant buffer (stdDev=0) is always + * stable regardless of threshold. */ isStable() { const values = this._storedValues(); @@ -32,8 +33,12 @@ class Calibrator { return { isStable: false, stdDev: 0 }; } const stdDev = stats.stdDev(values); - const stableThreshold = stdDev * MARGIN_FACTOR; - return { isStable: stdDev < stableThreshold || stdDev === 0, stdDev }; + const cfg = this._config(); + const raw = cfg && cfg.calibration && cfg.calibration.stabilityThreshold; + const threshold = Number.isFinite(Number(raw)) && Number(raw) >= 0 + ? Number(raw) + : DEFAULT_STABILITY_THRESHOLD; + return { isStable: stdDev === 0 || stdDev <= threshold, stdDev }; } /** diff --git a/src/nodeClass.js b/src/nodeClass.js index ed55510..73fcf0e 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -33,6 +33,7 @@ class nodeClass extends BaseNodeAdapter { }, smoothing: { smoothWindow: uiConfig.count, smoothMethod: uiConfig.smooth_method }, simulation: { enabled: uiConfig.simulator }, + calibration: { stabilityThreshold: uiConfig.stabilityThreshold }, mode: { current: mode }, channels, }; diff --git a/test/basic/calibrator.basic.test.js b/test/basic/calibrator.basic.test.js index 4c1b34b..3a40b2e 100644 --- a/test/basic/calibrator.basic.test.js +++ b/test/basic/calibrator.basic.test.js @@ -34,17 +34,50 @@ test('isStable: constant array → stable with stdDev=0', () => { assert.strictEqual(r.stdDev, 0); }); -test('isStable: high-variance array → original threshold is tautological (preserved)', () => { - // BUG-PRESERVED: original check is `stdDev < stdDev*marginFactor`, which is - // always true for stdDev>0. Length>=2 ⇒ isStable=true regardless of spread. - // See calibrator stdDev-threshold note. We pin the behaviour here so the - // refactor stays byte-equivalent; a separate behavioural PR can fix the rule. +test('isStable: high-variance array under default threshold → unstable', () => { + // Resolved 2026-05-11: config-driven absolute stabilityThreshold replaces + // the old `stdDev < stdDev*marginFactor` tautology. Default threshold is + // 0.01 (scaling-units); a 0..100 spread blows past it. const { cal } = makeCalibrator([0, 100, 0, 100], {}); const r = cal.isStable(); + assert.strictEqual(r.isStable, false); + assert.ok(r.stdDev > 0); +}); + +test('isStable: high-variance array with relaxed threshold → stable', () => { + const cfg = { calibration: { stabilityThreshold: 100 } }; + const { cal } = makeCalibrator([0, 100, 0, 100], cfg); + const r = cal.isStable(); assert.strictEqual(r.isStable, true); assert.ok(r.stdDev > 0); }); +test('isStable: zero stdDev (constant) is stable regardless of threshold', () => { + const cfg = { calibration: { stabilityThreshold: 0 } }; + const { cal } = makeCalibrator([7, 7, 7, 7], cfg); + const r = cal.isStable(); + assert.strictEqual(r.isStable, true); + assert.strictEqual(r.stdDev, 0); +}); + +test('isStable: stdDev just above threshold → unstable', () => { + const cfg = { calibration: { stabilityThreshold: 0.5 } }; + // stdDev of [10, 11] = 0.5; nudge the spread up so stdDev > 0.5. + const { cal } = makeCalibrator([10, 12], cfg); + const r = cal.isStable(); + assert.strictEqual(r.isStable, false); + assert.ok(r.stdDev > 0.5); +}); + +test('isStable: missing config.calibration → falls back to default 0.01', () => { + // stdDev of [10, 10.001] ≈ 0.0005, well under the 0.01 default. + const { cal: stable } = makeCalibrator([10, 10.001], {}); + assert.strictEqual(stable.isStable().isStable, true); + // stdDev of [10, 10.1] ≈ 0.05, above the 0.01 default. + const { cal: unstable } = makeCalibrator([10, 10.1], {}); + assert.strictEqual(unstable.isStable().isStable, false); +}); + test('isStable: < 2 values → unstable', () => { const { cal } = makeCalibrator([42], {}); const r = cal.isStable(); @@ -101,11 +134,22 @@ test('evaluateRepeatability: insufficient data → null', () => { assert.strictEqual(r.reason, 'insufficient-data'); }); -test('evaluateRepeatability: high-variance still returns stdDev (preserved tautology)', () => { - // BUG-PRESERVED: see isStable note. Original rule treats any length>=2 - // buffer as stable, so repeatability returns the raw stdDev even when the - // spread is large. +test('evaluateRepeatability: high-variance under default threshold → null', () => { + // Resolved 2026-05-11: with the real stability check in place, a noisy + // buffer fails isStable() and repeatability reports null with reason. const cfg = { smoothing: { smoothMethod: 'mean' } }; + const { cal, logger } = makeCalibrator([0, 50, 0, 50], cfg); + const r = cal.evaluateRepeatability(); + assert.strictEqual(r.repeatability, null); + assert.strictEqual(r.reason, 'unstable'); + assert.match(logger.calls.warn[0], /not stable/); +}); + +test('evaluateRepeatability: high-variance with relaxed threshold → returns stdDev', () => { + const cfg = { + smoothing: { smoothMethod: 'mean' }, + calibration: { stabilityThreshold: 100 }, + }; const { cal } = makeCalibrator([0, 50, 0, 50], cfg); const r = cal.evaluateRepeatability(); assert.ok(r.repeatability > 0);