diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go b/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go index eec4248ec7..68f78ffc6c 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go @@ -207,7 +207,7 @@ type SecondaryAggregation struct { type Function struct { // name of the function - Name string `json:"name"` + Name FunctionName `json:"name"` // args is the arguments to the function Args []struct { diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/functions.go b/pkg/types/querybuildertypes/querybuildertypesv5/functions.go index a3c58349c6..679e7c9095 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/functions.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/functions.go @@ -1,27 +1,489 @@ package querybuildertypesv5 -import "github.com/SigNoz/signoz/pkg/valuer" +import ( + "math" + "sort" + "strconv" + + "github.com/SigNoz/signoz/pkg/valuer" +) type FunctionName struct { valuer.String } var ( - FunctionNameCutOffMin = FunctionName{valuer.NewString("cutOffMin")} - FunctionNameCutOffMax = FunctionName{valuer.NewString("cutOffMax")} - FunctionNameClampMin = FunctionName{valuer.NewString("clampMin")} - FunctionNameClampMax = FunctionName{valuer.NewString("clampMax")} - FunctionNameAbsolute = FunctionName{valuer.NewString("absolute")} - FunctionNameRunningDiff = FunctionName{valuer.NewString("runningDiff")} - FunctionNameLog2 = FunctionName{valuer.NewString("log2")} - FunctionNameLog10 = FunctionName{valuer.NewString("log10")} - FunctionNameCumSum = FunctionName{valuer.NewString("cumSum")} - FunctionNameEWMA3 = FunctionName{valuer.NewString("ewma3")} - FunctionNameEWMA5 = FunctionName{valuer.NewString("ewma5")} - FunctionNameEWMA7 = FunctionName{valuer.NewString("ewma7")} - FunctionNameMedian3 = FunctionName{valuer.NewString("median3")} - FunctionNameMedian5 = FunctionName{valuer.NewString("median5")} - FunctionNameMedian7 = FunctionName{valuer.NewString("median7")} - FunctionNameTimeShift = FunctionName{valuer.NewString("timeShift")} - FunctionNameAnomaly = FunctionName{valuer.NewString("anomaly")} + FunctionNameCutOffMin = FunctionName{valuer.NewString("cutoff_min")} + FunctionNameCutOffMax = FunctionName{valuer.NewString("cutoff_max")} + FunctionNameClampMin = FunctionName{valuer.NewString("clamp_min")} + FunctionNameClampMax = FunctionName{valuer.NewString("clamp_max")} + FunctionNameAbsolute = FunctionName{valuer.NewString("absolute")} + FunctionNameRunningDiff = FunctionName{valuer.NewString("running_diff")} + FunctionNameLog2 = FunctionName{valuer.NewString("log2")} + FunctionNameLog10 = FunctionName{valuer.NewString("log10")} + FunctionNameCumulativeSum = FunctionName{valuer.NewString("cumulative_sum")} + FunctionNameEWMA3 = FunctionName{valuer.NewString("ewma3")} + FunctionNameEWMA5 = FunctionName{valuer.NewString("ewma5")} + FunctionNameEWMA7 = FunctionName{valuer.NewString("ewma7")} + FunctionNameMedian3 = FunctionName{valuer.NewString("median3")} + FunctionNameMedian5 = FunctionName{valuer.NewString("median5")} + FunctionNameMedian7 = FunctionName{valuer.NewString("median7")} + FunctionNameTimeShift = FunctionName{valuer.NewString("time_shift")} + FunctionNameAnomaly = FunctionName{valuer.NewString("anomaly")} ) + +// ApplyFunction applies the given function to the result data +func ApplyFunction(fn Function, result *Result) *Result { + // Extract the function name and arguments + name := fn.Name + args := fn.Args + + switch name { + case FunctionNameCutOffMin, FunctionNameCutOffMax, FunctionNameClampMin, FunctionNameClampMax: + if len(args) == 0 { + return result + } + threshold, err := parseFloat64Arg(args[0].Value) + if err != nil { + return result + } + switch name { + case FunctionNameCutOffMin: + return funcCutOffMin(result, threshold) + case FunctionNameCutOffMax: + return funcCutOffMax(result, threshold) + case FunctionNameClampMin: + return funcClampMin(result, threshold) + case FunctionNameClampMax: + return funcClampMax(result, threshold) + } + case FunctionNameAbsolute: + return funcAbsolute(result) + case FunctionNameRunningDiff: + return funcRunningDiff(result) + case FunctionNameLog2: + return funcLog2(result) + case FunctionNameLog10: + return funcLog10(result) + case FunctionNameCumulativeSum: + return funcCumulativeSum(result) + case FunctionNameEWMA3, FunctionNameEWMA5, FunctionNameEWMA7: + alpha := getEWMAAlpha(name, args) + return funcEWMA(result, alpha) + case FunctionNameMedian3: + return funcMedian3(result) + case FunctionNameMedian5: + return funcMedian5(result) + case FunctionNameMedian7: + return funcMedian7(result) + case FunctionNameTimeShift: + if len(args) == 0 { + return result + } + shift, err := parseFloat64Arg(args[0].Value) + if err != nil { + return result + } + return funcTimeShift(result, shift) + case FunctionNameAnomaly: + // Placeholder for anomaly detection - would need more sophisticated implementation + return result + } + return result +} + +// parseFloat64Arg parses a string argument to float64 +func parseFloat64Arg(value string) (float64, error) { + return strconv.ParseFloat(value, 64) +} + +// getEWMAAlpha calculates the alpha value for EWMA functions +func getEWMAAlpha(name FunctionName, args []struct { + Name string `json:"name,omitempty"` + Value string `json:"value"` +}) float64 { + // Try to get alpha from arguments first + if len(args) > 0 { + if alpha, err := parseFloat64Arg(args[0].Value); err == nil { + return alpha + } + } + + // Default alpha values: alpha = 2 / (n + 1) where n is the window size + switch name { + case FunctionNameEWMA3: + return 0.5 // 2 / (3 + 1) + case FunctionNameEWMA5: + return 1.0 / 3.0 // 2 / (5 + 1) + case FunctionNameEWMA7: + return 0.25 // 2 / (7 + 1) + } + return 0.5 // default +} + +// funcCutOffMin cuts off values below the threshold and replaces them with NaN +func funcCutOffMin(result *Result, threshold float64) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + if point.Value < threshold { + point.Value = math.NaN() + } + series.Values[idx] = point + } + } + } + return result +} + +// funcCutOffMax cuts off values above the threshold and replaces them with NaN +func funcCutOffMax(result *Result, threshold float64) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + if point.Value > threshold { + point.Value = math.NaN() + } + series.Values[idx] = point + } + } + } + return result +} + +// funcClampMin cuts off values below the threshold and replaces them with the threshold +func funcClampMin(result *Result, threshold float64) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + if point.Value < threshold { + point.Value = threshold + } + series.Values[idx] = point + } + } + } + return result +} + +// funcClampMax cuts off values above the threshold and replaces them with the threshold +func funcClampMax(result *Result, threshold float64) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + if point.Value > threshold { + point.Value = threshold + } + series.Values[idx] = point + } + } + } + return result +} + +// funcAbsolute returns the absolute value of each point +func funcAbsolute(result *Result) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + point.Value = math.Abs(point.Value) + series.Values[idx] = point + } + } + } + return result +} + +// funcRunningDiff returns the running difference of each point +func funcRunningDiff(result *Result) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + // iterate over the points in reverse order + for idx := len(series.Values) - 1; idx >= 0; idx-- { + if idx > 0 { + series.Values[idx].Value = series.Values[idx].Value - series.Values[idx-1].Value + } + } + // remove the first point + if len(series.Values) > 0 { + series.Values = series.Values[1:] + } + } + } + return result +} + +// funcLog2 returns the log2 of each point +func funcLog2(result *Result) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + point.Value = math.Log2(point.Value) + series.Values[idx] = point + } + } + } + return result +} + +// funcLog10 returns the log10 of each point +func funcLog10(result *Result) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + point.Value = math.Log10(point.Value) + series.Values[idx] = point + } + } + } + return result +} + +// funcCumulativeSum returns the cumulative sum for each point in a series +func funcCumulativeSum(result *Result) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + var sum float64 + for idx, point := range series.Values { + if !math.IsNaN(point.Value) { + sum += point.Value + } + point.Value = sum + series.Values[idx] = point + } + } + } + return result +} + +// funcEWMA calculates the Exponentially Weighted Moving Average +func funcEWMA(result *Result, alpha float64) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + var ewma float64 + var initialized bool + + for i, point := range series.Values { + if !initialized { + if !math.IsNaN(point.Value) { + // Initialize EWMA with the first non-NaN value + ewma = point.Value + initialized = true + } + // Continue until the EWMA is initialized + continue + } + + if !math.IsNaN(point.Value) { + // Update EWMA with the current value + ewma = alpha*point.Value + (1-alpha)*ewma + } + // Set the EWMA value for the current point + series.Values[i].Value = ewma + } + } + } + return result +} + +// funcMedian3 returns the median of 3 points for each point in a series +func funcMedian3(result *Result) *Result { + return funcMedianN(result, 3) +} + +// funcMedian5 returns the median of 5 points for each point in a series +func funcMedian5(result *Result) *Result { + return funcMedianN(result, 5) +} + +// funcMedian7 returns the median of 7 points for each point in a series +func funcMedian7(result *Result) *Result { + return funcMedianN(result, 7) +} + +// funcMedianN returns the median of N points for each point in a series +func funcMedianN(result *Result, n int) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + halfWindow := n / 2 + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + medianValues := make([]*TimeSeriesValue, 0) + + for i := halfWindow; i < len(series.Values)-halfWindow; i++ { + values := make([]float64, 0, n) + + // Add non-NaN values to the slice + for j := -halfWindow; j <= halfWindow; j++ { + if !math.IsNaN(series.Values[i+j].Value) { + values = append(values, series.Values[i+j].Value) + } + } + + // Create a new point with median value + newPoint := &TimeSeriesValue{ + Timestamp: series.Values[i].Timestamp, + } + + // Handle the case where there are not enough values to calculate a median + if len(values) == 0 { + newPoint.Value = math.NaN() + } else { + newPoint.Value = median(values) + } + + medianValues = append(medianValues, newPoint) + } + + // Replace the series values with median values + // Keep the original edge points unchanged + for i := halfWindow; i < len(series.Values)-halfWindow; i++ { + series.Values[i] = medianValues[i-halfWindow] + } + } + } + return result +} + +// median calculates the median of a slice of float64 values +func median(values []float64) float64 { + if len(values) == 0 { + return math.NaN() + } + + sort.Float64s(values) + medianIndex := len(values) / 2 + if len(values)%2 == 0 { + return (values[medianIndex-1] + values[medianIndex]) / 2 + } + return values[medianIndex] +} + +// funcTimeShift shifts all timestamps by the given amount (in seconds) +func funcTimeShift(result *Result, shift float64) *Result { + if result.Type != RequestTypeTimeSeries { + return result + } + + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok { + return result + } + + shiftMs := int64(shift * 1000) // Convert seconds to milliseconds + + for _, aggregation := range timeSeriesData.Aggregations { + for _, series := range aggregation.Series { + for idx, point := range series.Values { + series.Values[idx].Timestamp = point.Timestamp + shiftMs + } + } + } + return result +} + +// ApplyFunctions applies a list of functions sequentially to the result +func ApplyFunctions(functions []Function, result *Result) *Result { + for _, fn := range functions { + result = ApplyFunction(fn, result) + } + return result +} diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/functions_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/functions_test.go new file mode 100644 index 0000000000..c1faea09b2 --- /dev/null +++ b/pkg/types/querybuildertypes/querybuildertypesv5/functions_test.go @@ -0,0 +1,712 @@ +package querybuildertypesv5 + +import ( + "math" + "testing" +) + +// Helper function to create test time series data +func createTestTimeSeriesData(values []float64) *Result { + timeSeriesValues := make([]*TimeSeriesValue, len(values)) + for i, val := range values { + timeSeriesValues[i] = &TimeSeriesValue{ + Timestamp: int64(i + 1), + Value: val, + } + } + + series := &TimeSeries{ + Values: timeSeriesValues, + } + + aggregation := &AggregationBucket{ + Index: 0, + Alias: "test", + Series: []*TimeSeries{series}, + } + + timeSeriesData := &TimeSeriesData{ + QueryName: "test", + Aggregations: []*AggregationBucket{aggregation}, + } + + return &Result{ + Type: RequestTypeTimeSeries, + Value: timeSeriesData, + } +} + +// Helper function to extract values from result for comparison +func extractValues(result *Result) []float64 { + timeSeriesData, ok := result.Value.(*TimeSeriesData) + if !ok || len(timeSeriesData.Aggregations) == 0 || len(timeSeriesData.Aggregations[0].Series) == 0 { + return nil + } + + series := timeSeriesData.Aggregations[0].Series[0] + values := make([]float64, len(series.Values)) + for i, point := range series.Values { + values[i] = point.Value + } + return values +} + +func TestFuncCutOffMin(t *testing.T) { + tests := []struct { + name string + values []float64 + threshold float64 + want []float64 + }{ + { + name: "test funcCutOffMin", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + threshold: 0.3, + want: []float64{0.5, 0.4, 0.3, math.NaN(), math.NaN()}, + }, + { + name: "test funcCutOffMin with threshold 0", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + threshold: 0, + want: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcCutOffMin(result, tt.threshold) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcCutOffMin() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if math.IsNaN(tt.want[i]) { + if !math.IsNaN(got[i]) { + t.Errorf("funcCutOffMin() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } else { + if got[i] != tt.want[i] { + t.Errorf("funcCutOffMin() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + } + }) + } +} + +func TestFuncCutOffMax(t *testing.T) { + tests := []struct { + name string + values []float64 + threshold float64 + want []float64 + }{ + { + name: "test funcCutOffMax", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + threshold: 0.3, + want: []float64{math.NaN(), math.NaN(), 0.3, 0.2, 0.1}, + }, + { + name: "test funcCutOffMax with threshold 0", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + threshold: 0, + want: []float64{math.NaN(), math.NaN(), math.NaN(), math.NaN(), math.NaN()}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcCutOffMax(result, tt.threshold) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcCutOffMax() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if math.IsNaN(tt.want[i]) { + if !math.IsNaN(got[i]) { + t.Errorf("funcCutOffMax() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } else { + if got[i] != tt.want[i] { + t.Errorf("funcCutOffMax() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + } + }) + } +} + +func TestCutOffMinCumSum(t *testing.T) { + tests := []struct { + name string + values []float64 + threshold float64 + want []float64 + }{ + { + name: "test funcCutOffMin followed by funcCumulativeSum", + values: []float64{0.5, 0.2, 0.1, 0.4, 0.3}, + threshold: 0.3, + want: []float64{0.5, 0.5, 0.5, 0.9, 1.2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcCutOffMin(result, tt.threshold) + newResult = funcCumulativeSum(newResult) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("CutOffMin+CumSum got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if math.IsNaN(tt.want[i]) { + if !math.IsNaN(got[i]) { + t.Errorf("CutOffMin+CumSum at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } else { + if got[i] != tt.want[i] { + t.Errorf("CutOffMin+CumSum at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + } + }) + } +} + +func TestFuncMedian3(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "Values", + values: []float64{5, 3, 8, 2, 7}, + want: []float64{5, 5, 3, 7, 7}, // edge values unchanged, middle values are median of 3 + }, + { + name: "NaNHandling", + values: []float64{math.NaN(), 3, math.NaN(), 7, 9}, + want: []float64{math.NaN(), 3, 5, 8, 9}, // median of available values + }, + { + name: "UniformValues", + values: []float64{7, 7, 7, 7, 7}, + want: []float64{7, 7, 7, 7, 7}, + }, + { + name: "SingleValueSeries", + values: []float64{9}, + want: []float64{9}, + }, + { + name: "EmptySeries", + values: []float64{}, + want: []float64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + got := funcMedian3(result) + gotValues := extractValues(got) + + if len(gotValues) != len(tt.want) { + t.Errorf("funcMedian3() got length %d, want length %d", len(gotValues), len(tt.want)) + return + } + + for i := range gotValues { + if math.IsNaN(tt.want[i]) { + if !math.IsNaN(gotValues[i]) { + t.Errorf("funcMedian3() at index %d = %v, want %v", i, gotValues[i], tt.want[i]) + } + } else { + if gotValues[i] != tt.want[i] { + t.Errorf("funcMedian3() at index %d = %v, want %v", i, gotValues[i], tt.want[i]) + } + } + } + }) + } +} + +func TestFuncMedian5(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "Values", + values: []float64{5, 3, 8, 2, 7, 9, 1, 4, 6, 10}, + want: []float64{5, 3, 5, 7, 7, 4, 6, 6, 6, 10}, // edge values unchanged + }, + { + name: "NaNHandling", + values: []float64{math.NaN(), 3, math.NaN(), 7, 9, 1, 4, 6, 10, 2}, + want: []float64{math.NaN(), 3, 7, 5, 5.5, 6, 6, 4, 10, 2}, // median of available values + }, + { + name: "UniformValues", + values: []float64{7, 7, 7, 7, 7}, + want: []float64{7, 7, 7, 7, 7}, + }, + { + name: "SingleValueSeries", + values: []float64{9}, + want: []float64{9}, + }, + { + name: "EmptySeries", + values: []float64{}, + want: []float64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + got := funcMedian5(result) + gotValues := extractValues(got) + + if len(gotValues) != len(tt.want) { + t.Errorf("funcMedian5() got length %d, want length %d", len(gotValues), len(tt.want)) + return + } + + for i := range gotValues { + if math.IsNaN(tt.want[i]) { + if !math.IsNaN(gotValues[i]) { + t.Errorf("funcMedian5() at index %d = %v, want %v", i, gotValues[i], tt.want[i]) + } + } else { + if gotValues[i] != tt.want[i] { + t.Errorf("funcMedian5() at index %d = %v, want %v", i, gotValues[i], tt.want[i]) + } + } + } + }) + } +} + +func TestFuncRunningDiff(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "test funcRunningDiff", + values: []float64{1, 2, 3}, + want: []float64{1, 1}, // diff removes first element + }, + { + name: "test funcRunningDiff with start number as 8", + values: []float64{8, 8, 8}, + want: []float64{0, 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + got := funcRunningDiff(result) + gotValues := extractValues(got) + + if len(gotValues) != len(tt.want) { + t.Errorf("funcRunningDiff() got length %d, want length %d", len(gotValues), len(tt.want)) + return + } + + for i := range gotValues { + if gotValues[i] != tt.want[i] { + t.Errorf("funcRunningDiff() at index %d = %v, want %v", i, gotValues[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncClampMin(t *testing.T) { + tests := []struct { + name string + values []float64 + threshold float64 + want []float64 + }{ + { + name: "test funcClampMin", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + threshold: 0.3, + want: []float64{0.5, 0.4, 0.3, 0.3, 0.3}, + }, + { + name: "test funcClampMin with threshold 0", + values: []float64{-0.5, -0.4, 0.3, 0.2, 0.1}, + threshold: 0, + want: []float64{0, 0, 0.3, 0.2, 0.1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcClampMin(result, tt.threshold) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcClampMin() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("funcClampMin() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncClampMax(t *testing.T) { + tests := []struct { + name string + values []float64 + threshold float64 + want []float64 + }{ + { + name: "test funcClampMax", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + threshold: 0.3, + want: []float64{0.3, 0.3, 0.3, 0.2, 0.1}, + }, + { + name: "test funcClampMax with threshold 1.0", + values: []float64{2.5, 0.4, 1.3, 0.2, 0.1}, + threshold: 1.0, + want: []float64{1.0, 0.4, 1.0, 0.2, 0.1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcClampMax(result, tt.threshold) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcClampMax() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("funcClampMax() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncAbsolute(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "test funcAbsolute", + values: []float64{-0.5, 0.4, -0.3, 0.2, -0.1}, + want: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + }, + { + name: "test funcAbsolute with all positive", + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + want: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcAbsolute(result) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcAbsolute() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("funcAbsolute() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncLog2(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "test funcLog2", + values: []float64{1, 2, 4, 8, 16}, + want: []float64{0, 1, 2, 3, 4}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcLog2(result) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcLog2() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if math.Abs(got[i]-tt.want[i]) > 1e-10 { + t.Errorf("funcLog2() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncLog10(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "test funcLog10", + values: []float64{1, 10, 100, 1000}, + want: []float64{0, 1, 2, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcLog10(result) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcLog10() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if math.Abs(got[i]-tt.want[i]) > 1e-10 { + t.Errorf("funcLog10() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncCumSum(t *testing.T) { + tests := []struct { + name string + values []float64 + want []float64 + }{ + { + name: "test funcCumSum", + values: []float64{1, 2, 3, 4, 5}, + want: []float64{1, 3, 6, 10, 15}, + }, + { + name: "test funcCumSum with NaN", + values: []float64{1, math.NaN(), 3, 4, 5}, + want: []float64{1, 1, 4, 8, 13}, // NaN is ignored + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcCumulativeSum(result) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("funcCumSum() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("funcCumSum() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFuncTimeShift(t *testing.T) { + tests := []struct { + name string + values []float64 + shift float64 + want []int64 // expected timestamps + }{ + { + name: "test funcTimeShift positive", + values: []float64{1, 2, 3}, + shift: 5.0, // 5 seconds + want: []int64{6000, 7000, 8000}, // original timestamps (1,2,3) + 5000ms + }, + { + name: "test funcTimeShift negative", + values: []float64{1, 2, 3}, + shift: -2.0, // -2 seconds + want: []int64{-1000, 0, 1000}, // original timestamps (1,2,3) - 2000ms + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := funcTimeShift(result, tt.shift) + + timeSeriesData, ok := newResult.Value.(*TimeSeriesData) + if !ok { + t.Errorf("funcTimeShift() failed to get time series data") + return + } + + series := timeSeriesData.Aggregations[0].Series[0] + got := make([]int64, len(series.Values)) + for i, point := range series.Values { + got[i] = point.Timestamp + } + + if len(got) != len(tt.want) { + t.Errorf("funcTimeShift() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("funcTimeShift() at index %d timestamp = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestApplyFunction(t *testing.T) { + tests := []struct { + name string + function Function + values []float64 + want []float64 + }{ + { + name: "cutOffMin function", + function: Function{ + Name: FunctionNameCutOffMin, + Args: []struct { + Name string `json:"name,omitempty"` + Value string `json:"value"` + }{ + {Value: "0.3"}, + }, + }, + values: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + want: []float64{0.5, 0.4, 0.3, math.NaN(), math.NaN()}, + }, + { + name: "absolute function", + function: Function{ + Name: FunctionNameAbsolute, + }, + values: []float64{-0.5, 0.4, -0.3, 0.2, -0.1}, + want: []float64{0.5, 0.4, 0.3, 0.2, 0.1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createTestTimeSeriesData(tt.values) + newResult := ApplyFunction(tt.function, result) + got := extractValues(newResult) + + if len(got) != len(tt.want) { + t.Errorf("ApplyFunction() got length %d, want length %d", len(got), len(tt.want)) + return + } + + for i := range got { + if math.IsNaN(tt.want[i]) { + if !math.IsNaN(got[i]) { + t.Errorf("ApplyFunction() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } else { + if got[i] != tt.want[i] { + t.Errorf("ApplyFunction() at index %d = %v, want %v", i, got[i], tt.want[i]) + } + } + } + }) + } +} + +func TestApplyFunctions(t *testing.T) { + functions := []Function{ + { + Name: FunctionNameCutOffMin, + Args: []struct { + Name string `json:"name,omitempty"` + Value string `json:"value"` + }{ + {Value: "0.3"}, + }, + }, + { + Name: FunctionNameCumulativeSum, + }, + } + + values := []float64{0.5, 0.2, 0.1, 0.4, 0.3} + want := []float64{0.5, 0.5, 0.5, 0.9, 1.2} + + result := createTestTimeSeriesData(values) + newResult := ApplyFunctions(functions, result) + got := extractValues(newResult) + + if len(got) != len(want) { + t.Errorf("ApplyFunctions() got length %d, want length %d", len(got), len(want)) + return + } + + for i := range got { + if got[i] != want[i] { + t.Errorf("ApplyFunctions() at index %d = %v, want %v", i, got[i], want[i]) + } + } +} diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/req_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/req_test.go index 60dc24b825..176b34b8f4 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/req_test.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/req_test.go @@ -249,7 +249,7 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) { Name: "error_rate", Expression: "A / B * 100", Functions: []Function{{ - Name: "absolute", + Name: FunctionNameAbsolute, Args: []struct { Name string `json:"name,omitempty"` Value string `json:"value"`