webui: add pure calibration module with unit tests
Implements Calib JS module (calibration.js) with affine transform primitives, CalTable, and normaliseCal matching Go CalConfig.Normalise semantics; 14 node --test cases all pass. Loads before app.js in index.html. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b1c2a34eea
commit
21d084d2ea
@@ -0,0 +1,119 @@
|
|||||||
|
// Per-signal affine calibration: value = raw * scale + offset, with an optional
|
||||||
|
// unit override. Pure and dependency-free so it can be unit-tested under Node;
|
||||||
|
// in the browser it defines the global `Calib`.
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var MAX_UNIT_LEN = 16;
|
||||||
|
var IDENTITY = Object.freeze({scale: 1, offset: 0, unit: ''});
|
||||||
|
|
||||||
|
// The table is keyed by (source label, base signal name). U+0000 cannot occur
|
||||||
|
// in either, so it is an unambiguous separator.
|
||||||
|
function calKey(source, signal) {
|
||||||
|
return source + '\u0000' + signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'Adc[3]' -> 'Adc'. One calibration covers every element of an array signal.
|
||||||
|
function baseSignalName(name) {
|
||||||
|
var s = String(name == null ? '' : name);
|
||||||
|
var open = s.lastIndexOf('[');
|
||||||
|
if (open > 0 && s.charAt(s.length - 1) === ']') {
|
||||||
|
var idx = s.slice(open + 1, s.length - 1);
|
||||||
|
if (idx.length > 0 && /^[0-9]+$/.test(idx)) return s.slice(0, open);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNum(v) {
|
||||||
|
return typeof v === 'number' && isFinite(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the hub-side validation exactly (Go: CalConfig.Normalise,
|
||||||
|
// C++: StreamHub::HandleSetCalibration). Returns null when the entry must be
|
||||||
|
// rejected, so a caller can revert an input field to its last accepted value.
|
||||||
|
function normaliseCal(obj) {
|
||||||
|
if (obj === null || typeof obj !== 'object') return null;
|
||||||
|
var source = String(obj.source == null ? '' : obj.source).trim();
|
||||||
|
var signal = baseSignalName(String(obj.signal == null ? '' : obj.signal).trim());
|
||||||
|
if (source === '' || signal === '') return null;
|
||||||
|
var scale = obj.scale === undefined ? 1 : obj.scale;
|
||||||
|
var offset = obj.offset === undefined ? 0 : obj.offset;
|
||||||
|
if (!isFiniteNum(scale) || scale === 0) return null;
|
||||||
|
if (!isFiniteNum(offset)) return null;
|
||||||
|
var unit = String(obj.unit == null ? '' : obj.unit).trim();
|
||||||
|
if (unit.length > MAX_UNIT_LEN) unit = unit.slice(0, MAX_UNIT_LEN);
|
||||||
|
return {source: source, signal: signal, scale: scale, offset: offset, unit: unit};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentity(cal) {
|
||||||
|
return cal.scale === 1 && cal.offset === 0 && cal.unit === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCal(raw, cal) {
|
||||||
|
return raw * cal.scale + cal.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invertCal(value, cal) {
|
||||||
|
return (value - cal.offset) / cal.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative scale swaps the ends of a range, so re-order after calibrating.
|
||||||
|
function calRange(min, max, cal) {
|
||||||
|
var a = applyCal(min, cal), b = applyCal(max, cal);
|
||||||
|
return a <= b ? [a, b] : [b, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalTable() {
|
||||||
|
this._m = Object.create(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
CalTable.prototype.get = function (source, signal) {
|
||||||
|
var e = this._m[calKey(source, baseSignalName(signal))];
|
||||||
|
return e === undefined ? IDENTITY : e;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns false when the entry was rejected as invalid. An entry that reduces
|
||||||
|
// to the identity is deleted rather than stored, so a Reset cleans the table
|
||||||
|
// (and, once saved, the config file) instead of filling it with no-ops.
|
||||||
|
CalTable.prototype.set = function (entry) {
|
||||||
|
var c = normaliseCal(entry);
|
||||||
|
if (c === null) return false;
|
||||||
|
var k = calKey(c.source, c.signal);
|
||||||
|
if (isIdentity(c)) delete this._m[k];
|
||||||
|
else this._m[k] = c;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
CalTable.prototype.replaceAll = function (list) {
|
||||||
|
this._m = Object.create(null);
|
||||||
|
if (!list) return;
|
||||||
|
for (var i = 0; i < list.length; i++) this.set(list[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
CalTable.prototype.list = function () {
|
||||||
|
var out = [], k;
|
||||||
|
for (k in this._m) out.push(this._m[k]);
|
||||||
|
out.sort(function (a, b) {
|
||||||
|
if (a.source !== b.source) return a.source < b.source ? -1 : 1;
|
||||||
|
if (a.signal !== b.signal) return a.signal < b.signal ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
var api = {
|
||||||
|
MAX_UNIT_LEN: MAX_UNIT_LEN,
|
||||||
|
IDENTITY: IDENTITY,
|
||||||
|
calKey: calKey,
|
||||||
|
baseSignalName: baseSignalName,
|
||||||
|
normaliseCal: normaliseCal,
|
||||||
|
isIdentity: isIdentity,
|
||||||
|
applyCal: applyCal,
|
||||||
|
invertCal: invertCal,
|
||||||
|
calRange: calRange,
|
||||||
|
CalTable: CalTable,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.Calib = api;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
@@ -216,6 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Follows the mouse over a plot: time + per-trace values. -->
|
<!-- Follows the mouse over a plot: time + per-trace values. -->
|
||||||
<div id="hover-readout" style="display:none"></div>
|
<div id="hover-readout" style="display:none"></div>
|
||||||
|
<script src="/calibration.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const C = require('../static/calibration.js');
|
||||||
|
|
||||||
|
test('baseSignalName strips an element suffix', () => {
|
||||||
|
assert.strictEqual(C.baseSignalName('Adc'), 'Adc');
|
||||||
|
assert.strictEqual(C.baseSignalName('Adc[3]'), 'Adc');
|
||||||
|
assert.strictEqual(C.baseSignalName('Adc[12]'), 'Adc');
|
||||||
|
assert.strictEqual(C.baseSignalName('A[1]B'), 'A[1]B');
|
||||||
|
assert.strictEqual(C.baseSignalName(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calKey is stable and separates the two fields', () => {
|
||||||
|
assert.strictEqual(C.calKey('a', 'b'), C.calKey('a', 'b'));
|
||||||
|
assert.notStrictEqual(C.calKey('ab', 'c'), C.calKey('a', 'bc'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normaliseCal accepts a valid entry and fills defaults', () => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
C.normaliseCal({source: ' wave ', signal: ' Adc ', scale: 2, offset: -1, unit: ' V '}),
|
||||||
|
{source: 'wave', signal: 'Adc', scale: 2, offset: -1, unit: 'V'});
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
C.normaliseCal({source: 'wave', signal: 'Adc'}),
|
||||||
|
{source: 'wave', signal: 'Adc', scale: 1, offset: 0, unit: ''});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normaliseCal strips an element suffix from the signal name', () => {
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 'Adc[3]'}).signal, 'Adc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normaliseCal truncates an over-long unit', () => {
|
||||||
|
const long = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: long}).unit,
|
||||||
|
long.slice(0, C.MAX_UNIT_LEN));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normaliseCal rejects invalid entries', () => {
|
||||||
|
assert.strictEqual(C.normaliseCal(null), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({signal: 's'}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w'}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: ' ', signal: 's'}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: 0}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: NaN}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: Infinity}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', offset: NaN}), null);
|
||||||
|
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: '2'}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyCal and invertCal round-trip', () => {
|
||||||
|
const cal = {scale: 0.5, offset: -1.25, unit: 'V'};
|
||||||
|
assert.strictEqual(C.applyCal(10, cal), 3.75);
|
||||||
|
assert.strictEqual(C.invertCal(3.75, cal), 10);
|
||||||
|
assert.strictEqual(C.applyCal(7, C.IDENTITY), 7);
|
||||||
|
assert.strictEqual(C.invertCal(7, C.IDENTITY), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyCal passes non-finite samples through untouched', () => {
|
||||||
|
assert.ok(Number.isNaN(C.applyCal(NaN, {scale: 2, offset: 1, unit: ''})));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calRange re-orders when the scale is negative', () => {
|
||||||
|
assert.deepStrictEqual(C.calRange(0, 10, {scale: 2, offset: 1, unit: ''}), [1, 21]);
|
||||||
|
assert.deepStrictEqual(C.calRange(0, 10, {scale: -2, offset: 1, unit: ''}), [-19, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalTable.get returns IDENTITY for an unknown signal', () => {
|
||||||
|
const t = new C.CalTable();
|
||||||
|
assert.deepStrictEqual(t.get('w', 'Adc'), C.IDENTITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalTable.get resolves an element name to its base signal', () => {
|
||||||
|
const t = new C.CalTable();
|
||||||
|
t.set({source: 'w', signal: 'Adc', scale: 3, offset: 0, unit: ''});
|
||||||
|
assert.strictEqual(t.get('w', 'Adc[7]').scale, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalTable.set stores, overwrites, and deletes identity entries', () => {
|
||||||
|
const t = new C.CalTable();
|
||||||
|
assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 2}), true);
|
||||||
|
assert.strictEqual(t.get('w', 'Adc').scale, 2);
|
||||||
|
t.set({source: 'w', signal: 'Adc', scale: 5});
|
||||||
|
assert.strictEqual(t.get('w', 'Adc').scale, 5);
|
||||||
|
assert.strictEqual(t.list().length, 1);
|
||||||
|
// Resetting to identity removes the entry entirely.
|
||||||
|
assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 1, offset: 0, unit: ''}), true);
|
||||||
|
assert.strictEqual(t.list().length, 0);
|
||||||
|
// An invalid entry is refused and changes nothing.
|
||||||
|
assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 0}), false);
|
||||||
|
assert.strictEqual(t.list().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalTable.replaceAll drops the previous contents', () => {
|
||||||
|
const t = new C.CalTable();
|
||||||
|
t.set({source: 'w', signal: 'Old', scale: 2});
|
||||||
|
t.replaceAll([
|
||||||
|
{source: 'w', signal: 'B', scale: 2},
|
||||||
|
{source: 'w', signal: 'A', scale: 3},
|
||||||
|
{source: 'w', signal: 'Bad', scale: 0},
|
||||||
|
{source: 'w', signal: 'Ident', scale: 1, offset: 0, unit: ''},
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(t.list().map(e => e.signal), ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalTable.list is sorted by source then signal', () => {
|
||||||
|
const t = new C.CalTable();
|
||||||
|
t.set({source: 'z', signal: 'a', scale: 2});
|
||||||
|
t.set({source: 'a', signal: 'z', scale: 2});
|
||||||
|
t.set({source: 'a', signal: 'b', scale: 2});
|
||||||
|
assert.deepStrictEqual(t.list().map(e => e.source + '/' + e.signal),
|
||||||
|
['a/b', 'a/z', 'z/a']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user