feat(webui): sporadic-trigger capture, CSV export and UI rework
Brings the Go hub and web SPA work developed on feature/udpscope onto main, without the udpscope client itself. The trigger engine could not capture a sporadic event: it armed on the live tail only, so a burst shorter than one push window was already past by the time the FSM looked for it. It now searches the ring history for the crossing, which also makes a capture reproducible from the same data rather than dependent on push timing (wshub/trigger.go, ringbuf.go, history.go). Adds CSV/JSON export of the visible window (wshub/export.go) and reworks the SPA: per-signal axis controls, a readable trigger panel, and a fix for the flicker caused by repainting on every push instead of on a frame tick (static/app.js, index.html, style.css). BUFFER_AND_TRIGGER.md documents the ring/decimation/trigger interaction, which is otherwise only inferable from the three files that implement it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cf815e1d3f
commit
f334995865
@@ -92,6 +92,14 @@ type triggerEngine struct {
|
||||
bufGrowth float64
|
||||
bufKnown bool
|
||||
bufRateOK bool
|
||||
// bufCoverage is the maximum span (seconds) the ring can reach at its
|
||||
// current bucket and capacity — the gate must never demand more than this,
|
||||
// or a ring whose coverage is below the window can never satisfy it. 0 =
|
||||
// unknown (no measurable rate).
|
||||
bufCoverage float64
|
||||
// bufArchived is true when the disk history already spans the trigger
|
||||
// window, so a short capture's front can be back-filled from it.
|
||||
bufArchived bool
|
||||
// Reference point the growth is measured against.
|
||||
bufRefSpan, bufRefWall float64
|
||||
|
||||
@@ -108,6 +116,22 @@ type triggerEngine struct {
|
||||
firedPost float64
|
||||
firedValid bool
|
||||
|
||||
// The edge to fire on as soon as the FSM rearms, in sample time. Recorded
|
||||
// while a capture is still being collected or handed out, for edges late
|
||||
// enough that a capture of them would not overlap the one in flight.
|
||||
//
|
||||
// Without this the trigger is deaf from its own trigger point until the
|
||||
// capture has been harvested — a post-window plus captureMarginSec — and
|
||||
// then for the holdoff on top of that, and afterwards waits for a FRESH
|
||||
// edge. On a sparse pulse train that rounds the capture spacing up to a
|
||||
// whole pulse period: at the default 1 s window the blind stretch comes to
|
||||
// 1.15 s, so a 1 Hz train was caught at 0.5 Hz and a wider window lost whole
|
||||
// multiples. Remembering the edge instead makes the blind stretch exactly
|
||||
// the post-window it has to be, since the capture is built from the edge's
|
||||
// own timestamp and the ring still holds everything around it.
|
||||
pendingT float64
|
||||
pendingValid bool
|
||||
|
||||
rearmAt float64 // wall-clock seconds; 0 when no rearm is pending
|
||||
}
|
||||
|
||||
@@ -163,10 +187,14 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
|
||||
if base != te.baseKey {
|
||||
// The buffer measurement belongs to the old signal's ring.
|
||||
te.bufKnown, te.bufRateOK = false, false
|
||||
te.bufCoverage, te.bufArchived = 0, false
|
||||
}
|
||||
te.baseKey, te.elemIdx = base, idx
|
||||
te.prevValid = false
|
||||
te.prevValue = 0
|
||||
// An edge held over from the old configuration would be latched against the
|
||||
// new window, whose fill the gate has not vouched for.
|
||||
te.pendingValid = false
|
||||
}
|
||||
|
||||
func (te *triggerEngine) Config() trigConfig {
|
||||
@@ -175,15 +203,37 @@ func (te *triggerEngine) Config() trigConfig {
|
||||
return te.cfg
|
||||
}
|
||||
|
||||
// Arm starts a fresh acquisition. It is the user's own arm, so it discards any
|
||||
// edge remembered during the previous capture: the user asked for the next
|
||||
// event, not for one that has already been and gone.
|
||||
func (te *triggerEngine) Arm() {
|
||||
te.mu.Lock()
|
||||
te.state = trigArmed
|
||||
te.prevValid = false
|
||||
te.prevValue = 0
|
||||
te.pendingValid = false
|
||||
te.rearmAt = 0
|
||||
te.mu.Unlock()
|
||||
}
|
||||
|
||||
// rearm is the automatic arm at the end of a capture. Unlike Arm it honours an
|
||||
// edge that arrived while the capture was being collected, firing on it at once
|
||||
// rather than waiting for the next one — see pendingT. It also keeps the level
|
||||
// tracked through the dead time, so the first sample after rearming is compared
|
||||
// against its real predecessor instead of being spent seeding one.
|
||||
func (te *triggerEngine) rearm() {
|
||||
te.mu.Lock()
|
||||
te.rearmAt = 0
|
||||
if te.pendingValid {
|
||||
t := te.pendingT
|
||||
te.pendingValid = false
|
||||
te.latchWindowLocked(t)
|
||||
} else {
|
||||
te.state = trigArmed
|
||||
}
|
||||
te.mu.Unlock()
|
||||
}
|
||||
|
||||
func (te *triggerEngine) Disarm() {
|
||||
te.mu.Lock()
|
||||
te.state = trigIdle
|
||||
@@ -191,6 +241,7 @@ func (te *triggerEngine) Disarm() {
|
||||
te.prevValid = false
|
||||
te.prevValue = 0
|
||||
te.firedValid = false
|
||||
te.pendingValid = false
|
||||
te.rearmAt = 0
|
||||
te.mu.Unlock()
|
||||
}
|
||||
@@ -243,13 +294,16 @@ const bufGrowthIntervalSec = 0.5
|
||||
const bufGrowthSmooth = 0.5
|
||||
|
||||
// setBuffered records how far back the trigger signal's ring reaches, at wall
|
||||
// clock now, and derives how fast that is growing. Pass known=false when there
|
||||
// is no such ring.
|
||||
func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
// clock now, and derives how fast that is growing. coverage is the maximum
|
||||
// span (seconds) the ring can reach at its current bucket/capacity; archived
|
||||
// says the disk history already spans the trigger window. Pass known=false when
|
||||
// there is no ring to measure.
|
||||
func (te *triggerEngine) setBuffered(span, coverage float64, archived, known bool, now float64) {
|
||||
te.mu.Lock()
|
||||
defer te.mu.Unlock()
|
||||
if !known {
|
||||
te.bufKnown, te.bufRateOK = false, false
|
||||
te.bufCoverage, te.bufArchived = 0, false
|
||||
return
|
||||
}
|
||||
if !te.bufKnown {
|
||||
@@ -257,6 +311,8 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
te.bufRefSpan, te.bufRefWall = span, now
|
||||
}
|
||||
te.bufSpan = span
|
||||
te.bufCoverage = coverage
|
||||
te.bufArchived = archived
|
||||
dt := now - te.bufRefWall
|
||||
if dt < bufGrowthIntervalSec {
|
||||
return
|
||||
@@ -294,9 +350,17 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
// anyway. A full one grows only as fast as its incoming samples free space —
|
||||
// re-bucketing to a longer window replaces dense old samples with sparse new
|
||||
// ones — and it is that case, growth well below 1, where firing on the
|
||||
// pre-window alone delivers a capture whose front has been overwritten by the
|
||||
// time it is read. In the steady state growth is 0 and need is the whole
|
||||
// window, which a ring tuned for that window already exceeds, so nothing waits.
|
||||
//
|
||||
// Two escapes keep an armed trigger from staying deaf forever:
|
||||
//
|
||||
// - archived — the disk history already spans the window, so the front of a
|
||||
// capture can be back-filled from it; the ring only needs to
|
||||
// hold the pre-window worth of recent data.
|
||||
// - coverage — never demand more than the ring can physically reach. If its
|
||||
// coverage saturates below the window (a measured source rate
|
||||
// that over-estimates the true one), the gate opens once the
|
||||
// ring is full anyway and a short capture is delivered instead
|
||||
// of deafness.
|
||||
func (te *triggerEngine) fillNeedLocked() float64 {
|
||||
pre := te.cfg.windowSec * te.cfg.prePercent / 100
|
||||
growth := 0.0 // until measured, assume the buffer will not fill on its own
|
||||
@@ -307,6 +371,14 @@ func (te *triggerEngine) fillNeedLocked() float64 {
|
||||
if need < pre {
|
||||
need = pre
|
||||
}
|
||||
if te.bufArchived {
|
||||
// The archive back-fills the front; the ring holds the post-trigger
|
||||
// window live, so the pre-window is all it needs to have reached.
|
||||
return pre
|
||||
}
|
||||
if te.bufCoverage > 0 && need > te.bufCoverage {
|
||||
need = te.bufCoverage
|
||||
}
|
||||
return need
|
||||
}
|
||||
|
||||
@@ -366,7 +438,11 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||
te.lastT = t[len(t)-1]
|
||||
te.lastTOK = true
|
||||
te.lastFeedWall = float64(time.Now().UnixNano()) / 1e9
|
||||
if te.state != trigArmed {
|
||||
|
||||
// A capture in flight does not stop the comparator; it only changes what an
|
||||
// edge does. See pendingT.
|
||||
inFlight := te.state == trigCollecting || te.state == trigTriggered
|
||||
if te.state != trigArmed && !inFlight {
|
||||
return
|
||||
}
|
||||
step, start := 1, 0
|
||||
@@ -381,12 +457,20 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||
// which is what made the first shot after a window change come back short.
|
||||
// Track the level meanwhile, so the first edge once the buffer is deep
|
||||
// enough is still measured against the right previous sample.
|
||||
if te.fillLocked() < 1 {
|
||||
if !inFlight && te.fillLocked() < 1 {
|
||||
for i := start; i < len(v); i += step {
|
||||
te.prevValue, te.prevValid = v[i], true
|
||||
}
|
||||
return
|
||||
}
|
||||
// The earliest trigger point a new capture may take. The one in flight owns
|
||||
// everything up to the end of its own post-window, and the holdoff — a guard
|
||||
// against re-triggering on the ringing of the SAME event — is measured from
|
||||
// its trigger point too, so the two overlap rather than add.
|
||||
notBefore := math.Inf(1)
|
||||
if inFlight && te.firedValid {
|
||||
notBefore = te.trigTime + math.Max(te.firedPost, te.cfg.holdoffSec)
|
||||
}
|
||||
thr := te.cfg.threshold
|
||||
for i := start; i < len(t); i += step {
|
||||
if !te.prevValid {
|
||||
@@ -406,10 +490,19 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||
default:
|
||||
fired = up
|
||||
}
|
||||
if fired {
|
||||
if !fired {
|
||||
continue
|
||||
}
|
||||
if !inFlight {
|
||||
te.latchWindowLocked(t[i])
|
||||
return
|
||||
}
|
||||
// Keep the FIRST qualifying edge and go on tracking the level: a later
|
||||
// one would be no more use, and stopping here would leave prevValue
|
||||
// stale by the time the FSM rearms.
|
||||
if !te.pendingValid && t[i] >= notBefore {
|
||||
te.pendingT, te.pendingValid = t[i], true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,19 +685,32 @@ func (h *Hub) refreshTriggerFill() {
|
||||
return
|
||||
}
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
key := h.trigger.baseSignalKey()
|
||||
var rb *sigRing
|
||||
if key := h.trigger.baseSignalKey(); key != "" {
|
||||
if key != "" {
|
||||
rb = h.getRing(key)
|
||||
}
|
||||
if rb == nil {
|
||||
// Nothing to measure. Do not gate on a signal the hub does not carry:
|
||||
// that would leave the trigger armed forever, which is worse than a
|
||||
// short capture.
|
||||
h.trigger.setBuffered(0, false, now)
|
||||
h.trigger.setBuffered(0, 0, false, false, now)
|
||||
return
|
||||
}
|
||||
_, span := rb.stats()
|
||||
h.trigger.setBuffered(span, true, now)
|
||||
// Maximum span the ring can ever reach at its current bucket/capacity, in
|
||||
// seconds. The gate must never demand more than this, or a ring whose
|
||||
// coverage is below the window (a measured source rate that over-estimates
|
||||
// the true one) can never satisfy it.
|
||||
coverage := 0.0
|
||||
if rate := rb.sourceRate(); rate > 0 {
|
||||
coverage = float64(ringCoverage(rb.bucketSize(), rb.capacity())) / rate
|
||||
}
|
||||
// If the disk archive already spans the trigger window, the front of a
|
||||
// short capture can be back-filled from it, so the ring need not cover the
|
||||
// whole window on its own.
|
||||
archived := h.hist.coversWindow(key, h.trigger.Config().windowSec)
|
||||
h.trigger.setBuffered(span, coverage, archived, true, now)
|
||||
}
|
||||
|
||||
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
|
||||
@@ -640,7 +746,7 @@ func (h *Hub) triggerTick() {
|
||||
// file of its own, where nothing overwrites it until the next trigger.
|
||||
h.hist.captureRange(trigTime-pre, trigTime+post)
|
||||
} else if h.trigger.dueRearm(nowSec) {
|
||||
h.trigger.Arm()
|
||||
h.trigger.rearm()
|
||||
}
|
||||
|
||||
if h.trigger.stateUnsent() {
|
||||
|
||||
Reference in New Issue
Block a user