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:
Martino Ferrari
2026-08-17 00:29:53 +02:00
co-authored by Claude Sonnet 4.6
parent b1c2a34eea
commit 21d084d2ea
3 changed files with 231 additions and 0 deletions
+119
View File
@@ -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);