diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go new file mode 100644 index 00000000..cbaab34b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// baseline.go + +package gdelt + +import "math" + +const ( + minStdDev = 1.0 +) + +type Bucket struct { + Score int64 + Count int +} + +type ThemeState struct { + buckets []Bucket + capN int +} + +func NewThemeState(capN int) *ThemeState { + if capN <= 0 { + capN = 96 + } + return &ThemeState{capN: capN} +} + +func (s *ThemeState) Push(b Bucket) { + s.buckets = append(s.buckets, b) + if len(s.buckets) > s.capN { + s.buckets = s.buckets[len(s.buckets)-s.capN:] + } +} + +func (s *ThemeState) Len() int { + return len(s.buckets) +} + +func (s *ThemeState) Stats() (mean, stddev float64) { + if len(s.buckets) == 0 { + return 0, 0 + } + sum := 0.0 + for _, b := range s.buckets { + sum += float64(b.Count) + } + mean = sum / float64(len(s.buckets)) + + variance := 0.0 + for _, b := range s.buckets { + d := float64(b.Count) - mean + variance += d * d + } + variance /= float64(len(s.buckets)) + stddev = math.Sqrt(variance) + return mean, stddev +} + +func (s *ThemeState) ZScore(count int) float64 { + mean, stddev := s.Stats() + if stddev < minStdDev { + return 0 + } + return (float64(count) - mean) / stddev +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go new file mode 100644 index 00000000..cdd383b9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go @@ -0,0 +1,58 @@ +// ©AngelaMos | 2026 +// baseline_test.go + +package gdelt_test + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" +) + +func TestBaseline_EmptyReturnsZeroZ(t *testing.T) { + b := gdelt.NewThemeState(96) + require.InDelta(t, 0.0, b.ZScore(100), 0.0001) +} + +func TestBaseline_StableThenSpikeReturnsHighZ(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)}) + } + z := b.ZScore(500) + require.Greater(t, z, 3.0) +} + +func TestBaseline_StableThenNormalReturnsLowZ(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)}) + } + z := b.ZScore(108) + require.Less(t, z, 3.0) +} + +func TestBaseline_CapacityCapsBuckets(t *testing.T) { + b := gdelt.NewThemeState(10) + for i := 0; i < 200; i++ { + b.Push(gdelt.Bucket{Score: int64(i), Count: i}) + } + require.Equal(t, 10, b.Len()) +} + +func TestBaseline_TinyStdDevDoesNotDivByZero(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100}) + } + z := b.ZScore(101) + require.False(t, math.IsInf(z, 0)) + require.False(t, math.IsNaN(z)) +}