Add latency stuff, clean up a couple of PEP8 things.
[xonotic/xonstat.git] / xonstat / elo.py
1 import datetime
2 import logging
3 import math
4
5 from xonstat.models import PlayerElo
6
7 log = logging.getLogger(__name__)
8
9
10 class EloParms:
11     def __init__(self, global_K=15, initial=100, floor=100,
12                  logdistancefactor=math.log(10)/float(400), maxlogdistance=math.log(10),
13                  latencyfactor=0.2):
14         self.global_K = global_K
15         self.initial = initial
16         self.floor = floor
17         self.logdistancefactor = logdistancefactor
18         self.maxlogdistance = maxlogdistance
19         self.latencyfactor = latencyfactor
20
21
22 class KReduction:
23     def __init__(self, fulltime, mintime, minratio, games_min, games_max, games_factor):
24         self.fulltime = fulltime
25         self.mintime = mintime
26         self.minratio = minratio
27         self.games_min = games_min
28         self.games_max = games_max
29         self.games_factor = games_factor
30
31     def eval(self, mygames, mytime, matchtime):
32         if mytime < self.mintime:
33             return 0
34         if mytime < self.minratio * matchtime:
35             return 0
36         if mytime < self.fulltime:
37             k = mytime / float(self.fulltime)
38         else:
39             k = 1.0
40         if mygames >= self.games_max:
41             k *= self.games_factor
42         elif mygames > self.games_min:
43             k *= 1.0 - (1.0 - self.games_factor) * (mygames - self.games_min) / float(self.games_max - self.games_min)
44         return k
45
46
47 # parameters for K reduction
48 # this may be touched even if the DB already exists
49 KREDUCTION = KReduction(600, 120, 0.5, 0, 32, 0.2)
50
51 # parameters for chess elo
52 # only global_K may be touched even if the DB already exists
53 # we start at K=200, and fall to K=40 over the first 20 games
54 ELOPARMS = EloParms(global_K = 200)
55
56
57 class EloWIP:
58     """EloWIP is a work-in-progress Elo value. It contains all of the
59     attributes necessary to calculate Elo deltas for a given game."""
60     def __init__(self, player_id, pgstat=None):
61         # player_id this belongs to
62         self.player_id = player_id
63
64         # score per second in the game
65         self.score_per_second = 0.0
66
67         # seconds alive during a given game
68         self.alivetime = 0
69
70         # current elo record
71         self.elo = None
72
73         # current player_game_stat record
74         self.pgstat = pgstat
75
76         # Elo algorithm K-factor 
77         self.k = 0.0
78
79         # Elo points accumulator, which is not adjusted by the K-factor
80         self.adjustment = 0.0
81
82         # elo points delta accumulator for the game, which IS adjusted 
83         # by the K-factor
84         self.elo_delta = 0.0
85
86     def should_save(self):
87         """Determines if the elo and pgstat attributes of this instance should
88         be persisted to the database"""
89         return self.k > 0.0
90
91     def __repr__(self):
92         return "<EloWIP(player_id={}, score_per_second={}, alivetime={}, \
93                 elo={}, pgstat={}, k={}, adjustment={}, elo_delta={})>".\
94                 format(self.player_id, self.score_per_second, self.alivetime, \
95                 self.elo, self.pgstat, self.k, self.adjustment, self.elo_delta)
96
97
98 class EloProcessor:
99     """EloProcessor is a container for holding all of the intermediary AND
100     final values used to calculate Elo deltas for all players in a given
101     game."""
102     def __init__(self, session, game, pgstats):
103
104         # game which we are processing
105         self.game = game
106
107         # work-in-progress values, indexed by player
108         self.wip = {}
109
110         # used to determine if a pgstat record is elo-eligible
111         def elo_eligible(pgs):
112             return pgs.player_id > 2 and pgs.alivetime > datetime.timedelta(seconds=0)
113
114         elostats = filter(elo_eligible, pgstats)
115
116         # only process elos for elo-eligible players
117         for pgstat in elostats:
118             self.wip[pgstat.player_id] = EloWIP(pgstat.player_id, pgstat)
119
120         # determine duration from the maximum alivetime
121         # of the players if the game doesn't have one
122         self.duration = 0
123         if game.duration is not None:
124             self.duration = game.duration.seconds
125         else:
126             self.duration = max(i.alivetime.seconds for i in elostats)
127
128         # Calculate the score_per_second and alivetime values for each player.
129         # Warmups may mess up the player alivetime values, so this is a 
130         # failsafe to put the alivetime ceiling to be the game's duration.
131         for e in self.wip.values():
132             if e.pgstat.alivetime.seconds > self.duration:
133                 e.score_per_second = e.pgstat.score/float(self.duration)
134                 e.alivetime = self.duration
135             else:
136                 e.score_per_second = e.pgstat.score/float(e.pgstat.alivetime.seconds)
137                 e.alivetime = e.pgstat.alivetime.seconds
138
139         # Fetch current Elo values for all players. For players that don't yet 
140         # have an Elo record, we'll give them a default one.
141         for e in session.query(PlayerElo).\
142                 filter(PlayerElo.player_id.in_(self.wip.keys())).\
143                 filter(PlayerElo.game_type_cd==game.game_type_cd).all():
144                     self.wip[e.player_id].elo = e
145
146         for pid in self.wip.keys():
147             if self.wip[pid].elo is None:
148                 self.wip[pid].elo = PlayerElo(pid, game.game_type_cd, ELOPARMS.initial)
149
150             # determine k reduction
151             self.wip[pid].k = KREDUCTION.eval(self.wip[pid].elo.games, self.wip[pid].alivetime,
152                                               self.duration)
153
154         # we don't process the players who have a zero K factor
155         self.wip = {e.player_id:e for e in self.wip.values() if e.k > 0.0}
156
157         # now actually process elos
158         self.process()
159
160     def scorefactor(self, si, sj):
161         """Calculate the real scorefactor of the game. This is how players
162         actually performed, which is compared to their expected performance as
163         predicted by their Elo values."""
164         scorefactor_real = si / float(si + sj)
165
166         # duels are done traditionally - a win nets
167         # full points, not the score factor
168         if self.game.game_type_cd == 'duel':
169             # player i won
170             if scorefactor_real > 0.5:
171                 scorefactor_real = 1.0
172             # player j won
173             elif scorefactor_real < 0.5:
174                 scorefactor_real = 0.0
175             # nothing to do here for draws
176
177         return scorefactor_real
178
179     def pingfactor(self, pi, pj):
180         """ Calculate the ping differences between the two players, but only if both have them. """
181         if pi is None or pj is None or pi < 0 or pj < 0:
182             return None
183
184         else:
185             return float(pi)/(pi+pj)
186
187     def process(self):
188         """Perform the core Elo calculation, storing the values in the "wip"
189         dict for passing upstream."""
190         if len(self.wip.keys()) < 2:
191             return
192
193         ep = ELOPARMS
194
195         pids = self.wip.keys()
196         for i in xrange(0, len(pids)):
197             ei = self.wip[pids[i]].elo
198             for j in xrange(i+1, len(pids)):
199                 ej = self.wip[pids[j]].elo
200                 si = self.wip[pids[i]].score_per_second
201                 sj = self.wip[pids[j]].score_per_second
202
203                 # normalize scores
204                 ofs = min(0, si, sj)
205                 si -= ofs
206                 sj -= ofs
207                 if si + sj == 0:
208                     si, sj = 1, 1 # a draw
209
210                 # real score factor
211                 scorefactor_real = self.scorefactor(si, sj)
212
213                 # expected score factor by elo
214                 elodiff = min(ep.maxlogdistance, max(-ep.maxlogdistance,
215                     (float(ei.elo) - float(ej.elo)) * ep.logdistancefactor))
216                 scorefactor_elo = 1 / (1 + math.exp(-elodiff))
217
218                 # initial adjustment values, which we may modify with additional rules
219                 adjustmenti = scorefactor_real - scorefactor_elo
220                 adjustmentj = scorefactor_elo - scorefactor_real
221
222                 # DEBUG
223                 # log.debug("(New) Player i: {0}".format(ei.player_id))
224                 # log.debug("(New) Player i's K: {0}".format(self.wip[pids[i]].k))
225                 # log.debug("(New) Player j: {0}".format(ej.player_id))
226                 # log.debug("(New) Player j's K: {0}".format(self.wip[pids[j]].k))
227                 # log.debug("(New) Scorefactor real: {0}".format(scorefactor_real))
228                 # log.debug("(New) Scorefactor elo: {0}".format(scorefactor_elo))
229                 # log.debug("(New) adjustment i: {0}".format(adjustmenti))
230                 # log.debug("(New) adjustment j: {0}".format(adjustmentj))
231
232                 if scorefactor_elo > 0.5:
233                     # player i is expected to win
234                     if scorefactor_real > 0.5:
235                         # he DID win, so he should never lose points.
236                         adjustmenti = max(0, adjustmenti)
237                     else:
238                         # he lost, but let's make it continuous
239                         # (making him lose less points in the result)
240                         adjustmenti = (2 * scorefactor_real - 1) * scorefactor_elo
241                 else:
242                     # player j is expected to win
243                     if scorefactor_real > 0.5:
244                         # he lost, but let's make it continuous
245                         # (making him lose less points in the result)
246                         adjustmentj = (1 - 2 * scorefactor_real) * (1 - scorefactor_elo)
247                     else:
248                         # he DID win, so he should never lose points.
249                         adjustmentj = max(0, adjustmentj)
250
251                 self.wip[pids[i]].adjustment += adjustmenti
252                 self.wip[pids[j]].adjustment += adjustmentj
253
254         for pid in pids:
255             w = self.wip[pid]
256             old_elo = float(w.elo.elo)
257             new_elo = max(float(w.elo.elo) + w.adjustment * w.k * ep.global_K / float(len(pids) - 1), ep.floor)
258             w.elo_delta = new_elo - old_elo
259
260             w.elo.elo = new_elo
261             w.elo.games += 1
262             w.elo.update_dt = datetime.datetime.utcnow()
263
264     def save(self, session):
265         """Put all changed PlayerElo and PlayerGameStat instances into the
266         session to be updated or inserted upon commit."""
267         # first, save all of the player_elo values
268         for w in self.wip.values():
269             session.add(w.elo)
270
271             try:
272                 w.pgstat.elo_delta = w.elo_delta
273                 session.add(w.pgstat)
274             except:
275                 log.debug("Unable to save Elo delta value for player_id {0}".format(w.player_id))
276