Implemented backend hr resolution data, splitted test gam
This commit is contained in:
+141
-13
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user