github.com/livekit/protocol@v1.16.1-0.20240517185851-47e4c6bba773/utils/timeseries/timeseries_test.go (about) 1 // Copyright 2023 LiveKit, Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package timeseries 16 17 import ( 18 "slices" 19 "testing" 20 "time" 21 22 "github.com/stretchr/testify/require" 23 ) 24 25 func TestTimeSeries(t *testing.T) { 26 t.Run("ordering", func(t *testing.T) { 27 ts := NewTimeSeries[uint32](TimeSeriesParams{ 28 UpdateOp: TimeSeriesUpdateOpMax, 29 Window: time.Minute, 30 }) 31 32 now := time.Now() 33 expectedSamples := []TimeSeriesSample[uint32]{ 34 { 35 Value: 10, 36 At: now, 37 }, 38 { 39 Value: 20, 40 At: now.Add(time.Second), 41 }, 42 { 43 Value: 30, 44 At: now.Add(2 * time.Second), 45 }, 46 { 47 Value: 40, 48 At: now.Add(3 * time.Second), 49 }, 50 } 51 52 testCases := []struct { 53 name string 54 samples []TimeSeriesSample[uint32] 55 }{ 56 { 57 name: "regular", 58 samples: []TimeSeriesSample[uint32]{ 59 {10, now}, 60 {20, now.Add(time.Second)}, 61 {30, now.Add(2 * time.Second)}, 62 {40, now.Add(3 * time.Second)}, 63 }, 64 }, 65 { 66 name: "reverse", 67 samples: []TimeSeriesSample[uint32]{ 68 {40, now.Add(3 * time.Second)}, 69 {30, now.Add(2 * time.Second)}, 70 {20, now.Add(time.Second)}, 71 {10, now}, 72 }, 73 }, 74 { 75 name: "jumbled 1", 76 samples: []TimeSeriesSample[uint32]{ 77 {20, now.Add(time.Second)}, 78 {40, now.Add(3 * time.Second)}, 79 {30, now.Add(2 * time.Second)}, 80 {10, now}, 81 }, 82 }, 83 { 84 name: "jumbled 2", 85 samples: []TimeSeriesSample[uint32]{ 86 {10, now}, 87 {40, now.Add(3 * time.Second)}, 88 {30, now.Add(2 * time.Second)}, 89 {20, now.Add(time.Second)}, 90 }, 91 }, 92 } 93 94 for _, tc := range testCases { 95 t.Run(tc.name, func(t *testing.T) { 96 ts.ClearSamples() 97 98 for _, tss := range tc.samples { 99 ts.AddSampleAt(tss.Value, tss.At) 100 } 101 102 require.Equal(t, expectedSamples, ts.GetSamples()) 103 }) 104 } 105 }) 106 107 t.Run("get samples after", func(t *testing.T) { 108 ts := NewTimeSeries[uint32](TimeSeriesParams{ 109 UpdateOp: TimeSeriesUpdateOpMax, 110 Window: 2 * time.Minute, 111 }) 112 113 expectedSamples := make([]TimeSeriesSample[uint32], 0, 4) 114 115 now := time.Now() 116 for val := uint32(0); val < 10; val++ { 117 at := now.Add(time.Duration(val) * time.Second) 118 ts.AddSampleAt(val, at) 119 if val > 5 { 120 expectedSamples = append(expectedSamples, TimeSeriesSample[uint32]{ 121 Value: val, 122 At: at, 123 }) 124 } 125 } 126 127 threshold := now.Add(5 * time.Second) 128 require.True(t, ts.HasSamplesAfter(threshold)) 129 require.Equal(t, expectedSamples, ts.GetSamplesAfter(threshold)) 130 131 history := make([]TimeSeriesSample[uint32], 0, 4) 132 for it := ts.ReverseIterateSamplesAfter(threshold); it.Next(); { 133 history = append(history, it.Value()) 134 } 135 slices.Reverse(history) 136 require.Equal(t, expectedSamples, ts.GetSamplesAfter(threshold)) 137 }) 138 139 t.Run("sum", func(t *testing.T) { 140 ts := NewTimeSeries[uint32](TimeSeriesParams{ 141 UpdateOp: TimeSeriesUpdateOpMax, 142 Window: 2 * time.Minute, 143 }) 144 145 ts.UpdateSample(10) 146 ts.UpdateSample(20) 147 ts.CommitActiveSampleAt(time.Now()) 148 require.Equal(t, float64(20.0), ts.Sum()) 149 150 ts.AddSample(30) 151 require.Equal(t, float64(50.0), ts.Sum()) 152 }) 153 154 t.Run("min", func(t *testing.T) { 155 ts := NewTimeSeries[uint32](TimeSeriesParams{ 156 UpdateOp: TimeSeriesUpdateOpLatest, 157 Window: 2 * time.Minute, 158 }) 159 160 ts.UpdateSample(10) 161 ts.UpdateSample(20) 162 ts.UpdateSample(15) 163 ts.CommitActiveSampleAt(time.Now()) 164 require.Equal(t, uint32(15), ts.Min()) 165 166 ts.AddSample(30) 167 require.Equal(t, uint32(15), ts.Min()) 168 }) 169 170 t.Run("max", func(t *testing.T) { 171 ts := NewTimeSeries[uint32](TimeSeriesParams{ 172 UpdateOp: TimeSeriesUpdateOpAdd, 173 Window: 2 * time.Minute, 174 }) 175 176 ts.UpdateSample(10) 177 ts.UpdateSample(20) 178 ts.CommitActiveSampleAt(time.Now()) 179 require.Equal(t, uint32(30), ts.Max()) 180 181 ts.AddSample(20) 182 require.Equal(t, uint32(30), ts.Max()) 183 }) 184 185 t.Run("current_run", func(t *testing.T) { 186 ts := NewTimeSeries[uint32](TimeSeriesParams{ 187 UpdateOp: TimeSeriesUpdateOpMax, 188 Window: time.Minute, 189 }) 190 191 testCases := []struct { 192 name string 193 values []uint32 194 timeStep time.Duration 195 compareOp TimeSeriesCompareOp 196 threshold uint32 197 expectedResult time.Duration 198 }{ 199 { 200 name: "eq_run", 201 values: []uint32{ 202 10, 203 20, 204 30, 205 40, 206 40, 207 40, 208 }, 209 timeStep: time.Second, 210 compareOp: TimeSeriesCompareOpEQ, 211 threshold: 40, 212 expectedResult: 2 * time.Second, 213 }, 214 { 215 name: "eq_no_run", 216 values: []uint32{ 217 10, 218 20, 219 30, 220 40, 221 40, 222 40, 223 }, 224 timeStep: time.Second, 225 compareOp: TimeSeriesCompareOpEQ, 226 threshold: 50, 227 expectedResult: 0, 228 }, 229 { 230 name: "ne", 231 values: []uint32{ 232 10, 233 20, 234 30, 235 40, 236 40, 237 40, 238 }, 239 timeStep: time.Second, 240 compareOp: TimeSeriesCompareOpNE, 241 threshold: 50, 242 expectedResult: 5 * time.Second, 243 }, 244 { 245 name: "gt", 246 values: []uint32{ 247 10, 248 20, 249 30, 250 40, 251 40, 252 40, 253 }, 254 timeStep: time.Second, 255 compareOp: TimeSeriesCompareOpGT, 256 threshold: 20, 257 expectedResult: 3 * time.Second, 258 }, 259 { 260 name: "gte", 261 values: []uint32{ 262 10, 263 20, 264 30, 265 40, 266 40, 267 40, 268 }, 269 timeStep: time.Second, 270 compareOp: TimeSeriesCompareOpGTE, 271 threshold: 20, 272 expectedResult: 4 * time.Second, 273 }, 274 { 275 name: "lt", 276 values: []uint32{ 277 50, 278 20, 279 30, 280 40, 281 40, 282 40, 283 }, 284 timeStep: time.Second, 285 compareOp: TimeSeriesCompareOpLT, 286 threshold: 50, 287 expectedResult: 4 * time.Second, 288 }, 289 { 290 name: "lte", 291 values: []uint32{ 292 10, 293 20, 294 30, 295 40, 296 40, 297 40, 298 }, 299 timeStep: time.Second, 300 compareOp: TimeSeriesCompareOpLTE, 301 threshold: 40, 302 expectedResult: 5 * time.Second, 303 }, 304 } 305 306 for _, tc := range testCases { 307 t.Run(tc.name, func(t *testing.T) { 308 ts.ClearSamples() 309 310 now := time.Now() 311 for idx, value := range tc.values { 312 ts.AddSampleAt(value, now.Add(time.Duration(idx)*tc.timeStep)) 313 } 314 315 require.Equal(t, tc.expectedResult, ts.CurrentRun(tc.threshold, tc.compareOp)) 316 }) 317 } 318 }) 319 320 t.Run("online", func(t *testing.T) { 321 ts := NewTimeSeries[uint32](TimeSeriesParams{ 322 UpdateOp: TimeSeriesUpdateOpMax, 323 Window: time.Minute, 324 }) 325 326 now := time.Now() 327 for val := uint32(1); val <= 10; val++ { 328 ts.AddSampleAt(val, now.Add(time.Duration(val)*time.Second)) 329 } 330 331 require.Equal(t, float64(5.5), ts.OnlineAverage()) 332 onlineVariance := ts.OnlineVariance() 333 require.Condition(t, func() bool { return onlineVariance > 9.16 && onlineVariance < 9.17 }, "online variance out of range") 334 onlineStdDev := ts.OnlineStdDev() 335 require.Condition(t, func() bool { return onlineStdDev > 3.02 && onlineStdDev < 3.03 }, "online std dev out of range") 336 }) 337 338 t.Run("collapse", func(t *testing.T) { 339 ts := NewTimeSeries[uint32](TimeSeriesParams{ 340 UpdateOp: TimeSeriesUpdateOpMax, 341 Window: time.Minute, 342 CollapseDuration: 2 * time.Second, 343 }) 344 345 // add same value spaced apart by half the collapse duration, should add only five to the list 346 now := time.Now() 347 for i := 0; i < 10; i++ { 348 ts.AddSampleAt(42, now.Add(time.Duration(i)*time.Second)) 349 } 350 samples := ts.GetSamples() 351 require.Equal(t, 5, len(samples)) 352 require.Equal(t, uint32(42), samples[0].Value) // spot check 353 require.Equal(t, uint32(42), samples[3].Value) // spot check 354 355 // add a sample of different value within the collapse window, it should get added 356 ts.AddSampleAt(43, now.Add(time.Duration(9)*time.Second)) // same time offset as last sample to keep within collapse window 357 samples = ts.GetSamples() 358 require.Equal(t, 6, len(samples)) 359 require.Equal(t, uint32(42), samples[0].Value) // spot check 360 require.Equal(t, uint32(42), samples[3].Value) // spot check 361 require.Equal(t, uint32(43), samples[5].Value) 362 363 // add a sample with same value as initial burst within the collapse window, it should get added 364 ts.AddSampleAt(42, now.Add(time.Duration(10)*time.Second)) 365 samples = ts.GetSamples() 366 require.Equal(t, 7, len(samples)) 367 require.Equal(t, uint32(42), samples[0].Value) // spot check 368 require.Equal(t, uint32(42), samples[3].Value) // spot check 369 require.Equal(t, uint32(43), samples[5].Value) 370 require.Equal(t, uint32(42), samples[6].Value) 371 }) 372 373 t.Run("slope", func(t *testing.T) { 374 ts := NewTimeSeries[float64](TimeSeriesParams{ 375 UpdateOp: TimeSeriesUpdateOpMax, 376 Window: time.Minute, 377 }) 378 379 // increasing values 380 now := time.Now() 381 for val := 1; val <= 10; val++ { 382 ts.AddSampleAt(float64(val)/10.0, now.Add(time.Duration(val)*time.Second)) 383 } 384 slope := ts.Slope() 385 require.Condition(t, func() bool { return slope > 5.71 && slope < 5.72 }, "slope out of range") 386 387 ts.ClearSamples() 388 389 // decreasing values 390 now = time.Now() 391 for val := 1; val <= 10; val++ { 392 ts.AddSampleAt(float64(11-val)/10.0, now.Add(time.Duration(val)*time.Second)) 393 } 394 slope = ts.Slope() 395 require.Condition(t, func() bool { return slope > -5.72 && slope < -5.71 }, "slope out of range") 396 397 ts.ClearSamples() 398 399 // see-saw values, slope should be 0.0 400 now = time.Now() 401 for val := 1; val <= 11; val++ { 402 if val&0x1 == 1 { 403 ts.AddSampleAt(1.0, now.Add(time.Duration(val)*time.Second)) 404 } else { 405 ts.AddSampleAt(10.0, now.Add(time.Duration(val)*time.Second)) 406 } 407 } 408 require.Equal(t, float64(0.0), ts.Slope()) 409 }) 410 411 t.Run("linear extrapolate to", func(t *testing.T) { 412 ts := NewTimeSeries[float64](TimeSeriesParams{ 413 UpdateOp: TimeSeriesUpdateOpMax, 414 Window: time.Minute, 415 }) 416 417 // increasing values 418 now := time.Now() 419 for val := 1; val <= 10; val++ { 420 ts.AddSampleAt(float64(val)/10.0, now.Add(time.Duration(val)*time.Second)) 421 } 422 423 // try to extrapolate using more than available samples 424 y, err := ts.LinearExtrapolateTo(11, 1*time.Second) 425 require.Error(t, err) 426 require.Equal(t, float64(0.0), y) 427 428 y, err = ts.LinearExtrapolateTo(10, 1*time.Second) 429 require.NoError(t, err) 430 require.Equal(t, float64(1.1), y) 431 432 ts.ClearSamples() 433 434 // decreasing values 435 now = time.Now() 436 for val := 1; val <= 10; val++ { 437 ts.AddSampleAt(float64(11-val)/10.0, now.Add(time.Duration(val)*time.Second)) 438 } 439 440 y, err = ts.LinearExtrapolateTo(10, 1*time.Second) 441 require.NoError(t, err) 442 // this picks up a value of -5.55 * 10^-17, probably due to float64 implementation, so check for smaller than some value very close to 0.0 443 // NOTE: printing the value still shows 0.000000, only require.Equal checking for 0.0 failed with that small value 444 require.Greater(t, float64(0.0000000000001), y) 445 }) 446 447 t.Run("kendall's tau", func(t *testing.T) { 448 ts := NewTimeSeries[int64](TimeSeriesParams{ 449 UpdateOp: TimeSeriesUpdateOpMax, 450 Window: time.Minute, 451 }) 452 453 // increasing values 454 now := time.Now() 455 for val := int64(1); val <= 10; val++ { 456 ts.AddSampleAt(val, now.Add(time.Duration(val)*time.Second)) 457 } 458 459 // asking to use more samples than available should return 0.0 460 tau, err := ts.KendallsTau(11) 461 require.Error(t, err) 462 require.Equal(t, float64(0.0), tau) 463 464 // ever increasing should return 1.0 465 tau, err = ts.KendallsTau(8) 466 require.NoError(t, err) 467 require.Equal(t, float64(1.0), tau) 468 469 ts.ClearSamples() 470 471 // decreasing values 472 now = time.Now() 473 for val := int64(1); val <= 10; val++ { 474 ts.AddSampleAt(11-val, now.Add(time.Duration(val)*time.Second)) 475 } 476 477 // ever decreasing should return -1.0 478 tau, err = ts.KendallsTau(8) 479 require.NoError(t, err) 480 require.Equal(t, float64(-1.0), tau) 481 482 ts.ClearSamples() 483 484 // overall increasing 485 now = time.Now() 486 for val := int64(1); val <= 10; val++ { 487 if val&0x1 == 0 { 488 ts.AddSampleAt(2*val, now.Add(time.Duration(val)*time.Second)) 489 } else { 490 ts.AddSampleAt(val, now.Add(time.Duration(val)*time.Second)) 491 } 492 } 493 494 // increasing envelope should trend positive 495 tau, err = ts.KendallsTau(8) 496 require.NoError(t, err) 497 require.Less(t, float64(0.0), tau) 498 499 // overall decreasing 500 now = time.Now() 501 for val := int64(1); val <= 10; val++ { 502 if val&0x1 == 0 { 503 ts.AddSampleAt(2*(11-val), now.Add(time.Duration(val)*time.Second)) 504 } else { 505 ts.AddSampleAt(11-val, now.Add(time.Duration(val)*time.Second)) 506 } 507 } 508 509 // decreasing envelope should trend negative 510 tau, err = ts.KendallsTau(8) 511 require.NoError(t, err) 512 require.Greater(t, float64(0.0), tau) 513 }) 514 }