Implemented and fixed many issues
This commit is contained in:
+233
-74
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -27,6 +28,29 @@ type wsClient struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan wsMessage
|
||||
|
||||
// window is the timespan this client is displaying, in seconds, held as
|
||||
// float64 bits. The retune sweep sizes the rings from the widest window in
|
||||
// use, so it must be readable from the hub goroutine while readPump writes
|
||||
// it. Zero means the client has not said, and the default applies.
|
||||
window atomic.Uint64
|
||||
}
|
||||
|
||||
func (c *wsClient) setDisplayWindowSec(s float64) {
|
||||
c.window.Store(math.Float64bits(s))
|
||||
}
|
||||
|
||||
func (c *wsClient) displayWindowSec() float64 {
|
||||
return math.Float64frombits(c.window.Load())
|
||||
}
|
||||
|
||||
// sendText enqueues one JSON frame for this client, dropping it if the client
|
||||
// is not draining its queue.
|
||||
func (c *wsClient) sendText(msg []byte) {
|
||||
select {
|
||||
case c.send <- wsMessage{websocket.TextMessage, msg}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsClient) writePump() {
|
||||
@@ -128,6 +152,13 @@ func (c *wsClient) readPump() {
|
||||
case c.hub.commandCh <- hubCmd{op: "wsReloadConfig"}:
|
||||
default:
|
||||
}
|
||||
case "setWindow":
|
||||
// Sizes the zoom rings: the hub cannot know how far back a
|
||||
// client is plotting, and a window it has not been told
|
||||
// about is a window the buffers may not reach.
|
||||
if sec, ok := env["seconds"].(float64); ok && sec > 0 && !math.IsInf(sec, 0) {
|
||||
c.setDisplayWindowSec(sec)
|
||||
}
|
||||
case "setMonotonic":
|
||||
enabled, _ := env["enabled"].(bool)
|
||||
select {
|
||||
@@ -140,6 +171,9 @@ func (c *wsClient) readPump() {
|
||||
if c.hub.handleTriggerCommand(t, env) {
|
||||
break
|
||||
}
|
||||
if c.hub.handleHistoryCommand(c, t, env) {
|
||||
break
|
||||
}
|
||||
// Unrecognized message type — forward to DebugCh
|
||||
select {
|
||||
case c.hub.DebugCh <- msg:
|
||||
@@ -271,11 +305,27 @@ type Hub struct {
|
||||
ringsMu sync.RWMutex
|
||||
rings map[string]*sigRing // "sourceId:signalKey" → ring
|
||||
|
||||
// hist is the disk-backed archive behind long time windows, which hold far
|
||||
// more samples than the in-memory rings can. nil when history is disabled.
|
||||
// histOpenAt throttles the sweep that opens the files of signals whose
|
||||
// producer declared no sampling rate; both are touched only from Run().
|
||||
hist *historyWriter
|
||||
histOpenAt float64
|
||||
|
||||
statsMu sync.RWMutex
|
||||
statsMap map[string]*SourceStat
|
||||
|
||||
// trigger is the hub-side trigger FSM driving the oscilloscope capture mode.
|
||||
trigger *triggerEngine
|
||||
// ringTuneAt throttles the sweep that keeps each ring's depth and min/max
|
||||
// bucket matched to the window being displayed; both are touched only from
|
||||
// Run(). ringBudgetPts is that sweep's per-signal budget; set before Run().
|
||||
trigger *triggerEngine
|
||||
ringTuneAt float64
|
||||
// capture is the trigger double buffer's read half: the last delivered
|
||||
// capture window, kept out of the rings' way so the shot being viewed
|
||||
// survives the re-arm that immediately follows it.
|
||||
capture captureHold
|
||||
ringBudgetPts int
|
||||
onClientConnectMu sync.RWMutex
|
||||
onClientConnect func(send func([]byte))
|
||||
|
||||
@@ -301,6 +351,44 @@ func NewHub() *Hub {
|
||||
}
|
||||
}
|
||||
|
||||
// SetRingBudget overrides the per-signal in-memory buffer budget, in points.
|
||||
// Non-positive values restore the default. It must be called before Run().
|
||||
// Each point costs 16 bytes, so the budget is the memory bound per temporal
|
||||
// signal. It does not limit how long a window can be held: a window too long
|
||||
// to fit at full rate is stored as min/max pairs instead (see retuneRings).
|
||||
func (h *Hub) SetRingBudget(n int) {
|
||||
if n <= 0 {
|
||||
n = defaultRingPts
|
||||
}
|
||||
if n < ringCapInitial {
|
||||
n = ringCapInitial
|
||||
}
|
||||
h.ringBudgetPts = n
|
||||
}
|
||||
|
||||
func (h *Hub) ringBudget() int {
|
||||
if h.ringBudgetPts <= 0 {
|
||||
return defaultRingPts
|
||||
}
|
||||
return h.ringBudgetPts
|
||||
}
|
||||
|
||||
// EnableHistory turns on the disk-backed history archive. It must be called
|
||||
// before Run(). A HistoryConfig with an empty Directory leaves history off.
|
||||
func (h *Hub) EnableHistory(cfg HistoryConfig) error {
|
||||
hw, err := newHistoryWriter(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.hist = hw
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseHistory flushes and closes the history files. Without it the samples
|
||||
// written since the last periodic flush are on disk but unaccounted for in the
|
||||
// file headers, so a restart would not see them.
|
||||
func (h *Hub) CloseHistory() { h.hist.close() }
|
||||
|
||||
// SetOnClientConnect registers a callback invoked synchronously (from Run())
|
||||
// each time a new WebSocket client connects. The callback receives a send
|
||||
// function that enqueues one message to that specific client.
|
||||
@@ -315,6 +403,22 @@ func (h *Hub) SetSourceManager(sm *SourceManager) {
|
||||
h.sm = sm
|
||||
}
|
||||
|
||||
// ingest routes one batch of full-resolution samples for a signal to every
|
||||
// consumer that needs them at full rate: the in-memory zoom ring, the disk
|
||||
// history and the trigger comparator. The live push is decimated separately by
|
||||
// the caller. The ring and the archive may reduce what they store to fit their
|
||||
// budget, but they are handed every sample so the reduction sees the extrema.
|
||||
func (h *Hub) ingest(key string, nElem int, t, v []float64) {
|
||||
if len(t) == 0 {
|
||||
return
|
||||
}
|
||||
if rb := h.getRing(key); rb != nil {
|
||||
rb.write(t, v)
|
||||
}
|
||||
h.hist.write(key, t, v)
|
||||
h.trigger.feed(key, nElem, t, v)
|
||||
}
|
||||
|
||||
// getRing returns the ring buffer for a fully-prefixed signal key, or nil.
|
||||
func (h *Hub) getRing(key string) *sigRing {
|
||||
h.ringsMu.RLock()
|
||||
@@ -323,8 +427,10 @@ func (h *Hub) getRing(key string) *sigRing {
|
||||
return rb
|
||||
}
|
||||
|
||||
// zoomSlice extracts [t0, t1] from the full-resolution rings for the named
|
||||
// signals, decimating each to at most n points.
|
||||
// zoomSlice extracts [t0, t1] for the named signals, decimating each to at most
|
||||
// n points. A range inside the last trigger capture is served from the held
|
||||
// copy of it, which the re-arming acquisition cannot overwrite; everything else
|
||||
// comes from the live rings.
|
||||
func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData {
|
||||
h.ringsMu.RLock()
|
||||
refs := make(map[string]*sigRing, len(keys))
|
||||
@@ -341,11 +447,14 @@ func (h *Hub) zoomSlice(t0, t1 float64, keys []string, n int) map[string]sigData
|
||||
|
||||
result := make(map[string]sigData, len(refs))
|
||||
for k, rb := range refs {
|
||||
rt, rv := rb.slice(t0, t1)
|
||||
rt, rv, ok := h.capture.slice(k, t0, t1)
|
||||
if !ok {
|
||||
rt, rv = rb.slice(t0, t1)
|
||||
}
|
||||
if len(rt) == 0 {
|
||||
continue
|
||||
}
|
||||
dt, dv := lttbDecimate(rt, rv, n)
|
||||
dt, dv := minMaxDecimate(rt, rv, n)
|
||||
result[k] = sigData{T: dt, V: dv}
|
||||
}
|
||||
return result
|
||||
@@ -388,10 +497,7 @@ func (h *Hub) handleWSZoom(c *wsClient, env map[string]interface{}) {
|
||||
log.Printf("hub: ws zoom encode: %v", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- wsMessage{websocket.TextMessage, reply}:
|
||||
default:
|
||||
}
|
||||
c.sendText(reply)
|
||||
}
|
||||
|
||||
// HandleZoom serves GET /api/zoom?...
|
||||
@@ -526,6 +632,16 @@ func (h *Hub) Run() {
|
||||
statsTicker := time.NewTicker(time.Second)
|
||||
defer statsTicker.Stop()
|
||||
|
||||
// Header flushes are what make the archived samples findable again; the
|
||||
// data region is written as it arrives. Ticks are ignored when history is
|
||||
// off, so a disabled writer costs one no-op call per period.
|
||||
flushPeriod := time.Duration(5) * time.Second
|
||||
if h.hist.enabled() {
|
||||
flushPeriod = time.Duration(h.hist.cfg.FlushIntervalSec) * time.Second
|
||||
}
|
||||
flushTicker := time.NewTicker(flushPeriod)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
sourcesMap := make(map[string]*sourceHubState)
|
||||
var sourcesMsg []byte
|
||||
|
||||
@@ -570,6 +686,11 @@ func (h *Hub) Run() {
|
||||
case c.send <- wsMessage{websocket.TextMessage, calMsg}:
|
||||
default:
|
||||
}
|
||||
if h.hist.enabled() {
|
||||
if msg := h.buildHistoryInfoMsg(); msg != nil {
|
||||
c.sendText(msg)
|
||||
}
|
||||
}
|
||||
// Notify the application layer so it can replay any persistent state
|
||||
// (e.g., MARTe2 connection status, forced/traced signals).
|
||||
h.onClientConnectMu.RLock()
|
||||
@@ -670,16 +791,29 @@ func (h *Hub) Run() {
|
||||
ne := sig.NumElements()
|
||||
isTemporal := ne > 1 && sig.TimeMode != udpsprotocol.TimeModePacket
|
||||
if isTemporal {
|
||||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
|
||||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
|
||||
} else if ne == 1 {
|
||||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar)
|
||||
} else {
|
||||
// n>1, TimeModePacket snapshot-waveform: each packet contributes n
|
||||
// elements, so use the temporal capacity to hold enough history.
|
||||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
|
||||
// elements, so this is a fast stream too and gets the same budget.
|
||||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapInitial)
|
||||
}
|
||||
}
|
||||
h.ringsMu.Unlock()
|
||||
// The held capture describes rings that no longer exist. A
|
||||
// restarted producer can even replay the same timestamps, so
|
||||
// keeping it would answer zooms with the old run's samples.
|
||||
h.capture.clear()
|
||||
// Opening the archive files touches the filesystem, so keep it
|
||||
// off the Run() goroutine; the write path simply drops samples
|
||||
// for a key whose file is not open yet.
|
||||
if h.hist.enabled() {
|
||||
go func(id string, sigs []udpsprotocol.SignalInfo) {
|
||||
h.hist.onSourceConfigured(id, sigs)
|
||||
h.broadcast(h.buildHistoryInfoMsg())
|
||||
}(cmd.sourceID, cmd.sigs)
|
||||
}
|
||||
|
||||
case "wsAddSource":
|
||||
if h.sm != nil {
|
||||
@@ -748,10 +882,15 @@ func (h *Hub) Run() {
|
||||
continue
|
||||
}
|
||||
src, ok := sourcesMap[srcID]
|
||||
if !ok || len(src.signals) == 0 || len(h.clients) == 0 {
|
||||
if !ok || len(src.signals) == 0 {
|
||||
pending[srcID] = pending[srcID][:0]
|
||||
continue
|
||||
}
|
||||
// Built even with no clients connected: this is also what feeds
|
||||
// the rings, the disk history and the trigger, none of which may
|
||||
// stop just because nobody is watching. It also keeps the push
|
||||
// cursors advancing, so the first client to connect does not get
|
||||
// a backlog burst. Matches the C++ StreamHub.
|
||||
msg := h.buildBinaryDataMessageForSource(src, samples)
|
||||
pending[srcID] = pending[srcID][:0]
|
||||
if msg != nil {
|
||||
@@ -765,6 +904,9 @@ func (h *Hub) Run() {
|
||||
}
|
||||
h.triggerTick()
|
||||
|
||||
case <-flushTicker.C:
|
||||
h.hist.flushHeaders()
|
||||
|
||||
case <-statsTicker.C:
|
||||
h.statsMu.RLock()
|
||||
snap := make(map[string]StatInfo, len(h.statsMap))
|
||||
@@ -802,9 +944,21 @@ func writeFloat64s(buf []byte, off int, f []float64) int {
|
||||
// ever recover, and the browser already decimates for display.
|
||||
const maxPushPoints = 50
|
||||
|
||||
// Zoom ring depth, in samples per signal (16 bytes each). ringCapTemporal
|
||||
// holds 6 s of a 1 MSps waveform; ringCapScalar holds 100 000 packets.
|
||||
const ringCapTemporal = 6_000_000
|
||||
// Ring geometry, in samples per signal (16 bytes each).
|
||||
//
|
||||
// defaultRingPts is the per-signal memory budget for temporal (array) signals:
|
||||
// what the hub may spend keeping one signal available for zoom and for trigger
|
||||
// captures. 10 M points is 160 MB. The budget buys resolution, not span —
|
||||
// retuneRings buckets the input so the display window fits whatever the source
|
||||
// rate is.
|
||||
//
|
||||
// ringCapInitial is where a ring starts, so a source that is configured but
|
||||
// never sends costs nothing; the first retune sweep grows it to the budget.
|
||||
//
|
||||
// ringCapScalar sizes scalar signals, which arrive at the packet rate and would
|
||||
// squander a budget meant for megasample streams.
|
||||
const defaultRingPts = 10_000_000
|
||||
const ringCapInitial = 250_000
|
||||
const ringCapScalar = 100_000
|
||||
|
||||
// monotonicTolerance is the maximum inter-frame timestamp deviation (seconds)
|
||||
@@ -817,52 +971,59 @@ const monotonicTolerance = 0.005 // 5 ms
|
||||
// track real rate changes, slow enough to average out per-frame jitter.
|
||||
const monotonicEMAAlpha = 0.01
|
||||
|
||||
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points
|
||||
// using the Largest-Triangle-Three-Buckets algorithm.
|
||||
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
|
||||
// minMaxDecimate reduces (tIn, vIn) to at most threshold points the way an
|
||||
// oscilloscope draws a trace it cannot show pixel-for-pixel: the range is split
|
||||
// into threshold/2 equal buckets and each contributes its smallest and largest
|
||||
// sample, in the order the two occurred.
|
||||
//
|
||||
// This is what replaced LTTB on every path here. LTTB picks the sample that
|
||||
// makes the largest triangle with its neighbours, which reads as a plausible
|
||||
// shape but silently drops a one-sample spike whenever a smoother neighbour
|
||||
// scores higher — precisely the sample the user is looking for. The envelope
|
||||
// cannot drop it: a spike is by definition its bucket's min or max. The cost is
|
||||
// that a flat trace is drawn as a band rather than a line, which is how a scope
|
||||
// behaves too.
|
||||
//
|
||||
// Both output arrays hold real samples with their real timestamps; nothing is
|
||||
// interpolated or averaged.
|
||||
func minMaxDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
|
||||
n := len(tIn)
|
||||
if n <= threshold || threshold < 3 {
|
||||
// Below four there is no room for a single min/max pair plus endpoints.
|
||||
if n <= threshold || threshold < 4 {
|
||||
return tIn, vIn
|
||||
}
|
||||
outT := make([]float64, threshold)
|
||||
outV := make([]float64, threshold)
|
||||
outT[0], outV[0] = tIn[0], vIn[0]
|
||||
outT[threshold-1], outV[threshold-1] = tIn[n-1], vIn[n-1]
|
||||
|
||||
every := float64(n-2) / float64(threshold-2)
|
||||
a := 0
|
||||
for i := 0; i < threshold-2; i++ {
|
||||
avgS := int(float64(i+1)*every) + 1
|
||||
avgE := int(float64(i+2)*every) + 1
|
||||
if avgE > n {
|
||||
avgE = n
|
||||
buckets := threshold / 2
|
||||
outT := make([]float64, 0, threshold)
|
||||
outV := make([]float64, 0, threshold)
|
||||
for b := 0; b < buckets; b++ {
|
||||
lo := b * n / buckets
|
||||
hi := (b + 1) * n / buckets
|
||||
if b == buckets-1 {
|
||||
hi = n
|
||||
}
|
||||
avgT, avgV, cnt := 0.0, 0.0, 0
|
||||
for j := avgS; j < avgE; j++ {
|
||||
avgT += tIn[j]
|
||||
avgV += vIn[j]
|
||||
cnt++
|
||||
if lo >= hi {
|
||||
continue
|
||||
}
|
||||
if cnt > 0 {
|
||||
avgT /= float64(cnt)
|
||||
avgV /= float64(cnt)
|
||||
}
|
||||
rS := int(float64(i)*every) + 1
|
||||
rE := int(float64(i+1)*every) + 1
|
||||
if rE > n {
|
||||
rE = n
|
||||
}
|
||||
maxArea, next := -1.0, rS
|
||||
aT, aV := tIn[a], vIn[a]
|
||||
for j := rS; j < rE; j++ {
|
||||
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
|
||||
if area > maxArea {
|
||||
maxArea = area
|
||||
next = j
|
||||
iMin, iMax := lo, lo
|
||||
for j := lo + 1; j < hi; j++ {
|
||||
if vIn[j] < vIn[iMin] {
|
||||
iMin = j
|
||||
}
|
||||
if vIn[j] > vIn[iMax] {
|
||||
iMax = j
|
||||
}
|
||||
}
|
||||
outT[i+1], outV[i+1] = tIn[next], vIn[next]
|
||||
a = next
|
||||
// Emit in time order so the result plots as one ascending trace.
|
||||
if iMin > iMax {
|
||||
iMin, iMax = iMax, iMin
|
||||
}
|
||||
outT = append(outT, tIn[iMin])
|
||||
outV = append(outV, vIn[iMin])
|
||||
// A bucket whose samples are all equal has one extreme, not two.
|
||||
if iMax != iMin {
|
||||
outT = append(outT, tIn[iMax])
|
||||
outV = append(outV, vIn[iMax])
|
||||
}
|
||||
}
|
||||
return outT, outV
|
||||
}
|
||||
@@ -974,11 +1135,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
||||
allV = append(allV, vals[k])
|
||||
}
|
||||
}
|
||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||
rb.write(allT, allV)
|
||||
}
|
||||
h.trigger.feed(pfx+sig.Name, n, allT, allV)
|
||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||
h.ingest(pfx+sig.Name, n, allT, allV)
|
||||
decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
|
||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||
|
||||
case sig.TimeMode == udpsprotocol.TimeModeFullArray:
|
||||
@@ -1020,11 +1178,8 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
||||
allV = append(allV, vals[k])
|
||||
}
|
||||
}
|
||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||
rb.write(allT, allV)
|
||||
}
|
||||
h.trigger.feed(pfx+sig.Name, n, allT, allV)
|
||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||
h.ingest(pfx+sig.Name, n, allT, allV)
|
||||
decimT, decimV := minMaxDecimate(allT, allV, maxPushPoints)
|
||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||
|
||||
case n == 1:
|
||||
@@ -1038,10 +1193,7 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
||||
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)
|
||||
}
|
||||
h.trigger.feed(pfx+sig.Name, 1, ts, vs)
|
||||
h.ingest(pfx+sig.Name, 1, ts, vs)
|
||||
pairs[sig.Name] = pairBuf{t: ts, v: vs}
|
||||
|
||||
default:
|
||||
@@ -1107,12 +1259,19 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsp
|
||||
src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano()
|
||||
}
|
||||
if len(allT) > 0 {
|
||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||
rb.write(allT, allV)
|
||||
h.ingest(pfx+sig.Name, n, allT, allV)
|
||||
// Live push: never below one packet's worth of elements, or LTTB
|
||||
// would flatten the snapshot waveform itself; never above it
|
||||
// either, since anything more is just packets that piled up
|
||||
// during the tick. Pushing every point unconditionally does not
|
||||
// survive a fast producer: a 5 kHz x 1000-element array is 5M
|
||||
// points/s on the wire and the client queue never drains.
|
||||
thr := maxPushPoints
|
||||
if n > thr {
|
||||
thr = n
|
||||
}
|
||||
h.trigger.feed(pfx+sig.Name, n, allT, allV)
|
||||
// Live push: send all points without LTTB (fix 2).
|
||||
pairs[sig.Name] = pairBuf{t: allT, v: allV}
|
||||
decimT, decimV := minMaxDecimate(allT, allV, thr)
|
||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user