Add Auto publishing mode to UDPStreamer; optimise WebUI hub

UDPStreamer:
- Add PublishingMode = "Strict" | "Auto" config parameter
- Add MinRefreshRate (Hz) for Auto mode; uses HRT phase-locked tick
  counting to rate-limit sends without accumulation buffers
- Fix high-frequency integration test configs to use newline-separated
  key-value pairs (MARTe2 StandardParser does not treat ';' as delimiter)
- Add 3 new unit tests (AutoMode_Valid, AutoMode_MissingRefreshRate,
  UnknownPublishingMode); all 33 tests passing

WebUI hub:
- Skip ring-buffer LTTB writes when zoom has not been accessed in 10 s,
  reducing idle CPU usage
- Use unsafe float64→bytes reinterpretation to eliminate per-element
  encoding overhead in the hot broadcast path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-05-26 22:29:40 +02:00
parent b465dd680c
commit f85ab8652c
8 changed files with 549 additions and 127 deletions
+107 -27
View File
@@ -3,7 +3,10 @@
Constants
════════════════════════════════════════════════════════════════ */
const DEFAULT_CAP = 10_000;
const TEMPORAL_CAP = 600_000;
// Temporal signals receive 50 pts/tick × 30 Hz = 1 500 pts/s from the server.
// 50 000 cap → ~33 s of rolling history; well beyond the max supported window.
// Zoom uses /api/zoom (server ring buffer) so the cap only affects rolling display.
const TEMPORAL_CAP = 50_000;
const LTTB_MIN = 200; // never decimate below this many points
const TRACE_COLORS = [
@@ -276,8 +279,8 @@ function onBinaryData(buf) {
}
}
// Trigger check
if (trigVals) checkTrigger(trigVals);
// Trigger check — pass as keyed object matching checkTrigger's expected format
if (trigVals) checkTrigger({ [trig.signal]: trigVals });
if (trig.enabled && trig.collecting && (Date.now() / 1000) >= trig.trigTime + trigPostSec())
finaliseTriggerCapture();
@@ -528,6 +531,69 @@ function makeSeriesPath(key) {
};
}
/* ════════════════════════════════════════════════════════════════
LTTB Web Worker — offloads decimation off the main thread.
Cache key: "<plotId>:<masterKey>:<t0f>:<t1f>:<len>"
On cache-hit → render uses cached {t, v} immediately (stale-while-revalidate).
On cache-miss → render falls back to sync lttb once (first zoom render only),
then worker takes over for subsequent updates.
════════════════════════════════════════════════════════════════ */
const lttbCache = new Map(); // key → {t, v}
const lttbPending = new Set(); // keys currently in-flight
let _lttbWorker = null;
try {
_lttbWorker = new Worker('lttb-worker.js');
_lttbWorker.onmessage = function({ data: { id, t, v } }) {
lttbPending.delete(id);
lttbCache.set(id, { t, v });
// Invalidate and redraw the owning plot.
const plotId = parseInt(id.split(':')[0], 10);
const p = plots.find(q => q.id === plotId);
if (p) { p.needsRedraw = true; }
};
_lttbWorker.onerror = e => console.warn('[lttb-worker] error:', e);
} catch(e) {
console.warn('[lttb-worker] unavailable, using sync fallback:', e);
}
// Submit a LTTB job to the worker (or run sync if worker unavailable).
// Returns cached {t, v} if fresh, null if a worker job was just submitted,
// or a sync result if the worker is unavailable.
function lttbAsync(cacheKey, t, v, threshold) {
const cached = lttbCache.get(cacheKey);
if (cached) return cached; // cache hit — use immediately
if (!lttbPending.has(cacheKey)) {
lttbPending.add(cacheKey);
if (_lttbWorker) {
// Send copies so the main thread retains the originals.
const tCopy = new Float64Array(t);
const vCopy = new Float64Array(v);
_lttbWorker.postMessage({ id: cacheKey, t: tCopy, v: vCopy, threshold },
[tCopy.buffer, vCopy.buffer]);
} else {
// Synchronous fallback (worker unavailable).
const result = lttb(t, v, threshold);
lttbCache.set(cacheKey, result);
lttbPending.delete(cacheKey);
return result;
}
}
return null; // worker job in-flight — caller should use fallback
}
// Evict stale LTTB cache entries for a plot (call when zoom range changes).
function lttbCacheEvict(plotId) {
const prefix = plotId + ':';
for (const k of [...lttbCache.keys()]) {
if (k.startsWith(prefix)) lttbCache.delete(k);
}
for (const k of [...lttbPending]) {
if (k.startsWith(prefix)) lttbPending.delete(k);
}
}
// LTTB decimation — O(n). Returns {t, v} unchanged when len ≤ threshold.
function lttb(t, v, threshold) {
const len = t.length;
@@ -1147,17 +1213,30 @@ function buildLiveData(p) {
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
// In rolling mode, Go backend already LTTB-decimated temporal signals to
// maxPushPoints (2000) and scalar points per tick are naturally limited.
// Skip JS-side LTTB entirely — just use the raw buffer data as-is.
// In zoomed mode, run pixel-adaptive LTTB for display quality.
// Decimate to pixel-adaptive point count in both rolling and zoomed modes.
// The server sends 2000 pts/tick but the buffer accumulates many ticks, so
// the full window slice can easily reach 100k300k pts — far more than uPlot
// needs for a 1200px-wide canvas. Always run LTTB via the background worker
// (stale-while-revalidate: use cached result; fall back to sync on first render).
// Rolling-mode cache key uses _dataGen so the result refreshes on new data.
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const cacheKey = isRolling
? `${p.id}:${masterKey}:rolling:${_dataGen}`
: `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
let sharedT, masterV;
if (isRolling) {
if (masterRaw.t.length <= targetPts) {
// Data already sparse enough — use directly (no decimation needed).
sharedT = masterRaw.t;
masterV = masterRaw.v;
} else {
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
const dec = lttb(masterRaw.t, masterRaw.v, targetPts);
const cached = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
let dec;
if (cached) {
dec = cached;
} else {
// Worker job submitted — sync fallback this frame so the plot isn't blank.
dec = lttb(masterRaw.t, masterRaw.v, targetPts);
}
sharedT = dec.t;
masterV = dec.v;
}
@@ -1203,7 +1282,11 @@ function buildTrigData(p) {
if (!masterRaw || masterRaw.t.length === 0)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
const dec = lttb(masterRaw.t, masterRaw.v, targetPts);
// Trig snapshot is fixed (no new data arrives after capture), so the cache
// key only needs range + data length. Worker result persists until re-arm.
const cacheKey = `${p.id}:${masterKey}:${t0.toFixed(6)}:${t1.toFixed(6)}:${masterRaw.t.length}`;
const cachedDec = lttbAsync(cacheKey, masterRaw.t, masterRaw.v, targetPts);
const dec = cachedDec || lttb(masterRaw.t, masterRaw.v, targetPts);
// Convert absolute → relative seconds
const sharedT = new Float64Array(dec.t.length);
for (let i = 0; i < dec.t.length; i++) sharedT[i] = dec.t[i] - trigT;
@@ -1252,6 +1335,9 @@ function onZoom(sourcePlotId, min, max) {
});
zoomGuard = false;
// Evict stale LTTB cache entries — new zoom range needs fresh decimation.
plots.forEach(p => lttbCacheEvict(p.id));
// Mark all plots dirty (re-slice data to the new range for full resolution)
plots.forEach(p => { p.needsRedraw = true; });
@@ -1903,6 +1989,7 @@ function deletePlot(plotId) {
const p = plots[idx];
if (p.ro) p.ro.disconnect();
if (p.uplot) p.uplot.destroy();
lttbCacheEvict(plotId);
plots.splice(idx, 1);
document.querySelector('[data-plot-id="' + plotId + '"]').remove();
}
@@ -1913,22 +2000,14 @@ function deletePlot(plotId) {
let _dbgTick = 0;
let _dataGen = 0; // incremented each time new data arrives
function renderDirtyPlots() {
// Compute global "now" once — shared by all rolling-window plots this frame.
const globalPlotNow = getGlobalNow();
// Diagnostic: every ~5 s print buffer state to the browser console.
// Open DevTools → Console to see timestamps and sizes.
// Lightweight diagnostic: every ~10 s log buffer count
if (++_dbgTick % 300 === 0) {
const wallNow = Date.now() / 1000;
let anyData = false;
Object.entries(buffers).forEach(([k, b]) => {
if (b.size === 0) return;
anyData = true;
const newest = b.t[(b.head - 1 + b.cap) % b.cap];
const oldest = b.t[(b.size === b.cap ? b.head : 0) % b.cap];
const sliceLen = getBufferSlice(b).t.length;
console.log(`[buf] ${k}: size=${b.size} oldest=${oldest.toFixed(3)} newest=${newest.toFixed(3)} wallNow=${wallNow.toFixed(3)} ts-age=${(wallNow - newest).toFixed(3)}s slice(${windowSec}s)=${sliceLen}pts`);
});
if (!anyData) console.log('[buf] all buffers empty — no data received yet');
plots.forEach(p => console.log(`[plot${p.id}] traces=${JSON.stringify(p.traces)} xRange=${JSON.stringify(p.xRange)} needsRedraw=${p.needsRedraw}`));
let bufCount = 0, totalPts = 0;
Object.values(buffers).forEach(b => { if (b.size > 0) { bufCount++; totalPts += b.size; } });
if (bufCount > 0) console.log(`[buf] ${bufCount} buffers, ${totalPts} total pts`);
}
// Rolling-window mode: detect stale xRange (buffer has scrolled past a frozen
@@ -1982,8 +2061,7 @@ function renderDirtyPlots() {
if (isRolling && _dataGen === p.lastDataGen && p.uplot.data && p.uplot.data[0] && p.uplot.data[0].length > 0) {
p.needsRedraw = false;
zoomGuard = true;
const plotNow = computePlotNow(p);
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
p.uplot.setScale('x', { min: globalPlotNow - windowSec, max: globalPlotNow });
zoomGuard = false;
return;
}
@@ -2040,6 +2118,8 @@ document.getElementById('btn-pause-global').addEventListener('click', () => {
document.getElementById('window-select').addEventListener('change', e => {
windowSec = parseFloat(e.target.value);
// Evict rolling-mode LTTB cache — window size change invalidates all cached results.
plots.forEach(p => lttbCacheEvict(p.id));
// Don't trigger redraws while in trigger mode without a snapshot
if (trig.enabled && !trig.snapshot) return;
plots.forEach(p => { if (!p.xRange) p.needsRedraw = true; });