github.com/network-quality/goresponsiveness@v0.0.0-20240129151524-343954285090/qualityattenuation/qualityattenuation.go (about)

     1  /*
     2   * This file is part of Go Responsiveness.
     3   *
     4   * Go Responsiveness is free software: you can redistribute it and/or modify it under
     5   * the terms of the GNU General Public License as published by the Free Software Foundation,
     6   * either version 2 of the License, or (at your option) any later version.
     7   * Go Responsiveness is distributed in the hope that it will be useful, but WITHOUT ANY
     8   * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
     9   * PARTICULAR PURPOSE. See the GNU General Public License for more details.
    10   *
    11   * You should have received a copy of the GNU General Public License along
    12   * with Go Responsiveness. If not, see <https://www.gnu.org/licenses/>.
    13   */
    14  
    15  // Implements a data structure for quality attenuation.
    16  
    17  package qualityattenuation
    18  
    19  import (
    20  	"fmt"
    21  	"math"
    22  
    23  	"github.com/influxdata/tdigest"
    24  )
    25  
    26  type cablelabsHist struct {
    27  	hist [256]float64
    28  }
    29  
    30  func (h *cablelabsHist) GetHistogram() [256]float64 {
    31  	return h.hist
    32  }
    33  
    34  func (h *cablelabsHist) AddSample(sample float64) error {
    35  	bin := 0
    36  	if sample < 0.050 {
    37  		// Round down
    38  		bin = int(sample / 0.0005)
    39  		h.hist[bin]++
    40  	} else if sample < 0.150 {
    41  		bin = int((sample - 0.050) / 0.001)
    42  		h.hist[100+bin]++
    43  	} else if sample < 1.150 {
    44  		bin = int((sample - 0.150) / 0.020)
    45  		h.hist[200+bin]++
    46  	} else if sample < 1.400 {
    47  		bin = 250
    48  		h.hist[bin]++
    49  	} else if sample < 3.000 {
    50  		bin = int((sample - 1.400) / 0.400)
    51  		h.hist[251+bin]++
    52  	} else {
    53  		bin = 255
    54  		h.hist[bin]++
    55  	}
    56  	return nil
    57  }
    58  
    59  type SimpleQualityAttenuation struct {
    60  	empiricalDistribution  *tdigest.TDigest
    61  	offset                 float64
    62  	offsetSum              float64
    63  	offsetSumOfSquares     float64
    64  	numberOfSamples        int64
    65  	numberOfLosses         int64
    66  	latencyEqLossThreshold float64
    67  	minimumLatency         float64
    68  	maximumLatency         float64
    69  }
    70  
    71  type percentileLatencyPair struct {
    72  	percentile     float64
    73  	perfectLatency float64
    74  	uselessLatency float64
    75  }
    76  
    77  type qualityRequirement struct {
    78  	latencyRequirements []percentileLatencyPair
    79  }
    80  
    81  func NewSimpleQualityAttenuation() *SimpleQualityAttenuation {
    82  	return &SimpleQualityAttenuation{
    83  		empiricalDistribution:  tdigest.NewWithCompression(50),
    84  		offset:                 0.1,
    85  		offsetSum:              0.0,
    86  		offsetSumOfSquares:     0.0,
    87  		numberOfSamples:        0,
    88  		numberOfLosses:         0,
    89  		latencyEqLossThreshold: 15.0, // Count latency greater than this value as a loss.
    90  		minimumLatency:         0.0,
    91  		maximumLatency:         0.0,
    92  	}
    93  }
    94  
    95  func (qa *SimpleQualityAttenuation) AddSample(sample float64) error {
    96  	if sample <= 0.0 {
    97  		// Ignore zero or negative samples because they cannot be valid.
    98  		// TODO: This should raise a warning and/or trigger error handling.
    99  		return fmt.Errorf("sample is zero or negative")
   100  	}
   101  	qa.numberOfSamples++
   102  	if sample > qa.latencyEqLossThreshold {
   103  		qa.numberOfLosses++
   104  		return nil
   105  	} else {
   106  		if qa.minimumLatency == 0.0 || sample < qa.minimumLatency {
   107  			qa.minimumLatency = sample
   108  		}
   109  		if qa.maximumLatency == 0.0 || sample > qa.maximumLatency {
   110  			qa.maximumLatency = sample
   111  		}
   112  		qa.empiricalDistribution.Add(sample, 1)
   113  		qa.offsetSum += sample - qa.offset
   114  		qa.offsetSumOfSquares += (sample - qa.offset) * (sample - qa.offset)
   115  	}
   116  	return nil
   117  }
   118  
   119  func (qa *SimpleQualityAttenuation) GetNumberOfLosses() int64 {
   120  	return qa.numberOfLosses
   121  }
   122  
   123  func (qa *SimpleQualityAttenuation) GetNumberOfSamples() int64 {
   124  	return qa.numberOfSamples
   125  }
   126  
   127  func (qa *SimpleQualityAttenuation) GetPercentile(percentile float64) float64 {
   128  	return qa.empiricalDistribution.Quantile(percentile / 100)
   129  }
   130  
   131  func (qa *SimpleQualityAttenuation) GetAverage() float64 {
   132  	return qa.offsetSum/float64(qa.numberOfSamples-qa.numberOfLosses) + qa.offset
   133  }
   134  
   135  func (qa *SimpleQualityAttenuation) GetVariance() float64 {
   136  	number_of_latency_samples := float64(qa.numberOfSamples) - float64(qa.numberOfLosses)
   137  	return (qa.offsetSumOfSquares - (qa.offsetSum * qa.offsetSum / number_of_latency_samples)) / (number_of_latency_samples - 1)
   138  }
   139  
   140  func (qa *SimpleQualityAttenuation) GetStandardDeviation() float64 {
   141  	return math.Sqrt(qa.GetVariance())
   142  }
   143  
   144  func (qa *SimpleQualityAttenuation) GetMinimum() float64 {
   145  	return qa.minimumLatency
   146  }
   147  
   148  func (qa *SimpleQualityAttenuation) GetMaximum() float64 {
   149  	return qa.maximumLatency
   150  }
   151  
   152  func (qa *SimpleQualityAttenuation) GetMedian() float64 {
   153  	return qa.GetPercentile(50.0)
   154  }
   155  
   156  func (qa *SimpleQualityAttenuation) GetLossPercentage() float64 {
   157  	return 100 * float64(qa.numberOfLosses) / float64(qa.numberOfSamples)
   158  }
   159  
   160  func (qa *SimpleQualityAttenuation) GetRPM() float64 {
   161  	return 60.0 / qa.GetAverage()
   162  }
   163  
   164  func (qa *SimpleQualityAttenuation) GetPDV(percentile float64) float64 {
   165  	return qa.GetPercentile(percentile) - qa.GetMinimum()
   166  }
   167  
   168  func (qa *SimpleQualityAttenuation) PrintCablelabsStatisticsSummary() string {
   169  	// Prints a digest based on Cablelabs Latency Measurements Metrics and Architeture, CL-TR-LM-Arch-V01-221123, https://www.cablelabs.com/specifications/CL-TR-LM-Arch
   170  	// The recommendation is to report the following percentiles: 0, 10, 25, 50, 75, 90, 95, 99, 99.9 and 100
   171  	return fmt.Sprintf("Cablelabs Statistics Summary:\n"+
   172  		"0th Percentile: %f\n"+
   173  		"10th Percentile: %f\n"+
   174  		"25th Percentile: %f\n"+
   175  		"50th Percentile: %f\n"+
   176  		"75th Percentile: %f\n"+
   177  		"90th Percentile: %f\n"+
   178  		"95th Percentile: %f\n"+
   179  		"99th Percentile: %f\n"+
   180  		"99.9th Percentile: %f\n"+
   181  		"100th Percentile: %f\n",
   182  		qa.GetPercentile(0.0),
   183  		qa.GetPercentile(10.0),
   184  		qa.GetPercentile(25.0),
   185  		qa.GetPercentile(50.0),
   186  		qa.GetPercentile(75.0),
   187  		qa.GetPercentile(90.0),
   188  		qa.GetPercentile(95.0),
   189  		qa.GetPercentile(99.0),
   190  		qa.GetPercentile(99.9),
   191  		qa.GetPercentile(100.0))
   192  }
   193  
   194  // Merge two quality attenuation values. This operation assumes the two samples have the same offset and latency_eq_loss_threshold, and
   195  // will return an error if they do not.
   196  // It also assumes that the two quality attenuation values are measurements of the same thing (path, outcome, etc.).
   197  func (qa *SimpleQualityAttenuation) Merge(other *SimpleQualityAttenuation) error {
   198  	// Check that offsets are the same
   199  	if qa.offset != other.offset ||
   200  		qa.latencyEqLossThreshold != other.latencyEqLossThreshold {
   201  		return fmt.Errorf("merge quality attenuation values with different offset or latency_eq_loss_threshold")
   202  	}
   203  	for _, centroid := range other.empiricalDistribution.Centroids() {
   204  		mean := centroid.Mean
   205  		weight := centroid.Weight
   206  		qa.empiricalDistribution.Add(mean, weight)
   207  	}
   208  	qa.offsetSum += other.offsetSum
   209  	qa.offsetSumOfSquares += other.offsetSumOfSquares
   210  	qa.numberOfSamples += other.numberOfSamples
   211  	qa.numberOfLosses += other.numberOfLosses
   212  	if other.minimumLatency < qa.minimumLatency {
   213  		qa.minimumLatency = other.minimumLatency
   214  	}
   215  	if other.maximumLatency > qa.maximumLatency {
   216  		qa.maximumLatency = other.maximumLatency
   217  	}
   218  	return nil
   219  }
   220  
   221  func (qa *SimpleQualityAttenuation) EmpiricalDistributionHistogram() []float64 {
   222  	// Convert the tdigest to a histogram on the format defined by CableLabs, with the following bucket edges:
   223  	// 100 bins from 0 to 50 ms, each 0.5 ms wide
   224  	// 100 bins from 50 to 100 ms, each 1 ms wide
   225  	// 50 bins from 150 to 1150 ms, each 20 ms wide
   226  	// 1 bin from 1150 to 1400 ms, 250 ms wide
   227  	// 4 bins from 1400 to 3000 ms, each 400 ms wide
   228  	hist := make([]float64, 256)
   229  	for i := 0; i < 100; i++ {
   230  		hist[i] = float64(qa.numberOfSamples) * (qa.empiricalDistribution.CDF(float64(i+1)*0.0005) -
   231  			qa.empiricalDistribution.CDF(float64(i)*0.0005))
   232  	}
   233  	for i := 100; i < 200; i++ {
   234  		hist[i] = float64(qa.numberOfSamples) * (qa.empiricalDistribution.CDF(0.050+float64(i-99)*0.001) -
   235  			qa.empiricalDistribution.CDF(0.050+float64(i-100)*0.001))
   236  	}
   237  	for i := 200; i < 250; i++ {
   238  		hist[i] = float64(qa.numberOfSamples) * (qa.empiricalDistribution.CDF(0.150+float64(i-199)*0.020) -
   239  			qa.empiricalDistribution.CDF(0.150+float64(i-200)*0.020))
   240  	}
   241  	for i := 250; i < 251; i++ {
   242  		hist[i] = float64(qa.numberOfSamples) * (qa.empiricalDistribution.CDF(1.150+0.250) - qa.empiricalDistribution.CDF(1.150))
   243  	}
   244  	for i := 251; i < 255; i++ {
   245  		hist[i] = float64(qa.numberOfSamples) * (qa.empiricalDistribution.CDF(1.400+float64(i-250)*0.400) -
   246  			qa.empiricalDistribution.CDF(1.400+float64(i-251)*0.400))
   247  	}
   248  	hist[255] = float64(qa.numberOfSamples) * (1 - qa.empiricalDistribution.CDF(3.000))
   249  	return hist
   250  }
   251  
   252  // Compute the Quality of Outcome (QoO) for a given quality requirement.
   253  // The details and motivation for the QoO metric are described in the following internet draft:
   254  // https://datatracker.ietf.org/doc/draft-olden-ippm-qoo/
   255  func (qa *SimpleQualityAttenuation) QoO(requirement qualityRequirement) float64 {
   256  	QoO := 100.0
   257  	for _, percentileLatencyPair := range requirement.latencyRequirements {
   258  		score := 0.0
   259  		percentile := percentileLatencyPair.percentile
   260  		perfectLatency := percentileLatencyPair.perfectLatency
   261  		uselessLatency := percentileLatencyPair.uselessLatency
   262  		latency := qa.GetPercentile(percentile)
   263  		if latency >= uselessLatency {
   264  			score = 0.0
   265  		} else if latency <= perfectLatency {
   266  			score = 100.0
   267  		} else {
   268  			score = 100 * ((uselessLatency - latency) / (uselessLatency - perfectLatency))
   269  		}
   270  		if score < QoO {
   271  			QoO = score
   272  		}
   273  	}
   274  	return QoO
   275  }
   276  
   277  func (qa *SimpleQualityAttenuation) GetGamingQoO() float64 {
   278  	qualReq := qualityRequirement{}
   279  	qualReq.latencyRequirements = []percentileLatencyPair{}
   280  	qualReq.latencyRequirements = append(qualReq.latencyRequirements, percentileLatencyPair{
   281  		percentile: 50.0, perfectLatency: 0.030, uselessLatency: 0.150,
   282  	})
   283  	qualReq.latencyRequirements = append(qualReq.latencyRequirements, percentileLatencyPair{
   284  		percentile: 95.0, perfectLatency: 0.065, uselessLatency: 0.200,
   285  	})
   286  	qualReq.latencyRequirements = append(qualReq.latencyRequirements, percentileLatencyPair{
   287  		percentile: 99.0, perfectLatency: 0.100, uselessLatency: 0.250,
   288  	})
   289  	return qa.QoO(qualReq)
   290  }