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) <noreply@anthropic.com>
157 lines
5.8 KiB
JavaScript
157 lines
5.8 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const Calibrator = require('../../src/calibration/calibrator.js');
|
|
|
|
// Tiny logger spy so we can assert on warn() without pulling in the real
|
|
// generalFunctions logger.
|
|
function makeLogger() {
|
|
const calls = { warn: [], info: [], debug: [], error: [] };
|
|
return {
|
|
calls,
|
|
warn: (m) => calls.warn.push(m),
|
|
info: (m) => calls.info.push(m),
|
|
debug: (m) => calls.debug.push(m),
|
|
error: (m) => calls.error.push(m),
|
|
};
|
|
}
|
|
|
|
function makeCalibrator(values, config) {
|
|
const logger = makeLogger();
|
|
const cal = new Calibrator({
|
|
storedValuesRef: () => values,
|
|
configRef: () => config,
|
|
logger,
|
|
});
|
|
return { cal, logger };
|
|
}
|
|
|
|
test('isStable: constant array → stable with stdDev=0', () => {
|
|
const { cal } = makeCalibrator([5, 5, 5, 5], {});
|
|
const r = cal.isStable();
|
|
assert.strictEqual(r.isStable, true);
|
|
assert.strictEqual(r.stdDev, 0);
|
|
});
|
|
|
|
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();
|
|
assert.strictEqual(r.isStable, false);
|
|
assert.strictEqual(r.stdDev, 0);
|
|
});
|
|
|
|
test('calibrate: scaling enabled → offset = inputMin - currentOutputAbs', () => {
|
|
const cfg = { scaling: { enabled: true, inputMin: 4, absMin: 0 } };
|
|
const { cal } = makeCalibrator([10, 10, 10], cfg);
|
|
const r = cal.calibrate(10);
|
|
assert.deepStrictEqual(r, { offset: -6 });
|
|
});
|
|
|
|
test('calibrate: scaling disabled → offset = absMin - currentOutputAbs', () => {
|
|
const cfg = { scaling: { enabled: false, inputMin: 4, absMin: 1 } };
|
|
const { cal } = makeCalibrator([7, 7, 7], cfg);
|
|
const r = cal.calibrate(7);
|
|
assert.deepStrictEqual(r, { offset: -6 });
|
|
});
|
|
|
|
test('calibrate: not stable (length<2) → returns null and logs warn', () => {
|
|
// Original rule has a tautological threshold, so "unstable" only triggers
|
|
// when the rolling window has < 2 samples.
|
|
const cfg = { scaling: { enabled: true, inputMin: 0, absMin: 0 } };
|
|
const { cal, logger } = makeCalibrator([], cfg);
|
|
const r = cal.calibrate(50);
|
|
assert.strictEqual(r, null);
|
|
assert.strictEqual(logger.calls.warn.length, 1);
|
|
assert.match(logger.calls.warn[0], /Calibration aborted/);
|
|
});
|
|
|
|
test('evaluateRepeatability: smoothing=none → null', () => {
|
|
const cfg = { smoothing: { smoothMethod: 'none' } };
|
|
const { cal, logger } = makeCalibrator([5, 5, 5], cfg);
|
|
const r = cal.evaluateRepeatability();
|
|
assert.strictEqual(r.repeatability, null);
|
|
assert.strictEqual(r.reason, 'smoothing-disabled');
|
|
assert.match(logger.calls.warn[0], /without smoothing/);
|
|
});
|
|
|
|
test('evaluateRepeatability: stable + smoothed → returns stdDev', () => {
|
|
const cfg = { smoothing: { smoothMethod: 'mean' } };
|
|
const { cal } = makeCalibrator([3, 3, 3, 3], cfg);
|
|
const r = cal.evaluateRepeatability();
|
|
assert.strictEqual(r.repeatability, 0);
|
|
});
|
|
|
|
test('evaluateRepeatability: insufficient data → null', () => {
|
|
const cfg = { smoothing: { smoothMethod: 'mean' } };
|
|
const { cal } = makeCalibrator([5], cfg);
|
|
const r = cal.evaluateRepeatability();
|
|
assert.strictEqual(r.repeatability, null);
|
|
assert.strictEqual(r.reason, 'insufficient-data');
|
|
});
|
|
|
|
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);
|
|
});
|