github.com/whoyao/protocol@v0.0.0-20230519045905-2d8ace718ca5/utils/timeseries/timeseries_test.go (about)

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