Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 160
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatsCalculator
0.00% covered (danger)
0.00%
0 / 160
0.00% covered (danger)
0.00%
0 / 9
1806
0.00% covered (danger)
0.00%
0 / 1
 erf
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 cdf
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 standardDeviation
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 arithmeticMean
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 geometricMean
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 harmonicMean
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 median
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 addBuyerEfficiencyScores
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
72
 addSorterEfficiencyScores
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace 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 */
16class 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}