Compare commits
1 Commits
e6e212a504
...
497f05d92c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497f05d92c |
@@ -34,6 +34,7 @@
|
|||||||
simulator: { value: false },
|
simulator: { value: false },
|
||||||
smooth_method: { value: "" },
|
smooth_method: { value: "" },
|
||||||
count: { value: "10", required: true },
|
count: { value: "10", required: true },
|
||||||
|
stabilityThreshold: { value: 0.01 },
|
||||||
processOutputFormat: { value: "process" },
|
processOutputFormat: { value: "process" },
|
||||||
dbaseOutputFormat: { value: "influxdb" },
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
@@ -227,6 +228,12 @@
|
|||||||
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
(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
|
// Mode-dependent validation. In digital mode we don't care about
|
||||||
// scaling completeness (the channels have their own per-channel
|
// scaling completeness (the channels have their own per-channel
|
||||||
// scaling); in analog mode we still warn about half-filled ranges.
|
// scaling); in analog mode we still warn about half-filled ranges.
|
||||||
@@ -329,6 +336,14 @@
|
|||||||
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
||||||
<div class="form-tips">Number of samples for smoothing</div>
|
<div class="form-tips">Number of samples for smoothing</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Calibration Stability Threshold -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-stabilityThreshold"><i class="fa fa-balance-scale"></i> Stability Threshold</label>
|
||||||
|
<input type="number" id="node-input-stabilityThreshold" placeholder="0.01" step="any" style="width:100px;"/>
|
||||||
|
<span style="margin-left:6px; color:#666;">(scaling-units)</span>
|
||||||
|
<div class="form-tips">Maximum stdDev of the rolling window for calibrate() and evaluateRepeatability() to accept the buffer as stable. Default 0.01.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const { stats } = require('generalFunctions');
|
const { stats } = require('generalFunctions');
|
||||||
|
|
||||||
const MARGIN_FACTOR = 2;
|
const DEFAULT_STABILITY_THRESHOLD = 0.01;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calibration helper extracted from measurement/specificClass.js.
|
* Calibration helper extracted from measurement/specificClass.js.
|
||||||
@@ -23,8 +23,9 @@ class Calibrator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether the rolling window is stable enough to trust.
|
* Decide whether the rolling window is stable enough to trust.
|
||||||
* Mirrors the original threshold check; with `stdDev=0` (constant input)
|
* Compares the window's stdDev against config.calibration.stabilityThreshold
|
||||||
* the comparison short-circuits to true.
|
* (absolute, in scaling-units). A constant buffer (stdDev=0) is always
|
||||||
|
* stable regardless of threshold.
|
||||||
*/
|
*/
|
||||||
isStable() {
|
isStable() {
|
||||||
const values = this._storedValues();
|
const values = this._storedValues();
|
||||||
@@ -32,8 +33,12 @@ class Calibrator {
|
|||||||
return { isStable: false, stdDev: 0 };
|
return { isStable: false, stdDev: 0 };
|
||||||
}
|
}
|
||||||
const stdDev = stats.stdDev(values);
|
const stdDev = stats.stdDev(values);
|
||||||
const stableThreshold = stdDev * MARGIN_FACTOR;
|
const cfg = this._config();
|
||||||
return { isStable: stdDev < stableThreshold || stdDev === 0, stdDev };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
},
|
},
|
||||||
smoothing: { smoothWindow: uiConfig.count, smoothMethod: uiConfig.smooth_method },
|
smoothing: { smoothWindow: uiConfig.count, smoothMethod: uiConfig.smooth_method },
|
||||||
simulation: { enabled: uiConfig.simulator },
|
simulation: { enabled: uiConfig.simulator },
|
||||||
|
calibration: { stabilityThreshold: uiConfig.stabilityThreshold },
|
||||||
mode: { current: mode },
|
mode: { current: mode },
|
||||||
channels,
|
channels,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,17 +34,50 @@ test('isStable: constant array → stable with stdDev=0', () => {
|
|||||||
assert.strictEqual(r.stdDev, 0);
|
assert.strictEqual(r.stdDev, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isStable: high-variance array → original threshold is tautological (preserved)', () => {
|
test('isStable: high-variance array under default threshold → unstable', () => {
|
||||||
// BUG-PRESERVED: original check is `stdDev < stdDev*marginFactor`, which is
|
// Resolved 2026-05-11: config-driven absolute stabilityThreshold replaces
|
||||||
// always true for stdDev>0. Length>=2 ⇒ isStable=true regardless of spread.
|
// the old `stdDev < stdDev*marginFactor` tautology. Default threshold is
|
||||||
// See calibrator stdDev-threshold note. We pin the behaviour here so the
|
// 0.01 (scaling-units); a 0..100 spread blows past it.
|
||||||
// refactor stays byte-equivalent; a separate behavioural PR can fix the rule.
|
|
||||||
const { cal } = makeCalibrator([0, 100, 0, 100], {});
|
const { cal } = makeCalibrator([0, 100, 0, 100], {});
|
||||||
const r = cal.isStable();
|
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.strictEqual(r.isStable, true);
|
||||||
assert.ok(r.stdDev > 0);
|
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', () => {
|
test('isStable: < 2 values → unstable', () => {
|
||||||
const { cal } = makeCalibrator([42], {});
|
const { cal } = makeCalibrator([42], {});
|
||||||
const r = cal.isStable();
|
const r = cal.isStable();
|
||||||
@@ -101,11 +134,22 @@ test('evaluateRepeatability: insufficient data → null', () => {
|
|||||||
assert.strictEqual(r.reason, 'insufficient-data');
|
assert.strictEqual(r.reason, 'insufficient-data');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('evaluateRepeatability: high-variance still returns stdDev (preserved tautology)', () => {
|
test('evaluateRepeatability: high-variance under default threshold → null', () => {
|
||||||
// BUG-PRESERVED: see isStable note. Original rule treats any length>=2
|
// Resolved 2026-05-11: with the real stability check in place, a noisy
|
||||||
// buffer as stable, so repeatability returns the raw stdDev even when the
|
// buffer fails isStable() and repeatability reports null with reason.
|
||||||
// spread is large.
|
|
||||||
const cfg = { smoothing: { smoothMethod: 'mean' } };
|
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 { cal } = makeCalibrator([0, 50, 0, 50], cfg);
|
||||||
const r = cal.evaluateRepeatability();
|
const r = cal.evaluateRepeatability();
|
||||||
assert.ok(r.repeatability > 0);
|
assert.ok(r.repeatability > 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user