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}
}
}