Implemented backend hr resolution data, splitted test gam

This commit is contained in:
Martino Ferrari
2026-05-19 14:14:22 +02:00
parent c122369ca7
commit 620542a722
13 changed files with 610 additions and 35 deletions
+141 -13
View File
@@ -5,6 +5,9 @@ import (
"log"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
@@ -137,7 +140,8 @@ type hubCmd struct {
}
// Hub is the central broker between UDP clients and WebSocket clients.
// All map state is accessed exclusively from the Run() goroutine.
// All map state is accessed exclusively from the Run() goroutine, except
// ringsMu/rings which are also read by HTTP handler goroutines.
type Hub struct {
clients map[*wsClient]bool
register chan *wsClient
@@ -147,6 +151,11 @@ type Hub struct {
commandCh chan hubCmd
sm *SourceManager // set after construction; used for WS-initiated source changes
// Ring buffers for hi-res zoom data.
// ringsMu protects the map structure; each sigRing has its own RWMutex for data.
ringsMu sync.RWMutex
rings map[string]*sigRing // "sourceId:signalKey" → ring
}
// NewHub creates an initialised Hub.
@@ -158,6 +167,75 @@ func NewHub() *Hub {
broadcastCh: make(chan []byte, 64),
dataCh: make(chan taggedSample, 256),
commandCh: make(chan hubCmd, 64),
rings: make(map[string]*sigRing),
}
}
// getRing returns the ring buffer for a fully-prefixed signal key, or nil.
func (h *Hub) getRing(key string) *sigRing {
h.ringsMu.RLock()
rb := h.rings[key]
h.ringsMu.RUnlock()
return rb
}
// HandleZoom serves GET /api/zoom?t0=...&t1=...&n=...&signals=key1,key2
// It reads from the ring buffers (safe for concurrent access) and returns
// LTTB-decimated signal data for the requested time range.
func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
t0, err0 := strconv.ParseFloat(q.Get("t0"), 64)
t1, err1 := strconv.ParseFloat(q.Get("t1"), 64)
if err0 != nil || err1 != nil || t1 <= t0 {
http.Error(w, "invalid t0/t1", http.StatusBadRequest)
return
}
// n=0 (or explicit "0") means no LTTB decimation — return all ring data in range.
// n omitted / invalid → default 2400 (display quality).
var n int
if nStr := q.Get("n"); nStr == "" {
n = 2400
} else {
n, _ = strconv.Atoi(nStr)
if n <= 0 {
n = 1 << 30 // no decimation
} else if n < 10 {
n = 2400
}
}
keys := strings.Split(q.Get("signals"), ",")
// Collect ring references under a brief RLock.
h.ringsMu.RLock()
refs := make(map[string]*sigRing, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if rb, ok := h.rings[k]; ok {
refs[k] = rb
}
}
h.ringsMu.RUnlock()
result := make(map[string]sigData, len(refs))
for k, rb := range refs {
rt, rv := rb.slice(t0, t1)
if len(rt) == 0 {
continue
}
dt, dv := lttbDecimate(rt, rv, n)
result[k] = sigData{T: dt, V: dv}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"type": "zoom",
"signals": result,
}); err != nil {
log.Printf("hub: zoom encode: %v", err)
}
}
@@ -294,6 +372,14 @@ func (h *Hub) Run() {
case "removeSource":
delete(sourcesMap, cmd.sourceID)
delete(pending, cmd.sourceID)
pfxDel := cmd.sourceID + ":"
h.ringsMu.Lock()
for k := range h.rings {
if strings.HasPrefix(k, pfxDel) {
delete(h.rings, k)
}
}
h.ringsMu.Unlock()
rebuildSources()
case "setSourceState":
@@ -309,7 +395,7 @@ func (h *Hub) Run() {
}
src.signals = cmd.sigs
src.configSeq++
cfgMsg, err := json.Marshal(map[string]interface{}{
cfgMsg, err := json.Marshal(map[string]any{
"type": "config",
"sourceId": cmd.sourceID,
"signals": cmd.sigs,
@@ -320,6 +406,28 @@ func (h *Hub) Run() {
}
src.configJS = cfgMsg
h.broadcast(cfgMsg)
// Rebuild ring buffers for this source.
pfxUpd := cmd.sourceID + ":"
h.ringsMu.Lock()
for k := range h.rings {
if strings.HasPrefix(k, pfxUpd) {
delete(h.rings, k)
}
}
for _, sig := range cmd.sigs {
ne := sig.NumElements()
isTemporal := ne > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample)
if isTemporal {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
} else if ne == 1 {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar)
} else {
for i := 0; i < ne; i++ {
h.rings[pfxUpd+arrayKey(sig.Name, i)] = newSigRing(ringCapScalar)
}
}
}
h.ringsMu.Unlock()
case "wsAddSource":
if h.sm != nil {
@@ -364,17 +472,25 @@ func (h *Hub) Run() {
// ─── Data serialisation ───────────────────────────────────────────────────────
// maxScalarPoints caps scalar/spatial-array signals per 30 Hz tick.
// At typical cycle rates (≤10 kHz) a tick accumulates at most ~333 samples,
// so this cap is almost never hit.
const maxScalarPoints = 2000
// maxPushPoints is the LTTB target for data pushed over WebSocket per 30 Hz tick.
// At 2 k pts/tick × 30 Hz = 60 k pts/s; the 600 k frontend buffer covers ~10 s.
// Kept deliberately low so the rolling window shows plenty of history even for
// multi-MHz signals — zoom resolution comes from the ring buffer instead.
const maxPushPoints = 2_000
// maxTemporalPoints caps temporal-array (packed-burst) signals per 30 Hz tick.
// Raised significantly vs scalars because temporal arrays carry high-frequency
// waveforms: at 5 Msps / 5 kHz update rate a tick produces ~167 k samples;
// sending 20 k points limits the wire to ~320 KB/ch/tick while giving a
// minimum visible Δt of ≈ 1.6 µs (vs ≈16 µs with the old 2 k cap).
const maxTemporalPoints = 20000
// maxRingPoints is the LTTB target written into the ring buffer per tick.
// At 5 Msps / 5 kHz packet rate ≈ 167 k raw samples/tick → LTTB to 20 k →
// min Δt ≈ 33 ms / 20 k ≈ 1.65 µs, sufficient for sub-10 µs zoom resolution.
const maxRingPoints = 20_000
// ringCapTemporal is the ring buffer capacity for temporal-array signals.
// At 20 k pts/tick × 30 Hz = 600 k pts/s → 6 M cap gives ~10 s of hi-res
// history — the same temporal coverage as the frontend push buffer.
const ringCapTemporal = 6_000_000
// ringCapScalar is the ring buffer capacity for scalar / spatial-array signals.
// At ≤10 kHz → ~333 pts/tick × 30 Hz ≈ 10 k pts/s → ~10 s of history.
const ringCapScalar = 100_000
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points
// using the Largest-Triangle-Three-Buckets algorithm.
@@ -510,7 +626,13 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
}
}
decimT, decimV := lttbDecimate(allT, allV, maxTemporalPoints)
// Write hi-res LTTB data to ring for on-demand zoom queries.
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(ringT, ringV)
}
// Decimate further for WebSocket push (rolling window).
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
case n == 1:
@@ -524,6 +646,9 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[0])
}
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(ts, vs)
}
out[pfx+sig.Name] = sigData{T: ts, V: vs}
default:
@@ -540,6 +665,9 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[i])
}
if rb := h.getRing(key); rb != nil {
rb.write(ts, vs)
}
out[key] = sigData{T: ts, V: vs}
}
}
+1
View File
@@ -53,6 +53,7 @@ func main() {
}
http.Handle("/", http.FileServer(http.FS(sub)))
http.HandleFunc("/ws", hub.HandleWebSocket)
http.HandleFunc("/api/zoom", hub.HandleZoom)
http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, version)
})
+88
View File
@@ -0,0 +1,88 @@
package main
import "sync"
// sigRing is a fixed-capacity circular buffer storing (time, value) pairs.
// Writes come from the Hub.Run() goroutine; reads come from HTTP handler goroutines.
// The embedded RWMutex protects concurrent access.
type sigRing struct {
mu sync.RWMutex
t, v []float64
cap int
head, size int // next write position; current fill
}
func newSigRing(capacity int) *sigRing {
return &sigRing{
t: make([]float64, capacity),
v: make([]float64, capacity),
cap: capacity,
}
}
// write appends (tArr[i], vArr[i]) pairs, overwriting oldest entries when full.
func (rb *sigRing) write(tArr, vArr []float64) {
rb.mu.Lock()
defer rb.mu.Unlock()
for i := 0; i < len(tArr); i++ {
rb.t[rb.head] = tArr[i]
rb.v[rb.head] = vArr[i]
rb.head = (rb.head + 1) % rb.cap
if rb.size < rb.cap {
rb.size++
}
}
}
// slice returns copies of all (t, v) pairs whose timestamp falls in [t0, t1].
// The returned slices are safe to use after the call without holding any lock.
func (rb *sigRing) slice(t0, t1 float64) ([]float64, []float64) {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.size == 0 {
return nil, nil
}
start := 0
if rb.size == rb.cap {
start = rb.head
}
physAt := func(k int) int { return (start + k) % rb.cap }
// Binary search for t0
lo, hi := 0, rb.size
for lo < hi {
m := (lo + hi) >> 1
if rb.t[physAt(m)] < t0 {
lo = m + 1
} else {
hi = m
}
}
kStart := lo
// Binary search for t1
lo, hi = kStart, rb.size
for lo < hi {
m := (lo + hi) >> 1
if rb.t[physAt(m)] <= t1 {
lo = m + 1
} else {
hi = m
}
}
kEnd := lo
n := kEnd - kStart
if n <= 0 {
return nil, nil
}
outT := make([]float64, n)
outV := make([]float64, n)
for i := 0; i < n; i++ {
p := physAt(kStart + i)
outT[i] = rb.t[p]
outV[i] = rb.v[p]
}
return outT, outV
}
+228 -11
View File
@@ -64,6 +64,11 @@ let zoomGuard = false;
// Zoom history for Back button (global since plots are zoom-synced)
const zoomHistory = [];
// zoomData: hi-res data fetched from /api/zoom, keyed by plot id.
// Each entry: { signals: { key: {t:Float64Array, v:Float64Array} }, t0, t1 }
const zoomData = {};
let _zoomFetchTimer = null;
// Cursors A/B — stored in x-axis units of the current mode:
// live mode → Unix seconds
// trig mode → relative seconds from trigger
@@ -248,6 +253,46 @@ function finaliseTriggerCapture() {
plots.forEach(p => { p.xRange = null; p.needsRedraw = true; });
if (trig.mode === 'normal' && !trig.stopped)
setTimeout(() => { if (trig.enabled && trig.mode === 'normal' && !trig.stopped) trigArm(); }, 200);
// Upgrade the snapshot in the background with full-resolution ring data.
// The push buffer has min Δt ≈ 16 µs; the ring provides ≈ 1.65 µs.
upgradeTriggerSnapshot(t0, t1, trig.trigTime);
}
// Fetches full-resolution ring data for the trigger window and replaces the
// push-buffer entries in trig.snapshot, then re-renders all plots.
async function upgradeTriggerSnapshot(t0, t1, capturedTrigTime) {
// Small delay so the ring receives the last post-trigger samples
// (ring write period ≈ 33 ms; 120 ms gives ≥ 3 ticks of margin).
await new Promise(r => setTimeout(r, 120));
// Abort if a new trigger has fired since we started.
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
const keys = Object.keys(buffers);
if (!keys.length) return;
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
let ringSignals;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const json = await resp.json();
ringSignals = json && json.signals;
} catch (e) {
console.warn('trigger snapshot ring upgrade failed:', e);
return;
}
// Abort if the snapshot was replaced while we were fetching.
if (!trig.snapshot || trig.trigTime !== capturedTrigTime) return;
let updated = false;
Object.entries(ringSignals || {}).forEach(([key, sd]) => {
if (!sd || !sd.t || !sd.t.length) return;
trig.snapshot[key] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
updated = true;
});
if (updated) plots.forEach(p => { p.needsRedraw = true; });
}
function trigArm() {
// Do NOT clear trig.snapshot here — keep the last waveform and trigger marker
@@ -431,6 +476,81 @@ function lttb(t, v, threshold) {
return { t: outT, v: outV };
}
/* ════════════════════════════════════════════════════════════════
Hybrid zoom: hi-res data fetched from /api/zoom on demand
════════════════════════════════════════════════════════════════ */
function cancelZoomFetch() {
if (_zoomFetchTimer !== null) { clearTimeout(_zoomFetchTimer); _zoomFetchTimer = null; }
}
function scheduleZoomFetch(t0, t1) {
cancelZoomFetch();
_zoomFetchTimer = setTimeout(() => { _zoomFetchTimer = null; doZoomFetch(t0, t1); }, 150);
}
async function doZoomFetch(t0, t1) {
// Collect all signal keys across all plots; each key encodes sourceId as prefix.
const allKeys = new Set();
plots.forEach(p => p.traces.forEach(k => allKeys.add(k)));
if (allKeys.size === 0) return;
const targetPts = Math.max(400, (plots.find(p => p.uplot)?.uplot?.width || 600) * 2);
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=${targetPts}&signals=${[...allKeys].join(',')}`;
let fetched;
try {
const resp = await fetch(url);
if (!resp.ok) return;
fetched = await resp.json();
} catch (e) { console.warn('zoom fetch:', e); return; }
const sigs = fetched && fetched.signals;
if (!sigs) return;
// Convert arrays from JSON to Float64Array and store per-plot.
const merged = {};
Object.entries(sigs).forEach(([k, sd]) => {
if (!sd || !sd.t || !sd.v) return;
merged[k] = { t: Float64Array.from(sd.t), v: Float64Array.from(sd.v) };
});
plots.forEach(p => {
// Only store if this plot's range still matches what we fetched.
if (!p.xRange || Math.abs(p.xRange[0] - t0) > 1e-9 || Math.abs(p.xRange[1] - t1) > 1e-9) return;
const plotSigs = {};
p.traces.forEach(k => { if (merged[k]) plotSigs[k] = merged[k]; });
if (Object.keys(plotSigs).length > 0) {
zoomData[p.id] = { signals: plotSigs, t0, t1 };
p.needsRedraw = true;
}
});
}
// Build uPlot data arrays from server-fetched hi-res signal data.
function buildDataFromFetched(p, fetchedSignals, targetPts) {
let masterKey = p.traces[0], masterCount = -1, masterRate = -1;
for (const key of p.traces) {
const sd = fetchedSignals[key];
if (!sd || !sd.t || !sd.t.length) continue;
const rate = getKeySamplingRate(key);
if (rate > masterRate || (rate === masterRate && sd.t.length > masterCount)) {
masterRate = rate; masterCount = sd.t.length; masterKey = key;
}
}
const masterSd = fetchedSignals[masterKey];
if (!masterSd || !masterSd.t.length)
return [new Float64Array(0), ...p.traces.map(() => new Float64Array(0))];
const dec = lttb(masterSd.t, masterSd.v, targetPts);
const sharedT = dec.t;
const yArrays = [];
for (const key of p.traces) {
if (key === masterKey) { yArrays.push(dec.v); continue; }
const sd = fetchedSignals[key];
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
}
return [sharedT, ...yArrays];
}
/* ════════════════════════════════════════════════════════════════
uPlot helpers
════════════════════════════════════════════════════════════════ */
@@ -903,6 +1023,14 @@ function buildLiveData(p) {
// raises the effective sample cap and reveals full resolution.
const targetPts = Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2);
// When zoomed, prefer server-fetched hi-res data if it covers this exact range.
if (p.xRange) {
const zd = zoomData[p.id];
if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) {
return buildDataFromFetched(p, zd.signals, targetPts);
}
}
// Slice all traces once; pick the master time grid using configured samplingRate
// as the primary criterion (unambiguous, independent of buffer fill / trace order).
// Fall back to raw sample count for signals without a configured rate.
@@ -1019,6 +1147,9 @@ function onZoom(sourcePlotId, min, max) {
// Mark all plots dirty (re-slice data to the new range for full resolution)
plots.forEach(p => { p.needsRedraw = true; });
// Schedule hi-res fetch from ring buffers.
scheduleZoomFetch(min, max);
}
// Undo last zoom/pan action
@@ -1027,6 +1158,10 @@ function zoomBack() {
const prev = zoomHistory.pop();
if (!zoomHistory.length) document.getElementById('btn-zoom-back').style.display = 'none';
// Discard stale zoom data regardless of direction.
Object.keys(zoomData).forEach(k => delete zoomData[k]);
cancelZoomFetch();
if (prev === null) {
// Was at auto/rolling state before the zoom
resetZoom();
@@ -1038,11 +1173,14 @@ function zoomBack() {
p.needsRedraw = true;
});
zoomGuard = false;
scheduleZoomFetch(prev[0], prev[1]);
}
}
// Reset to auto/rolling window (clears all zoom)
function resetZoom() {
Object.keys(zoomData).forEach(k => delete zoomData[k]);
cancelZoomFetch();
syncLocked = false;
document.getElementById('btn-sync-resume').style.display = 'none';
if (trig.enabled && trig.snapshot) {
@@ -1097,6 +1235,7 @@ function zoomFit() {
p.needsRedraw = true;
});
zoomGuard = false;
scheduleZoomFetch(gMin, gMax);
}
// Auto = return to rolling window / full trigger window
@@ -1464,34 +1603,112 @@ function buildLayoutMenu() {
}
/* ════════════════════════════════════════════════════════════════
Export CSV (all plots)
Export CSV (all plots) — fetches full-resolution data from ring
════════════════════════════════════════════════════════════════ */
function exportAllCSV() {
async function exportAllCSV() {
const btn = document.getElementById('btn-csv-all');
if (btn.disabled) return;
const inTrigMode = trig.enabled && trig.snapshot !== null;
// Collect unique signal keys across all plots (preserving order)
// Collect unique signal keys across all plots (preserving order).
const keys = [];
plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); }));
if (!keys.length) return;
// Determine time range to export.
let t0, t1, relOffset = 0;
if (inTrigMode) {
// Export the full trigger window around the trigger event.
t0 = trig.trigTime - trigPreSec();
t1 = trig.trigTime + trigPostSec();
relOffset = trig.trigTime;
} else {
// Use the current zoom range if active, else the rolling window.
const refPlot = plots.find(p => p.xRange);
if (refPlot) {
[t0, t1] = refPlot.xRange;
} else {
let plotNow = -Infinity;
keys.forEach(k => {
const buf = buffers[k];
if (buf && buf.size > 0) {
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
if (t > plotNow) plotNow = t;
}
});
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
t0 = plotNow - windowSec;
t1 = plotNow;
}
}
// Show loading state.
const origLabel = btn.textContent;
btn.textContent = '⏳ Downloading…';
btn.disabled = true;
// Fetch full-resolution ring data (n=0 → no LTTB decimation).
const url = `/api/zoom?t0=${t0}&t1=${t1}&n=0&signals=${keys.join(',')}`;
let ringSignals = null;
try {
const resp = await fetch(url);
if (resp.ok) {
const json = await resp.json();
ringSignals = json && json.signals;
}
} catch (e) {
console.warn('CSV export: ring fetch failed, falling back to push buffer', e);
} finally {
btn.textContent = origLabel;
btn.disabled = false;
}
// Build per-signal time/value arrays.
// Priority: ring buffer (full res) → trigger snapshot → push buffer.
const slices = keys.map(key => {
const rd = ringSignals && ringSignals[key];
if (rd && rd.t && rd.t.length > 0) {
const t = rd.t, v = rd.v;
if (inTrigMode) {
return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) };
}
return { t: Array.from(t), v: Array.from(v) };
}
// Fallback: push buffer or trigger snapshot.
if (inTrigMode) {
const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) };
return { t: Array.from(raw.t).map(ts => ts - trig.trigTime), v: Array.from(raw.v) };
return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) };
}
const buf = buffers[key]; if (!buf) return { t: [], v: [] };
const sl = getBufferSlice(buf);
const sl = getBufferSliceRange(buf, t0, t1);
return { t: Array.from(sl.t), v: Array.from(sl.v) };
});
const allT = new Set(); slices.forEach(s => s.t.forEach(t => allT.add(t)));
// Merge all timestamps and build aligned rows.
const allT = new Set();
slices.forEach(s => s.t.forEach(t => allT.add(t)));
const sortedT = Array.from(allT).sort((a, b) => a - b);
const lookups = slices.map(s => { const m = new Map(); s.t.forEach((t, i) => m.set(t, s.v[i])); return m; });
const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...keys].join(',');
const rows = sortedT.map(t => [t.toFixed(9), ...lookups.map(lk => lk.has(t) ? lk.get(t) : '')].join(','));
if (!sortedT.length) return;
const lookups = slices.map(s => {
const m = new Map();
s.t.forEach((t, i) => m.set(t, s.v[i]));
return m;
});
// Strip "sourceId:" prefix from column headers for readability.
const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k));
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(',')
);
const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'signals_' + Date.now() + '.csv';
a.click(); URL.revokeObjectURL(a.href);
a.href = URL.createObjectURL(blob);
a.download = 'signals_' + Date.now() + '.csv';
a.click();
URL.revokeObjectURL(a.href);
}
/* ════════════════════════════════════════════════════════════════