feat(04-01): add AggregatePcap with timestamp-based windowing and gap-filling

- Add AggregatePcap to aggregate/window.go: reads ClassifiedPackets from channel,
  assigns to time windows using Timestamp field (D-01), fills gaps with empty
  snapshots (D-02: silence), fires onSnapshot callback per window (D-07)
- Add 7 tests covering basic, multiple windows, gaps, empty input, WindowIndex,
  class counts, and onSnapshot callback
- All tests pass
This commit is contained in:
2026-03-26 14:34:43 +01:00
parent 52c601019b
commit d13844f219
2 changed files with 212 additions and 0 deletions
+64
View File
@@ -6,6 +6,70 @@ import (
"github.com/netsynth/netsynth/classify"
)
// AggregatePcap reads all ClassifiedPackets (with Timestamp set), assigns to time windows
// using packet timestamps per D-01, fills gap windows with empty snapshots per D-02,
// and returns all WindowSnapshots. Calls onSnapshot for each if non-nil (D-07: --verbose).
// Returns synchronously after channel closes (pcap processing is finite).
func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int,
onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot {
// Drain the events channel into a slice.
var all []classify.ClassifiedPacket
for ev := range events {
all = append(all, ev)
}
if len(all) == 0 {
return nil
}
// Find min and max timestamps (handle non-monotonic pcaps per Pitfall 5).
minTS := all[0].Timestamp
maxTS := all[0].Timestamp
for _, ev := range all[1:] {
if ev.Timestamp.Before(minTS) {
minTS = ev.Timestamp
}
if ev.Timestamp.After(maxTS) {
maxTS = ev.Timestamp
}
}
// Calculate the maximum window index.
maxWindowIdx := int(maxTS.Sub(minTS).Milliseconds()) / windowMs
// Create all snapshots including gaps (D-02: gaps are silent).
snapshots := make([]classify.WindowSnapshot, maxWindowIdx+1)
for i := range snapshots {
snapshots[i] = classify.WindowSnapshot{
Counts: make(map[classify.TrafficClass]int64),
WindowIndex: i,
}
}
// Assign each packet to its window.
for _, ev := range all {
idx := int(ev.Timestamp.Sub(minTS).Milliseconds()) / windowMs
// Clamp to [0, maxWindowIdx] for safety.
if idx < 0 {
idx = 0
}
if idx > maxWindowIdx {
idx = maxWindowIdx
}
snapshots[idx].Counts[ev.Class]++
snapshots[idx].TotalPackets++
}
// Fire onSnapshot callback for each snapshot in order (D-07: --verbose).
if onSnapshot != nil {
for _, s := range snapshots {
onSnapshot(s)
}
}
return snapshots
}
// DefaultWindowMs is the default time window duration in milliseconds.
const DefaultWindowMs = 500