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:
+69
-26
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -163,6 +164,12 @@ type Hub struct {
|
||||
ringsMu sync.RWMutex
|
||||
rings map[string]*sigRing // "sourceId:signalKey" → ring
|
||||
|
||||
// lastZoomAt tracks the last time a zoom request was served.
|
||||
// Ring buffer writes are skipped when no zoom has been requested
|
||||
// in the last 10 s, saving substantial CPU on LTTB + ring writes.
|
||||
lastZoomAt time.Time
|
||||
zoomAtMu sync.Mutex
|
||||
|
||||
statsMu sync.RWMutex
|
||||
statsMap map[string]*SourceStat
|
||||
}
|
||||
@@ -189,9 +196,17 @@ func (h *Hub) getRing(key string) *sigRing {
|
||||
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.
|
||||
// shouldWriteRing returns true if zoom was requested within the last 10 seconds.
|
||||
// When false, the hot path can skip LTTB decimation for the ring buffer entirely.
|
||||
func (h *Hub) shouldWriteRing() bool {
|
||||
h.zoomAtMu.Lock()
|
||||
ok := time.Since(h.lastZoomAt) < 10*time.Second
|
||||
h.zoomAtMu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// HandleZoom serves GET /api/zoom?... It also records the access time
|
||||
// so the ring buffer knows zoom is active and worth populating.
|
||||
func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
t0, err0 := strconv.ParseFloat(q.Get("t0"), 64)
|
||||
@@ -214,6 +229,15 @@ func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Record zoom access time so ring writes stay active.
|
||||
// Skip n=0 requests (full-data exports / trigger snapshots) — only
|
||||
// interactive zooms should keep the ring buffer populated.
|
||||
if n > 0 {
|
||||
h.zoomAtMu.Lock()
|
||||
h.lastZoomAt = time.Now()
|
||||
h.zoomAtMu.Unlock()
|
||||
}
|
||||
|
||||
keys := strings.Split(q.Get("signals"), ",")
|
||||
|
||||
// Collect ring references under a brief RLock.
|
||||
@@ -506,13 +530,31 @@ func (h *Hub) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// float64ToBytes reinterprets a []float64 as []byte without copying.
|
||||
// The caller must ensure the slice is not modified while the byte view is in use.
|
||||
func float64ToBytes(f []float64) []byte {
|
||||
if len(f) == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(&f[0])), len(f)*8)
|
||||
}
|
||||
|
||||
// writeFloat64s encodes a []float64 as little-endian bytes into buf at offset
|
||||
// and returns the new offset. Uses unsafe to avoid per-element PutUint64 calls.
|
||||
func writeFloat64s(buf []byte, off int, f []float64) int {
|
||||
copy(buf[off:], float64ToBytes(f))
|
||||
return off + len(f)*8
|
||||
}
|
||||
|
||||
// ─── Data serialisation ───────────────────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
// With cascaded LTTB the server selects the most visually significant pts from
|
||||
// each batch; the browser accumulates ticks × pts/tick for the rolling window.
|
||||
// For a 1 200 px plot showing a 5 s window at 30 Hz: 2×1200/(5×30) ≈ 16 pts/tick
|
||||
// needed. 50 gives 4× headroom: 50×30×5 = 7 500 pts → trivial 120 KB buffer.
|
||||
// Zoom resolution is unaffected — the ring buffer (maxRingPoints) serves /api/zoom.
|
||||
const maxPushPoints = 50
|
||||
|
||||
// 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 →
|
||||
@@ -800,6 +842,7 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
||||
|
||||
sigs := src.signals
|
||||
pfx := src.id + ":"
|
||||
writeRing := h.shouldWriteRing()
|
||||
|
||||
// ---- Phase 1: collect (t,v) for each signal (same logic as JSON path) ----
|
||||
|
||||
@@ -864,10 +907,12 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
||||
allV = append(allV, vals[k])
|
||||
}
|
||||
}
|
||||
// Write hi-res LTTB data to ring.
|
||||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||
rb.write(ringT, ringV)
|
||||
// Write hi-res LTTB data to ring (only if zoom is active).
|
||||
if writeRing {
|
||||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||
rb.write(ringT, ringV)
|
||||
}
|
||||
}
|
||||
// Decimate for push.
|
||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||
@@ -930,8 +975,10 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
||||
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)
|
||||
if writeRing {
|
||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||
rb.write(ts, vs)
|
||||
}
|
||||
}
|
||||
pairs[sig.Name] = pairBuf{t: ts, v: vs}
|
||||
|
||||
@@ -948,19 +995,21 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
||||
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
||||
vs = append(vs, vals[i])
|
||||
}
|
||||
if rb := h.getRing(pfx + key); rb != nil {
|
||||
rb.write(ts, vs)
|
||||
if writeRing {
|
||||
if rb := h.getRing(pfx + key); rb != nil {
|
||||
rb.write(ts, vs)
|
||||
}
|
||||
}
|
||||
pairs[key] = pairBuf{t: ts, v: vs}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phase 2: compute total size for pre-allocation ----
|
||||
// ---- Phase 2: compute total size and serialize ----
|
||||
totalSize := 1 + 1 + len(src.id) + 4 // version + srcIdLen + srcId + numSigs
|
||||
for key, p := range pairs {
|
||||
totalSize += 2 + len(key) + 4 // keyLen + key + pairCount
|
||||
totalSize += len(p.t) * 16 // t + v, each float64 = 8 bytes
|
||||
totalSize += 2 + len(key) + 4 // keyLen + key + pairCount
|
||||
totalSize += len(p.t)*8 + len(p.v)*8 // t + v float64 data
|
||||
}
|
||||
|
||||
buf := make([]byte, totalSize)
|
||||
@@ -978,14 +1027,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
||||
off += len(key)
|
||||
binary.LittleEndian.PutUint32(buf[off:], uint32(len(p.t)))
|
||||
off += 4
|
||||
for i := 0; i < len(p.t); i++ {
|
||||
binary.LittleEndian.PutUint64(buf[off:], math.Float64bits(p.t[i]))
|
||||
off += 8
|
||||
}
|
||||
for i := 0; i < len(p.v); i++ {
|
||||
binary.LittleEndian.PutUint64(buf[off:], math.Float64bits(p.v[i]))
|
||||
off += 8
|
||||
}
|
||||
off = writeFloat64s(buf, off, p.t)
|
||||
off = writeFloat64s(buf, off, p.v)
|
||||
}
|
||||
|
||||
return buf
|
||||
|
||||
Reference in New Issue
Block a user