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:
+107
-27
@@ -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 100k–300k 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; });
|
||||
|
||||
Reference in New Issue
Block a user