Welford's online algorithm for per-zone, per-hour, per-day-of-week occupancy modeling with cold start suppression, outlier protection, security mode override, and SQLite persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
655 lines
16 KiB
Go
655 lines
16 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) })
|
|
|
|
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() })
|
|
|
|
// 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) })
|
|
|
|
pl, err := NewPatternLearner(filepath.Join(tmpDir, "patterns.db"))
|
|
if err != nil {
|
|
t.Fatalf("NewPatternLearner: %v", err)
|
|
}
|
|
t.Cleanup(func() { pl.Close() })
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
pl.ObserveAndUpdate("zone-2", 12, 0, 3, 0)
|
|
}
|
|
|
|
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) })
|
|
|
|
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()
|
|
|
|
pl2, err := NewPatternLearner(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("NewPatternLearner after restart: %v", err)
|
|
}
|
|
defer pl2.Close()
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|