Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 160 |
|
0.00% |
0 / 9 |
CRAP | |
0.00% |
0 / 1 |
| StatsCalculator | |
0.00% |
0 / 160 |
|
0.00% |
0 / 9 |
1806 | |
0.00% |
0 / 1 |
| erf | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| cdf | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| standardDeviation | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
56 | |||
| arithmeticMean | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| geometricMean | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| harmonicMean | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
| median | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
| addBuyerEfficiencyScores | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
72 | |||
| addSorterEfficiencyScores | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
72 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\Stats; |
| 4 | |
| 5 | /** |
| 6 | * StatsCalculator - Statistical calculation utilities |
| 7 | * |
| 8 | * Provides static methods for statistical calculations including: |
| 9 | * - Distribution functions (erf, cdf) |
| 10 | * - Central tendency measures (mean, median) |
| 11 | * - Dispersion measures (standard deviation) |
| 12 | * - Employee efficiency scoring |
| 13 | * |
| 14 | * @package BuyerKiosk\Stats |
| 15 | */ |
| 16 | class StatsCalculator |
| 17 | { |
| 18 | /** |
| 19 | * Error function approximation using Abramowitz and Stegun formula |
| 20 | * |
| 21 | * @param float $x Input value |
| 22 | * @return float Error function result |
| 23 | */ |
| 24 | public static function erf(float $x): float |
| 25 | { |
| 26 | $pi = M_PI; |
| 27 | $a = (8 * ($pi - 3)) / (3 * $pi * (4 - $pi)); |
| 28 | $x2 = $x * $x; |
| 29 | $ax2 = $a * $x2; |
| 30 | $num = (4 / $pi) + $ax2; |
| 31 | $denom = 1 + $ax2; |
| 32 | $inner = (-$x2) * $num / $denom; |
| 33 | $erf2 = 1 - exp($inner); |
| 34 | |
| 35 | return $x < 0 ? -sqrt($erf2) : sqrt($erf2); |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Cumulative distribution function (standard normal distribution) |
| 40 | * |
| 41 | * @param float $n Z-score value |
| 42 | * @return float Probability (0 to 1) |
| 43 | */ |
| 44 | public static function cdf(float $n): float |
| 45 | { |
| 46 | if ($n < 0) { |
| 47 | return (1 - self::erf($n / sqrt(2))) / 2; |
| 48 | } else { |
| 49 | return (1 + self::erf($n / sqrt(2))) / 2; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Calculate standard deviation |
| 55 | * |
| 56 | * @param array $a Array of numeric values |
| 57 | * @param bool $sample If true, calculate sample standard deviation (n-1), otherwise population (n) |
| 58 | * @return float|false Standard deviation or false on error |
| 59 | */ |
| 60 | public static function standardDeviation(array $a, bool $sample = false): float|false |
| 61 | { |
| 62 | $n = count($a); |
| 63 | |
| 64 | if ($n === 0) { |
| 65 | trigger_error("The array has zero elements", E_USER_WARNING); |
| 66 | return false; |
| 67 | } |
| 68 | |
| 69 | if ($sample && $n === 1) { |
| 70 | trigger_error("The array has only 1 element", E_USER_WARNING); |
| 71 | return false; |
| 72 | } |
| 73 | |
| 74 | $mean = array_sum($a) / $n; |
| 75 | $carry = 0.0; |
| 76 | |
| 77 | foreach ($a as $val) { |
| 78 | $d = ((float) $val) - $mean; |
| 79 | $carry += $d * $d; |
| 80 | } |
| 81 | |
| 82 | if ($sample) { |
| 83 | --$n; |
| 84 | } |
| 85 | |
| 86 | // Prevent division by zero |
| 87 | if ($n <= 0) { |
| 88 | return 0.0; |
| 89 | } |
| 90 | |
| 91 | return sqrt($carry / $n); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Calculate arithmetic mean (average) |
| 96 | * |
| 97 | * @param array $a Array of numeric values |
| 98 | * @return float Arithmetic mean |
| 99 | */ |
| 100 | public static function arithmeticMean(array $a): float |
| 101 | { |
| 102 | if (count($a) === 0) { |
| 103 | return 0.0; |
| 104 | } |
| 105 | |
| 106 | return array_sum($a) / count($a); |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Calculate geometric mean |
| 111 | * |
| 112 | * @param array $a Array of numeric values |
| 113 | * @return float Geometric mean |
| 114 | */ |
| 115 | public static function geometricMean(array $a): float |
| 116 | { |
| 117 | $count = count($a); |
| 118 | |
| 119 | if ($count === 0) { |
| 120 | return 0.0; |
| 121 | } |
| 122 | |
| 123 | // Filter out zero and negative values as they're invalid for geometric mean |
| 124 | $filtered = array_filter($a, fn($n) => $n > 0); |
| 125 | |
| 126 | if (count($filtered) === 0) { |
| 127 | return 0.0; |
| 128 | } |
| 129 | |
| 130 | // Use logarithms to prevent overflow with large numbers |
| 131 | $logSum = 0.0; |
| 132 | foreach ($filtered as $n) { |
| 133 | $logSum += log($n); |
| 134 | } |
| 135 | |
| 136 | return exp($logSum / count($filtered)); |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Calculate harmonic mean |
| 141 | * |
| 142 | * @param array $a Array of numeric values |
| 143 | * @return float Harmonic mean |
| 144 | */ |
| 145 | public static function harmonicMean(array $a): float |
| 146 | { |
| 147 | $count = count($a); |
| 148 | |
| 149 | if ($count === 0) { |
| 150 | return 0.0; |
| 151 | } |
| 152 | |
| 153 | $sum = 0.0; |
| 154 | $validCount = 0; |
| 155 | |
| 156 | foreach ($a as $n) { |
| 157 | if ($n != 0) { |
| 158 | $sum += 1 / $n; |
| 159 | $validCount++; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | if ($sum == 0 || $validCount == 0) { |
| 164 | return 0.0; |
| 165 | } |
| 166 | |
| 167 | return $validCount / $sum; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Calculate median value |
| 172 | * |
| 173 | * @param array $a Array of numeric values |
| 174 | * @return float Median value |
| 175 | */ |
| 176 | public static function median(array $a): float |
| 177 | { |
| 178 | if (count($a) === 0) { |
| 179 | return 0.0; |
| 180 | } |
| 181 | |
| 182 | sort($a, SORT_NUMERIC); |
| 183 | $count = count($a); |
| 184 | $middle = floor($count / 2); |
| 185 | |
| 186 | // Odd number of elements |
| 187 | if ($count % 2) { |
| 188 | return (float) $a[$middle]; |
| 189 | } |
| 190 | |
| 191 | // Even number of elements - average the two middle values |
| 192 | return ($a[$middle] + $a[$middle - 1]) / 2; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Add buyer efficiency composite scores to employee array |
| 197 | * |
| 198 | * Calculates z-scores and percentiles for: |
| 199 | * - Total buys per day (20% weight) |
| 200 | * - Total containers/bins (30% weight) |
| 201 | * - Average process time (20% weight, inverted) |
| 202 | * - Average process time per container (30% weight, inverted) |
| 203 | * |
| 204 | * @param array $employeeArray Array of employee data with keys: totalBuys, totalContainers, avgProcessTime, avgPTC |
| 205 | * @return array Employee array with added efficiency scores |
| 206 | */ |
| 207 | public static function addBuyerEfficiencyScores(array $employeeArray): array |
| 208 | { |
| 209 | if (empty($employeeArray)) { |
| 210 | return $employeeArray; |
| 211 | } |
| 212 | |
| 213 | // Extract metric arrays |
| 214 | $buyTotalsArray = []; |
| 215 | $binsTotalsArray = []; |
| 216 | $processTimesArray = []; |
| 217 | $ptcArray = []; |
| 218 | |
| 219 | foreach ($employeeArray as $employee) { |
| 220 | $buyTotalsArray[] = $employee['totalBuys'] ?? 0; |
| 221 | $binsTotalsArray[] = $employee['totalContainers'] ?? 0; |
| 222 | $processTimesArray[] = $employee['avgProcessTime'] ?? 0; |
| 223 | $ptcArray[] = $employee['avgPTC'] ?? 0; |
| 224 | } |
| 225 | |
| 226 | // Calculate means |
| 227 | $allAvgBuysPerDay = self::harmonicMean($buyTotalsArray); |
| 228 | $allBinsTotals = self::harmonicMean($binsTotalsArray); |
| 229 | $allAvgPTC = self::arithmeticMean($ptcArray); |
| 230 | $allAvgProcessTimes = self::arithmeticMean($processTimesArray); |
| 231 | |
| 232 | // Calculate standard deviations |
| 233 | $avgBuysSD = self::standardDeviation($buyTotalsArray); |
| 234 | $avgBinsSD = self::standardDeviation($binsTotalsArray); |
| 235 | $avgPTCSD = self::standardDeviation($ptcArray); |
| 236 | $avgProcessTimeSD = self::standardDeviation($processTimesArray); |
| 237 | |
| 238 | // Prevent division by zero in z-score calculations |
| 239 | $avgBuysSD = $avgBuysSD ?: 1; |
| 240 | $avgBinsSD = $avgBinsSD ?: 1; |
| 241 | $avgPTCSD = $avgPTCSD ?: 1; |
| 242 | $avgProcessTimeSD = $avgProcessTimeSD ?: 1; |
| 243 | |
| 244 | // Calculate z-scores and composite scores for each employee |
| 245 | foreach ($employeeArray as $key => $employee) { |
| 246 | $totalBuys = (float) ($employee['totalBuys'] ?? 0); |
| 247 | $totalContainers = (float) ($employee['totalContainers'] ?? 0); |
| 248 | $avgProcessTime = (float) ($employee['avgProcessTime'] ?? 0); |
| 249 | $avgPTC = (float) ($employee['avgPTC'] ?? 0); |
| 250 | |
| 251 | // Calculate z-scores |
| 252 | $buysPerDayZScore = ($totalBuys - $allAvgBuysPerDay) / $avgBuysSD; |
| 253 | $binsTotalsZScore = ($totalContainers - $allBinsTotals) / $avgBinsSD; |
| 254 | $processTimeZScore = ($avgProcessTime - $allAvgProcessTimes) / $avgProcessTimeSD; |
| 255 | $ptcZScore = ($avgPTC - $allAvgPTC) / $avgPTCSD; |
| 256 | |
| 257 | // Convert z-scores to percentiles |
| 258 | $buysPercentile = self::cdf($buysPerDayZScore); |
| 259 | $binsPercentile = self::cdf($binsTotalsZScore); |
| 260 | $processTimesPercentile = self::cdf($processTimeZScore); |
| 261 | $ptcPercentile = self::cdf($ptcZScore); |
| 262 | |
| 263 | // Calculate composite score (lower process time and PTC are better, so invert) |
| 264 | $compositeScore = ( |
| 265 | (0.20 * $buysPercentile) + |
| 266 | (0.30 * $binsPercentile) + |
| 267 | (0.20 * (1 - $processTimesPercentile)) + |
| 268 | (0.30 * (1 - $ptcPercentile)) |
| 269 | ); |
| 270 | |
| 271 | $employee['compositeScore'] = $compositeScore; |
| 272 | $employee['buysScore'] = $buysPercentile; |
| 273 | $employee['binsScore'] = $binsPercentile; |
| 274 | $employee['processScore'] = 1 - $processTimesPercentile; |
| 275 | $employee['ptcScore'] = 1 - $ptcPercentile; |
| 276 | |
| 277 | $employeeArray[$key] = $employee; |
| 278 | } |
| 279 | |
| 280 | return $employeeArray; |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Add sorter efficiency composite scores to employee array |
| 285 | * |
| 286 | * Calculates z-scores and percentiles for: |
| 287 | * - Total sorts per day (20% weight) |
| 288 | * - Total containers sorted (30% weight) |
| 289 | * - Average sort time (20% weight, inverted) |
| 290 | * - Average sort time per container (30% weight, inverted) |
| 291 | * |
| 292 | * @param array $employeeArray Array of employee data with keys: totalSorts, totalContainers, avgSortTime, avgSTC |
| 293 | * @return array Employee array with added efficiency scores |
| 294 | */ |
| 295 | public static function addSorterEfficiencyScores(array $employeeArray): array |
| 296 | { |
| 297 | if (empty($employeeArray)) { |
| 298 | return $employeeArray; |
| 299 | } |
| 300 | |
| 301 | // Extract metric arrays |
| 302 | $sortTotalsArray = []; |
| 303 | $binsTotalsArray = []; |
| 304 | $sortTimesArray = []; |
| 305 | $stcArray = []; |
| 306 | |
| 307 | foreach ($employeeArray as $employee) { |
| 308 | $sortTotalsArray[] = $employee['totalSorts'] ?? 0; |
| 309 | $binsTotalsArray[] = $employee['totalContainers'] ?? 0; |
| 310 | $sortTimesArray[] = $employee['avgSortTime'] ?? 0; |
| 311 | $stcArray[] = $employee['avgSTC'] ?? 0; |
| 312 | } |
| 313 | |
| 314 | // Calculate means |
| 315 | $allAvgSortsPerDay = self::harmonicMean($sortTotalsArray); |
| 316 | $allBinsTotals = self::harmonicMean($binsTotalsArray); |
| 317 | $allAvgSTC = self::arithmeticMean($stcArray); |
| 318 | $allAvgSortTimes = self::arithmeticMean($sortTimesArray); |
| 319 | |
| 320 | // Calculate standard deviations |
| 321 | $avgSortsSD = self::standardDeviation($sortTotalsArray); |
| 322 | $avgBinsSD = self::standardDeviation($binsTotalsArray); |
| 323 | $avgSTCSD = self::standardDeviation($stcArray); |
| 324 | $avgSortTimeSD = self::standardDeviation($sortTimesArray); |
| 325 | |
| 326 | // Prevent division by zero in z-score calculations |
| 327 | $avgSortsSD = $avgSortsSD ?: 1; |
| 328 | $avgBinsSD = $avgBinsSD ?: 1; |
| 329 | $avgSTCSD = $avgSTCSD ?: 1; |
| 330 | $avgSortTimeSD = $avgSortTimeSD ?: 1; |
| 331 | |
| 332 | // Calculate z-scores and composite scores for each employee |
| 333 | foreach ($employeeArray as $key => $employee) { |
| 334 | $totalSorts = (float) ($employee['totalSorts'] ?? 0); |
| 335 | $totalContainers = (float) ($employee['totalContainers'] ?? 0); |
| 336 | $avgSortTime = (float) ($employee['avgSortTime'] ?? 0); |
| 337 | $avgSTC = (float) ($employee['avgSTC'] ?? 0); |
| 338 | |
| 339 | // Calculate z-scores |
| 340 | $sortsPerDayZScore = ($totalSorts - $allAvgSortsPerDay) / $avgSortsSD; |
| 341 | $binsTotalsZScore = ($totalContainers - $allBinsTotals) / $avgBinsSD; |
| 342 | $sortTimeZScore = ($avgSortTime - $allAvgSortTimes) / $avgSortTimeSD; |
| 343 | $stcZScore = ($avgSTC - $allAvgSTC) / $avgSTCSD; |
| 344 | |
| 345 | // Convert z-scores to percentiles |
| 346 | $sortsPercentile = self::cdf($sortsPerDayZScore); |
| 347 | $binsPercentile = self::cdf($binsTotalsZScore); |
| 348 | $sortTimesPercentile = self::cdf($sortTimeZScore); |
| 349 | $stcPercentile = self::cdf($stcZScore); |
| 350 | |
| 351 | // Calculate composite score (lower sort time and STC are better, so invert) |
| 352 | $compositeScore = ( |
| 353 | (0.20 * $sortsPercentile) + |
| 354 | (0.30 * $binsPercentile) + |
| 355 | (0.20 * (1 - $sortTimesPercentile)) + |
| 356 | (0.30 * (1 - $stcPercentile)) |
| 357 | ); |
| 358 | |
| 359 | $employee['compositeScore'] = $compositeScore; |
| 360 | $employee['sortsScore'] = $sortsPercentile; |
| 361 | $employee['binsScore'] = $binsPercentile; |
| 362 | $employee['sortTimeScore'] = 1 - $sortTimesPercentile; |
| 363 | $employee['stcScore'] = 1 - $stcPercentile; |
| 364 | |
| 365 | $employeeArray[$key] = $employee; |
| 366 | } |
| 367 | |
| 368 | return $employeeArray; |
| 369 | } |
| 370 | } |