Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
CRAP
100.00% covered (success)
100.00%
1 / 1
Glicko2
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
1 / 1
 rating
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Algorithm\Rating
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\Algorithm\Rating;
16
17use phpOMS\Math\Solver\Root\Bisection;
18
19/**
20 * Elo rating calculation using Glicko-2
21 *
22 * @package phpOMS\Algorithm\Rating
23 * @license OMS License 2.0
24 * @link    https://jingga.app
25 * @see     https://en.wikipedia.org/wiki/Glicko_rating_system
26 * @see     http://www.glicko.net/glicko/glicko2.pdf
27 * @since   1.0.0
28 *
29 * @todo: implement
30 */
31final class Glicko2
32{
33    /**
34     * Glicko scale factor
35     *
36     * @latex Q = 400 / ln(10)
37     *
38     * @var int
39     * @since 1.0.0
40     */
41    private const Q = 173.7177927613;
42
43    /**
44     * Constraint for the volatility over time (smaller = stronger constraint)
45     *
46     * @var float
47     * @since 1.0.0
48     */
49    public float $tau = 0.5;
50
51    /**
52     * Default elo to use for new players
53     *
54     * @var int
55     * @since 1.0.0
56     */
57    public int $DEFAULT_ELO = 1500;
58
59    /**
60     * Default rd to use for new players
61     *
62     * @var int
63     * @since 1.0.0
64     */
65    public int $DEFAULT_RD = 350;
66
67    /**
68     * Valatility (sigma)
69     *
70     * Expected flactuation = how erratic is the player's performance
71     *
72     * @var float
73     * @since 1.0.0
74     */
75    public float $DEFAULT_VOLATILITY = 0.06;
76
77    /**
78     * Lowest elo allowed
79     *
80     * @var int
81     * @since 1.0.0
82     */
83    public int $MIN_ELO = 100;
84
85    /**
86     * Lowest rd allowed
87     *
88     * @example 50 means that the player rating is probably between -100 / +100 of the current rating
89     *
90     * @var int
91     * @since 1.0.0
92     */
93    public int $MIN_RD = 50;
94
95    /**
96     * Calcualte the glicko-2 elo
97     *
98     * @example $glicko->elo(1500, 200, 0.06, [1,0,0], [1400,1550,1700], [30,100,300]) // 1464, 151, 0.059
99     *
100     * @param int     $elo    Current player "elo"
101     * @param int     $rdOld  Current player deviation (RD)
102     * @param float   $volOld Last match date used to calculate the time difference (can be days, months, ... depending on your match interval)
103     * @param int[]   $oElo   Opponent "elo"
104     * @param float[] $s      Match results (1 = victor, 0 = loss, 0.5 = draw)
105     * @param int[]   $oRd    Opponent deviation (RD)
106     *
107     * @return array{elo:int, rd:int, vol:float}
108     *
109     * @since 1.0.0
110     */
111    public function rating(
112        int $elo = 1500,
113        int $rdOld = 50,
114        float $volOld = 0.06,
115        array $oElo = [],
116        array $s = [],
117        array $oRd = []
118    ) : array
119    {
120        $tau = $this->tau;
121
122        // Step 0:
123        $rdOld /= self::Q;
124        $elo    = ($elo - $this->DEFAULT_ELO) / self::Q;
125
126        foreach ($oElo as $idx => $value) {
127            $oElo[$idx] = ($value - $this->DEFAULT_ELO) / self::Q;
128        }
129
130        foreach ($oRd as $idx => $value) {
131            $oRd[$idx] = $value / self::Q;
132        }
133
134        // Step 1:
135        $g = [];
136        foreach ($oRd as $idx => $rd) {
137            $g[$idx] = 1 / \sqrt(1 + 3 * $rd * $rd / (\M_PI * \M_PI));
138        }
139
140        $E = [];
141        foreach ($oElo as $idx => $oe) {
142            $E[$idx] = 1 / (1 + \exp(-$g[$idx] * ($elo - $oe)));
143        }
144
145        $v = 0;
146        foreach ($g as $idx => $t) {
147            $v += $t * $t * $E[$idx] * (1 - $E[$idx]);
148        }
149        $v = 1 / $v;
150
151        $tDelta = 0;
152        foreach ($g as $idx => $t) {
153            $tDelta += $t * ($s[$idx] - $E[$idx]);
154        }
155        $Delta = $v * $tDelta;
156
157        // Step 2:
158        $fn = function($x) use ($Delta, $rdOld, $v, $tau, $volOld)
159        {
160            return 0.5 * (\exp($x) * ($Delta ** 2 - $rdOld ** 2 - $v - \exp($x))) / (($rdOld ** 2 + $v + \exp($x)) ** 2)
161                - ($x - \log($volOld ** 2)) / ($tau ** 2);
162        };
163
164        $root = Bisection::root($fn, -100, 100, 1000);
165        $vol  = \exp($root / 2);
166
167        // Step 3:
168        $RD = 1 / \sqrt(1 / ($rdOld ** 2 + $vol ** 2) + 1 / $v);
169        $r  = $elo + $RD ** 2 * $tDelta;
170
171        // Undo step 0:
172        $RD = self::Q * $RD;
173        $r  = self::Q * $r + $this->DEFAULT_ELO;
174
175        return [
176            'elo' => (int) \max($r, $this->MIN_ELO),
177            'rd'  => (int) \max($RD, $this->MIN_RD),
178            'vol' => $vol,
179        ];
180    }
181}