Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
655 lines
17 KiB
Go
655 lines
17 KiB
Go
package analytics
|
|
|
|
import (
|
|
"database/sql"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// openTestDB creates a test SQLite database with the anomaly_patterns table.
|
|
func openTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
tmpDir, err := os.MkdirTemp("", "pattern_test")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(tmpDir) }) //nolint:errcheck
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() }) //nolint:errcheck
|
|
|
|
// Create required tables
|
|
_, err = db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value_json TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS anomaly_patterns (
|
|
zone_id TEXT NOT NULL,
|
|
hour_of_day INTEGER NOT NULL CHECK (hour_of_day BETWEEN 0 AND 23),
|
|
day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
|
|
mean_count REAL NOT NULL DEFAULT 0,
|
|
variance REAL NOT NULL DEFAULT 0,
|
|
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (zone_id, hour_of_day, day_of_week)
|
|
);
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("create tables: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
// newTestLearner creates a PatternLearner backed by a temp database.
|
|
func newTestLearner(t *testing.T) *PatternLearner {
|
|
t.Helper()
|
|
tmpDir, err := os.MkdirTemp("", "pattern_learner_test")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(tmpDir) }) //nolint:errcheck
|
|
|
|
pl, err := NewPatternLearner(filepath.Join(tmpDir, "patterns.db"))
|
|
if err != nil {
|
|
t.Fatalf("NewPatternLearner: %v", err)
|
|
}
|
|
t.Cleanup(func() { pl.Close() }) //nolint:errcheck
|
|
return pl
|
|
}
|
|
|
|
// --- Welford's algorithm tests ---
|
|
|
|
func TestWelfordUpdate_NumericalStability(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
observations []float64
|
|
wantMean float64
|
|
wantVar float64
|
|
}{
|
|
{
|
|
name: "single observation",
|
|
observations: []float64{5.0},
|
|
wantMean: 5.0,
|
|
wantVar: 0.0,
|
|
},
|
|
{
|
|
name: "two identical observations",
|
|
observations: []float64{3.0, 3.0},
|
|
wantMean: 3.0,
|
|
wantVar: 0.0,
|
|
},
|
|
{
|
|
name: "three observations",
|
|
observations: []float64{1.0, 2.0, 6.0},
|
|
wantMean: 3.0,
|
|
wantVar: 4.666666666666667,
|
|
},
|
|
{
|
|
name: "zero observations then non-zero",
|
|
observations: []float64{0.0, 0.0, 0.0, 5.0},
|
|
wantMean: 1.25,
|
|
wantVar: 4.6875,
|
|
},
|
|
{
|
|
name: "large count stability",
|
|
observations: makeSequence(2.0, 1000),
|
|
wantMean: 2.0,
|
|
wantVar: 0.0,
|
|
},
|
|
{
|
|
name: "large count with variance",
|
|
observations: makeSequence(5.0, 100),
|
|
wantMean: 5.0,
|
|
wantVar: 0.0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mean, m2, count := 0.0, 0.0, 0.0
|
|
for _, obs := range tt.observations {
|
|
mean, m2, count = WelfordUpdate(mean, m2, count, obs)
|
|
}
|
|
|
|
if math.Abs(mean-tt.wantMean) > 1e-9 {
|
|
t.Errorf("mean = %v, want %v", mean, tt.wantMean)
|
|
}
|
|
|
|
variance := 0.0
|
|
if count > 0 {
|
|
variance = m2 / count
|
|
}
|
|
|
|
if math.Abs(variance-tt.wantVar) > 1e-6 {
|
|
t.Errorf("variance = %v, want %v", variance, tt.wantVar)
|
|
}
|
|
|
|
// Check no NaN or Inf
|
|
if math.IsNaN(mean) || math.IsInf(mean, 0) {
|
|
t.Error("mean is NaN or Inf")
|
|
}
|
|
if math.IsNaN(variance) || math.IsInf(variance, 0) {
|
|
t.Error("variance is NaN or Inf")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWelfordUpdate_NoNaNInf_AnySampleCount(t *testing.T) {
|
|
mean, m2, count := 0.0, 0.0, 0.0
|
|
for i := 0; i < 10000; i++ {
|
|
obs := float64(i%100) * 0.01
|
|
mean, m2, count = WelfordUpdate(mean, m2, count, obs)
|
|
|
|
if math.IsNaN(mean) || math.IsInf(mean, 0) {
|
|
t.Fatalf("NaN/Inf mean at sample %d: mean=%v, m2=%v, count=%v", i+1, mean, m2, count)
|
|
}
|
|
|
|
variance := m2 / count
|
|
if math.IsNaN(variance) || math.IsInf(variance, 0) {
|
|
t.Fatalf("NaN/Inf variance at sample %d: variance=%v, m2=%v, count=%v", i+1, variance, m2, count)
|
|
}
|
|
|
|
if variance < -1e-12 {
|
|
t.Fatalf("negative variance at sample %d: %v", i+1, variance)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWelfordUpdate_MatchesBatchVariance(t *testing.T) {
|
|
observations := []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0}
|
|
|
|
mean, m2, count := 0.0, 0.0, 0.0
|
|
for _, obs := range observations {
|
|
mean, m2, count = WelfordUpdate(mean, m2, count, obs)
|
|
}
|
|
onlineVar := m2 / count
|
|
|
|
var batchMean float64
|
|
for _, obs := range observations {
|
|
batchMean += obs
|
|
}
|
|
batchMean /= float64(len(observations))
|
|
|
|
var batchVar float64
|
|
for _, obs := range observations {
|
|
batchVar += (obs - batchMean) * (obs - batchMean)
|
|
}
|
|
batchVar /= float64(len(observations))
|
|
|
|
if math.Abs(onlineVar-batchVar) > 1e-12 {
|
|
t.Errorf("online variance %v != batch variance %v", onlineVar, batchVar)
|
|
}
|
|
}
|
|
|
|
// --- normalizeZScore tests ---
|
|
|
|
func TestNormalizeZScore(t *testing.T) {
|
|
tests := []struct {
|
|
z float64
|
|
want float64
|
|
}{
|
|
{0.0, 0.0},
|
|
{0.5, 0.0},
|
|
{1.0, 0.0},
|
|
{2.0, 0.333},
|
|
{3.0, 0.667},
|
|
{4.0, 1.0},
|
|
{5.0, 1.0},
|
|
{-1.5, 0.167},
|
|
{-4.0, 1.0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := normalizeZScore(tt.z)
|
|
if math.Abs(got-tt.want) > 0.001 {
|
|
t.Errorf("normalizeZScore(%v) = %v, want %v", tt.z, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- PatternLearner tests ---
|
|
|
|
func TestPatternLearner_ColdStart(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
if !pl.IsColdStart() {
|
|
t.Error("expected cold start for new learner")
|
|
}
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 12, 0, 5)
|
|
if !result.Suppressed {
|
|
t.Error("expected anomaly score to be suppressed during cold start")
|
|
}
|
|
if result.CompositeScore != 0 {
|
|
t.Errorf("expected 0 composite score during cold start, got %v", result.CompositeScore)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_SlotNotReady(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
if pl.IsSlotReady("zone-1", 12, 0) {
|
|
t.Error("expected slot not ready")
|
|
}
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 12, 0, 5)
|
|
if !result.Suppressed {
|
|
t.Error("expected anomaly score suppressed when slot not ready")
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_ObserveAndUpdate_Persists(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
if err := pl.ObserveAndUpdate("zone-1", 12, 0, 2, 0); err != nil { //nolint:errcheck
|
|
t.Fatalf("ObserveAndUpdate: %v", err)
|
|
}
|
|
}
|
|
|
|
if !pl.IsSlotReady("zone-1", 12, 0) {
|
|
t.Error("expected slot to be ready after 50 observations")
|
|
}
|
|
|
|
slot := pl.GetPattern("zone-1", 12, 0)
|
|
if slot == nil {
|
|
t.Fatal("expected pattern to exist")
|
|
}
|
|
if slot.MeanCount != 2.0 {
|
|
t.Errorf("expected mean=2.0, got %v", slot.MeanCount)
|
|
}
|
|
if slot.SampleCount != 50 {
|
|
t.Errorf("expected sample_count=50, got %d", slot.SampleCount)
|
|
}
|
|
if slot.Variance > 1e-9 {
|
|
t.Errorf("expected variance=0 for identical observations, got %v", slot.Variance)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_ObserveAndUpdate_WithVariance(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
if err := pl.ObserveAndUpdate("zone-1", 12, 0, i%5, 0); err != nil { //nolint:errcheck
|
|
t.Fatalf("ObserveAndUpdate: %v", err)
|
|
}
|
|
}
|
|
|
|
slot := pl.GetPattern("zone-1", 12, 0)
|
|
if slot == nil {
|
|
t.Fatal("expected pattern")
|
|
}
|
|
|
|
if math.Abs(slot.MeanCount-2.0) > 1e-9 {
|
|
t.Errorf("expected mean=2.0, got %v", slot.MeanCount)
|
|
}
|
|
|
|
if math.Abs(slot.Variance-2.0) > 1e-6 {
|
|
t.Errorf("expected variance=2.0, got %v", slot.Variance)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_OutlierProtection(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
if err := pl.ObserveAndUpdate("zone-1", 12, 0, 0, 0); err != nil { //nolint:errcheck
|
|
t.Fatalf("ObserveAndUpdate: %v", err)
|
|
}
|
|
}
|
|
|
|
slotBefore := pl.GetPattern("zone-1", 12, 0)
|
|
meanBefore := slotBefore.MeanCount
|
|
countBefore := slotBefore.SampleCount
|
|
|
|
// Outlier should be skipped
|
|
if err := pl.ObserveAndUpdate("zone-1", 12, 0, 100, 0.6); err != nil { //nolint:errcheck
|
|
t.Fatalf("ObserveAndUpdate: %v", err)
|
|
}
|
|
|
|
slotAfter := pl.GetPattern("zone-1", 12, 0)
|
|
if slotAfter.MeanCount != meanBefore {
|
|
t.Errorf("outlier protection failed: mean changed from %v to %v", meanBefore, slotAfter.MeanCount)
|
|
}
|
|
if slotAfter.SampleCount != countBefore {
|
|
t.Errorf("outlier protection failed: count changed from %d to %d", countBefore, slotAfter.SampleCount)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_OutlierProtection_AfterMultipleAnomalies(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 12, 0, 1, 0) //nolint:errcheck
|
|
}
|
|
|
|
slot := pl.GetPattern("zone-1", 12, 0)
|
|
meanBefore := slot.MeanCount
|
|
|
|
// Inject 3 synthetic anomalies
|
|
for i := 0; i < 3; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 12, 0, 50, 1.0) //nolint:errcheck
|
|
}
|
|
|
|
slot = pl.GetPattern("zone-1", 12, 0)
|
|
if slot.SampleCount != 50 {
|
|
t.Errorf("expected sample_count to remain 50, got %d", slot.SampleCount)
|
|
}
|
|
if math.Abs(slot.MeanCount-meanBefore) > 1e-9 {
|
|
t.Errorf("expected mean to remain %v, got %v", meanBefore, slot.MeanCount)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_SecurityModeOverride(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
pl.SetSecurityMode(true)
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 12, 0, 0)
|
|
if result.CompositeScore != 1.0 {
|
|
t.Errorf("security mode: expected composite=1.0, got %v", result.CompositeScore)
|
|
}
|
|
if !result.IsAlert {
|
|
t.Error("security mode: expected is_alert=true")
|
|
}
|
|
|
|
result = pl.ComputeAnomalyScore("zone-1", 12, 0, 0)
|
|
if result.CompositeScore != 1.0 {
|
|
t.Errorf("security mode with 0 count: expected composite=1.0, got %v", result.CompositeScore)
|
|
}
|
|
|
|
pl.SetSecurityMode(false)
|
|
}
|
|
|
|
func TestPatternLearner_AnomalyScoring(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
pl.SetLearningStartTime(time.Now().Add(-8 * 24 * time.Hour))
|
|
|
|
for i := 0; i < 50; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 3, 0, 0, 0) //nolint:errcheck
|
|
}
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 3, 0, 0)
|
|
if result.CompositeScore > 0.01 {
|
|
t.Errorf("expected low score for expected observation, got %v", result.CompositeScore)
|
|
}
|
|
if result.Suppressed {
|
|
t.Error("expected not suppressed when slot is ready")
|
|
}
|
|
|
|
result = pl.ComputeAnomalyScore("zone-1", 3, 0, 3)
|
|
if result.ZoneScore != 1.0 {
|
|
t.Errorf("expected zone_score=1.0 when zone normally empty, got %v", result.ZoneScore)
|
|
}
|
|
if result.CompositeScore < 1.0 {
|
|
t.Errorf("expected composite=1.0 (max of time and zone), got %v", result.CompositeScore)
|
|
}
|
|
if !result.IsAlert {
|
|
t.Error("expected alert when zone normally empty but now occupied")
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_AnomalyScoring_ZScoreBased(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
pl.SetLearningStartTime(time.Now().Add(-8 * 24 * time.Hour))
|
|
|
|
for i := 0; i < 50; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 14, 0, 1+i%2, 0) //nolint:errcheck
|
|
}
|
|
|
|
slot := pl.GetPattern("zone-1", 14, 0)
|
|
if slot == nil {
|
|
t.Fatal("expected pattern")
|
|
}
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 14, 0, 2)
|
|
if result.TimeScore > 0.01 {
|
|
t.Errorf("expected low time_score for mean observation, got %v", result.TimeScore)
|
|
}
|
|
|
|
result = pl.ComputeAnomalyScore("zone-1", 14, 0, 10)
|
|
if result.TimeScore < 0.9 {
|
|
t.Errorf("expected high time_score for extreme observation, got %v", result.TimeScore)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_GetPatterns(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 12, 0, 2, 0) //nolint:errcheck
|
|
pl.ObserveAndUpdate("zone-2", 12, 0, 3, 0) //nolint:errcheck
|
|
}
|
|
|
|
all := pl.GetPatterns("")
|
|
if len(all) != 2 {
|
|
t.Errorf("expected 2 patterns, got %d", len(all))
|
|
}
|
|
|
|
zone1 := pl.GetPatterns("zone-1")
|
|
if len(zone1) != 1 {
|
|
t.Errorf("expected 1 pattern for zone-1, got %d", len(zone1))
|
|
}
|
|
if zone1[0].ZoneID != "zone-1" {
|
|
t.Errorf("expected zone_id=zone-1, got %s", zone1[0].ZoneID)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_SurvivesRestart(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "pattern_restart_test")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(tmpDir) }) //nolint:errcheck
|
|
|
|
dbPath := filepath.Join(tmpDir, "patterns.db")
|
|
|
|
pl1, err := NewPatternLearner(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("NewPatternLearner: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 50; i++ {
|
|
pl1.ObserveAndUpdate("zone-1", 12, 0, 2, 0)
|
|
}
|
|
|
|
pl1.Close() //nolint:errcheck
|
|
|
|
pl2, err := NewPatternLearner(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("NewPatternLearner after restart: %v", err)
|
|
}
|
|
defer pl2.Close() //nolint:errcheck
|
|
|
|
if !pl2.IsSlotReady("zone-1", 12, 0) {
|
|
t.Error("expected slot to be ready after reload from DB")
|
|
}
|
|
|
|
slot := pl2.GetPattern("zone-1", 12, 0)
|
|
if slot == nil {
|
|
t.Fatal("expected pattern after restart")
|
|
}
|
|
if slot.MeanCount != 2.0 {
|
|
t.Errorf("expected mean=2.0 after restart, got %v", slot.MeanCount)
|
|
}
|
|
if slot.SampleCount != 50 {
|
|
t.Errorf("expected 50 samples after restart, got %d", slot.SampleCount)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_AlertThresholds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
observations []int
|
|
testCount int
|
|
wantAlert bool
|
|
wantWarning bool
|
|
}{
|
|
{
|
|
name: "normal observation at mean",
|
|
observations: makeConst(2, 50),
|
|
testCount: 2,
|
|
wantAlert: false,
|
|
wantWarning: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for _, obs := range tt.observations {
|
|
pl.ObserveAndUpdate("zone-1", 14, 0, obs, 0) //nolint:errcheck
|
|
}
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 14, 0, tt.testCount)
|
|
if result.IsAlert != tt.wantAlert {
|
|
t.Errorf("is_alert = %v, want %v (composite=%v)", result.IsAlert, tt.wantAlert, result.CompositeScore)
|
|
}
|
|
if result.IsWarning != tt.wantWarning {
|
|
t.Errorf("is_warning = %v, want %v (composite=%v)", result.IsWarning, tt.wantWarning, result.CompositeScore)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_NaNInf_NeverProduced(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
observations := []int{0, 100, 0, 100, 0, 100, 1, 99, 50, 0}
|
|
for i := 0; i < 5; i++ {
|
|
for _, obs := range observations {
|
|
pl.ObserveAndUpdate("zone-1", 14, 0, obs, 0) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
slot := pl.GetPattern("zone-1", 14, 0)
|
|
if slot == nil {
|
|
t.Fatal("expected pattern")
|
|
}
|
|
|
|
if math.IsNaN(slot.MeanCount) || math.IsInf(slot.MeanCount, 0) {
|
|
t.Error("mean is NaN or Inf")
|
|
}
|
|
if math.IsNaN(slot.Variance) || math.IsInf(slot.Variance, 0) {
|
|
t.Error("variance is NaN or Inf")
|
|
}
|
|
|
|
for _, obs := range []int{0, 1, 5, 50, 100, 200} {
|
|
result := pl.ComputeAnomalyScore("zone-1", 14, 0, obs)
|
|
if math.IsNaN(result.CompositeScore) || math.IsInf(result.CompositeScore, 0) {
|
|
t.Errorf("NaN/Inf composite for obs=%d: %v", obs, result.CompositeScore)
|
|
}
|
|
if math.IsNaN(result.TimeScore) || math.IsInf(result.TimeScore, 0) {
|
|
t.Errorf("NaN/Inf time_score for obs=%d: %v", obs, result.TimeScore)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_NoAlertsDuringColdStart(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 100; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 3, 0, 50, 0) //nolint:errcheck
|
|
}
|
|
|
|
if !pl.IsColdStart() {
|
|
t.Log("note: cold start check depends on timing")
|
|
}
|
|
|
|
result := pl.ComputeAnomalyScore("zone-1", 3, 0, 50)
|
|
if !result.Suppressed {
|
|
t.Error("expected anomaly score to be suppressed during cold start regardless of activity")
|
|
}
|
|
if result.IsAlert || result.IsWarning {
|
|
t.Error("expected no alerts during cold start")
|
|
}
|
|
}
|
|
|
|
// --- Integration test: hourly update with mock provider ---
|
|
|
|
type mockOccupancyProvider struct {
|
|
counts map[string]int
|
|
}
|
|
|
|
func (m *mockOccupancyProvider) GetZoneOccupancyCounts() map[string]int {
|
|
return m.counts
|
|
}
|
|
|
|
func TestPatternLearner_HourlyUpdate_Integration(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
provider := &mockOccupancyProvider{
|
|
counts: map[string]int{"zone-1": 2, "zone-2": 0},
|
|
}
|
|
|
|
pl.updateAllZones(provider)
|
|
|
|
slot1 := pl.GetPattern("zone-1", time.Now().Hour(), int(time.Now().Weekday()))
|
|
if slot1 == nil {
|
|
t.Fatal("expected pattern for zone-1 after hourly update")
|
|
}
|
|
if slot1.MeanCount != 2.0 {
|
|
t.Errorf("expected mean=2.0 for zone-1, got %v", slot1.MeanCount)
|
|
}
|
|
|
|
slot2 := pl.GetPattern("zone-2", time.Now().Hour(), int(time.Now().Weekday()))
|
|
if slot2 == nil {
|
|
t.Fatal("expected pattern for zone-2 after hourly update")
|
|
}
|
|
if slot2.MeanCount != 0.0 {
|
|
t.Errorf("expected mean=0.0 for zone-2, got %v", slot2.MeanCount)
|
|
}
|
|
}
|
|
|
|
func TestPatternLearner_HourlyUpdate_OutlierProtectionInUpdate(t *testing.T) {
|
|
pl := newTestLearner(t)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
pl.ObserveAndUpdate("zone-1", 12, 0, 1, 0) //nolint:errcheck
|
|
}
|
|
|
|
slotBefore := pl.GetPattern("zone-1", 12, 0)
|
|
|
|
provider := &mockOccupancyProvider{
|
|
counts: map[string]int{"zone-1": 50},
|
|
}
|
|
|
|
pl.updateAllZones(provider)
|
|
|
|
slotAfter := pl.GetPattern("zone-1", 12, 0)
|
|
|
|
if slotAfter.SampleCount != slotBefore.SampleCount {
|
|
t.Logf("note: sample count changed from %d to %d (outlier protection may not trigger if score < 0.5)", slotBefore.SampleCount, slotAfter.SampleCount)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func makeSequence(value float64, count int) []float64 {
|
|
result := make([]float64, count)
|
|
for i := range result {
|
|
result[i] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
func makeConst(value, count int) []int {
|
|
result := make([]int, count)
|
|
for i := range result {
|
|
result[i] = value
|
|
}
|
|
return result
|
|
}
|