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:
Martino Ferrari
2026-05-26 22:29:40 +02:00
parent b465dd680c
commit f85ab8652c
8 changed files with 549 additions and 127 deletions
+69 -26
View File
@@ -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