From 3cb998c5da4762d615676d72c9e44a010b1afc9e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 17 Aug 2026 00:55:07 +0200 Subject: [PATCH] webui: calibrate CSV export, trigger threshold and unit display Co-Authored-By: Claude Sonnet 4.6 --- Client/udpstreamer/static/app.js | 45 ++++++++++++++++----- Client/udpstreamer/static/calibration.js | 4 +- Client/udpstreamer/test/calibration.test.js | 4 ++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js index 42c2783..159215e 100644 --- a/Client/udpstreamer/static/app.js +++ b/Client/udpstreamer/static/app.js @@ -706,14 +706,25 @@ function onBinaryData(buf) { function wsSend(obj) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } +// trig.threshold is held in calibrated units. The hub's comparator runs on raw +// samples, so invert on the way out: raw = (calibrated - offset) / scale. function sendTrigConfig() { + const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY; wsSend({ type: 'setTrigger', signal: trig.signal, edge: trig.edge, - threshold: trig.threshold, windowSec: trig.windowSec, + threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec, prePercent: trig.prePercent, mode: trig.mode, }); } +// Rewrite the threshold input and its unit hint from trig.threshold. +function refreshTrigThresholdField() { + const el = document.getElementById('trig-threshold'); + if (document.activeElement !== el) el.value = trig.threshold; + const u = trig.signal ? unitForKey(trig.signal) : ''; + el.title = u ? 'Threshold in ' + u : 'Threshold in the signal\u2019s raw units'; +} + // Hub FSM broadcast: {state:"idle|armed|collecting|triggered", mode, stopped[, trigTime]} function onTriggerState(msg) { const st = msg.state || 'idle'; @@ -2423,7 +2434,11 @@ function showHoverReadout(p, e) { p.traces.forEach((key, idx) => { const vNorm = interpAtTime(p.uplot, idx + 1, t); const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key; - const val = vNorm === null ? '—' : _fmtVal(rawFromNorm(p, key, vNorm)); + // rawFromNorm inverts the vscale transform, which Task 7 made operate on + // calibrated values — so this is already in calibrated units. + const unit = unitForKey(key); + const val = vNorm === null ? '—' + : (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : '')); html += '
' + '' + escHtml(name) + '' + @@ -2612,6 +2627,7 @@ function buildTrigSignalSelect() { // Restore selection: match base key so array element "sig[3]" selects "sig" option. if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase; // Do NOT overwrite trig.signal here — an array element selection must be preserved. + refreshTrigThresholdField(); } /* ════════════════════════════════════════════════════════════════ @@ -2660,19 +2676,20 @@ function buildSidebar() { const n = numElements(sig), temporal = isTemporal(sig); const typeName = _typeNames[sig.typeCode] || '?'; const globalKey = prefix + sig.name; + const effUnit = unitForKey(globalKey); if (n === 1 || temporal) { - grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || '')); + grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, effUnit)); } else { const group = document.createElement('div'); group.className = 'array-group'; const header = document.createElement('div'); header.className = 'array-header'; header.innerHTML = '' + escHtml(sig.name) + '' - + (sig.unit ? '' + escHtml(sig.unit) + '' : '') + + (effUnit ? '' + escHtml(effUnit) + '' : '') + '[' + n + '] ' + typeName + ''; header.addEventListener('click', () => header.classList.toggle('open')); const children = document.createElement('div'); children.className = 'array-children'; for (let i = 0; i < n; i++) { const key = globalKey + '[' + i + ']'; - const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || ''); + const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, effUnit); child.className = 'array-child'; children.appendChild(child); } group.appendChild(header); group.appendChild(children); grp.appendChild(group); @@ -3028,11 +3045,19 @@ async function exportAllCSV() { return m; }); - // Strip "sourceId:" prefix from column headers for readability. - const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k)); + // Strip "sourceId:" prefix from column headers for readability, and append + // the effective unit. These values come straight from the ring/history/ + // snapshot and never pass through applyVScaleNorm, so calibrate them here. + const cals = keys.map(k => calForKey(k)); + const displayKeys = keys.map(k => { + const name = k.includes(':') ? k.split(':').slice(1).join(':') : k; + const u = unitForKey(k); + return u ? name + ' [' + u + ']' : name; + }); const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(','); const rows = sortedT.map(t => - [t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',') + [t.toFixed(9), ...lookups.map((lk, i) => + lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',') ); const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); @@ -3441,7 +3466,9 @@ function applyCalibrationChanged() { buildSidebar(); // unit badges plots.forEach(p => { p.needsRedraw = true; }); if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8 - if (typeof sendTrigConfig === 'function') sendTrigConfig(); // Task 9 + // The threshold is held in calibrated units, so a calibration change alters + // the raw value the hub must compare against — resend it. + if (trig.signal) { refreshTrigThresholdField(); sendTrigConfig(); } } // Replaced in Task 10 with the Sources & Config status renderer. diff --git a/Client/udpstreamer/static/calibration.js b/Client/udpstreamer/static/calibration.js index 1c3c557..8756153 100644 --- a/Client/udpstreamer/static/calibration.js +++ b/Client/udpstreamer/static/calibration.js @@ -40,7 +40,9 @@ 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(); + // Commas and quotes would corrupt the CSV export header; drop them here so + // every consumer sees an already-safe unit. + var unit = String(obj.unit == null ? '' : obj.unit).replace(/[",]/g, '').trim(); // Cap at MAX_UNIT_LEN UTF-8 bytes, matching both hubs' Normalise() exactly. // TextEncoder/TextDecoder are available natively in all modern browsers and // Node v11+; no build step or bundler is needed. diff --git a/Client/udpstreamer/test/calibration.test.js b/Client/udpstreamer/test/calibration.test.js index 9a108d7..c1bed6d 100644 --- a/Client/udpstreamer/test/calibration.test.js +++ b/Client/udpstreamer/test/calibration.test.js @@ -150,3 +150,7 @@ test('CalTable.list is sorted by source then signal', () => { assert.deepStrictEqual(t.list().map(e => e.source + '/' + e.signal), ['a/b', 'a/z', 'z/a']); }); + +test('normaliseCal strips characters that would corrupt a CSV header', () => { + assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'k,V"'}).unit, 'kV'); +});