From 21d084d2ea01d7cb4a22317e4e8f9b142950a072 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 17 Aug 2026 00:29:53 +0200 Subject: [PATCH] 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 --- Client/udpstreamer/static/calibration.js | 119 ++++++++++++++++++++ Client/udpstreamer/static/index.html | 1 + Client/udpstreamer/test/calibration.test.js | 111 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 Client/udpstreamer/static/calibration.js create mode 100644 Client/udpstreamer/test/calibration.test.js diff --git a/Client/udpstreamer/static/calibration.js b/Client/udpstreamer/static/calibration.js new file mode 100644 index 0000000..4776e5f --- /dev/null +++ b/Client/udpstreamer/static/calibration.js @@ -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); diff --git a/Client/udpstreamer/static/index.html b/Client/udpstreamer/static/index.html index 439bc53..9805077 100644 --- a/Client/udpstreamer/static/index.html +++ b/Client/udpstreamer/static/index.html @@ -216,6 +216,7 @@ + \ No newline at end of file diff --git a/Client/udpstreamer/test/calibration.test.js b/Client/udpstreamer/test/calibration.test.js new file mode 100644 index 0000000..0b8e973 --- /dev/null +++ b/Client/udpstreamer/test/calibration.test.js @@ -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']); +});