rustat-python-api 0.4.12__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {rustat-python-api-0.4.12/rustat_python_api.egg-info → rustat-python-api-0.5.0}/PKG-INFO +1 -1
  2. rustat-python-api-0.5.0/rustat_python_api/__init__.py +5 -0
  3. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api/parser.py +5 -1
  4. rustat-python-api-0.5.0/rustat_python_api/pitch_control.py +310 -0
  5. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0/rustat_python_api.egg-info}/PKG-INFO +1 -1
  6. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api.egg-info/SOURCES.txt +1 -0
  7. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api.egg-info/requires.txt +3 -0
  8. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/setup.py +5 -2
  9. rustat-python-api-0.4.12/rustat_python_api/__init__.py +0 -4
  10. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/LICENSE +0 -0
  11. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/README.md +0 -0
  12. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/pyproject.toml +0 -0
  13. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api/config.py +0 -0
  14. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api/models_api.py +0 -0
  15. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api/processing.py +0 -0
  16. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api/urls.py +0 -0
  17. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api.egg-info/dependency_links.txt +0 -0
  18. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/rustat_python_api.egg-info/top_level.txt +0 -0
  19. {rustat-python-api-0.4.12 → rustat-python-api-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rustat-python-api
3
- Version: 0.4.12
3
+ Version: 0.5.0
4
4
  Summary: A Python wrapper for RuStat API
5
5
  Home-page: https://github.com/dailydaniel/rustat-python-api
6
6
  Author: Daniel Zholkovsky
@@ -0,0 +1,5 @@
1
+ from .parser import RuStatParser
2
+ from .models_api import DynamoLab
3
+ from .pitch_control import PitchControl
4
+
5
+ __all__ = ['RuStatParser', 'DynamoLab', 'PitchControl']
@@ -167,7 +167,11 @@ class RuStatParser:
167
167
 
168
168
  df = pd.concat([df, cur_df], ignore_index=True)
169
169
 
170
- return df.sort_values(by=["second", "team_id", "player_id"])
170
+ df = df.sort_values(by=['half', 'second', 'team_id', 'player_id']).reset_index(drop=True)
171
+ df['pos_x'] = df['pos_x'] + 105/2
172
+ df['second'] = df['second'].astype(int)
173
+
174
+ return df
171
175
 
172
176
  def get_match_stats(self, match_id: int) -> dict:
173
177
  data = self.resp2data(
@@ -0,0 +1,310 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from scipy.stats import multivariate_normal as mvn
4
+ import matplotlib.pyplot as plt
5
+ import matplotlib.animation as animation
6
+ import matplotsoccer as mpl
7
+
8
+
9
+ class PitchControl:
10
+ def __init__(self, tracking: pd.DataFrame, events: pd.DataFrame):
11
+ self.team_ids = tracking['team_id'].unique()
12
+ sides = tracking.groupby('team_id')['side_1h'].unique()
13
+ side_by_team = dict(zip(self.team_ids, sides[self.team_ids].apply(lambda x: x[0])))
14
+ self.side_by_half = {
15
+ 1: side_by_team,
16
+ 2:
17
+ {
18
+ team: 'left' if side == 'right' else 'right'
19
+ for team, side in side_by_team.items()
20
+ }
21
+ }
22
+
23
+ self.locs_home, self.locs_away, self.locs_ball, self.t = self.get_locs(
24
+ tracking,
25
+ events
26
+ )
27
+
28
+ def get_locs(self, tracking: pd.DataFrame, events: pd.DataFrame) -> tuple:
29
+ events = events[[
30
+ 'possession_number', 'team_id', 'possession_team_id',
31
+ 'half', 'second', 'pos_x', 'pos_y'
32
+ ]]
33
+
34
+ events.loc[:, 'pos_x'] = events.apply(
35
+ lambda x: self.swap_coords(x, 'x'), axis=1
36
+ )
37
+ events.loc[:, 'pos_y'] = events.apply(
38
+ lambda x: self.swap_coords(x, 'y'), axis=1
39
+ )
40
+
41
+ ball_data = self.interpolate_ball_data(
42
+ events[['half', 'second', 'pos_x', 'pos_y']],
43
+ tracking
44
+ )
45
+
46
+ locs_home = {
47
+ half: {
48
+ player_id: self.get_player_data(player_id, half=half)
49
+ for player_id in tracking[tracking['side_1h'] == 'left']['player_id'].unique()
50
+ }
51
+ for half in tracking['half'].unique()
52
+ }
53
+
54
+ locs_away = {
55
+ half: {
56
+ player_id: self.get_player_data(player_id, half=half)
57
+ for player_id in tracking[tracking['side_1h'] == 'right']['player_id'].unique()
58
+ }
59
+ for half in tracking['half'].unique()
60
+ }
61
+
62
+ locs_ball = {
63
+ half: ball_data[ball_data['half'] == half][['pos_x', 'pos_y']].values
64
+ for half in tracking['half'].unique()
65
+ }
66
+
67
+ t = {
68
+ half: ball_data[ball_data['half'] == 1]['second'].values
69
+ for half in tracking['half'].unique()
70
+ }
71
+
72
+ return locs_home, locs_away, locs_ball, t
73
+
74
+
75
+ def swap_coords(self, row, how: str = 'x'):
76
+ half = row['half']
77
+ team_id = row['team_id']
78
+ possession_team_id = row['possession_team_id']
79
+ x = row['pos_x']
80
+ y = row['pos_y']
81
+
82
+ if isinstance(possession_team_id, list):
83
+ current_side = 'left' if team_id in possession_team_id else 'right'
84
+ real_side = self.side_by_half[half][str(int(team_id))]
85
+ else:
86
+ current_side = 'left' if team_id == possession_team_id else 'right'
87
+ real_side = self.side_by_half[half][str(int(team_id))]
88
+
89
+ if current_side != real_side:
90
+ if how == 'x':
91
+ x = 105 - x
92
+ else:
93
+ y = 68 - y
94
+
95
+ return x if how == 'x' else y
96
+
97
+ @staticmethod
98
+ def interpolate_ball_data(
99
+ ball_data: pd.DataFrame,
100
+ player_data: pd.DataFrame
101
+ ) -> pd.DataFrame:
102
+ ball_data = ball_data.drop_duplicates(subset=['second', 'half'])
103
+
104
+ interpolated_data = []
105
+ for half in ball_data['half'].unique():
106
+ ball_half = ball_data[ball_data['half'] == half]
107
+ player_half = player_data[player_data['half'] == half]
108
+
109
+ player_times = player_half['second'].unique()
110
+
111
+ ball_half = ball_half.sort_values(by='second')
112
+ interpolated_half = pd.DataFrame({'second': player_times})
113
+ interpolated_half['pos_x'] = np.interp(
114
+ interpolated_half['second'], ball_half['second'], ball_half['pos_x']
115
+ )
116
+ interpolated_half['pos_y'] = np.interp(
117
+ interpolated_half['second'], ball_half['second'], ball_half['pos_y']
118
+ )
119
+ interpolated_half['half'] = half
120
+ interpolated_data.append(interpolated_half)
121
+
122
+ interpolated_ball_data = pd.concat(interpolated_data, ignore_index=True)
123
+ return interpolated_ball_data
124
+
125
+ @staticmethod
126
+ def get_player_data(player_id, half, tracking):
127
+ return tracking[
128
+ (tracking['player_id'] == player_id)
129
+ & (tracking['half'] == half)
130
+ ][['pos_x', 'pos_y']].values
131
+
132
+ def influence_function(
133
+ self,
134
+ player_index: str,
135
+ location: np.ndarray,
136
+ time_index: int,
137
+ home_or_away: str,
138
+ half: int
139
+ ):
140
+ if home_or_away == 'h':
141
+ data = self.locs_home[half].copy()
142
+ elif home_or_away == 'a':
143
+ data = self.locs_away[half].copy()
144
+ else:
145
+ raise ValueError("Enter either 'h' or 'a'.")
146
+
147
+ locs_ball = self.locs_ball[half].copy()
148
+ t = self.t[half].copy()
149
+
150
+ if (
151
+ np.all(np.isfinite(data[player_index][[time_index, time_index + 1], :]))
152
+ & np.all(np.isfinite(locs_ball[time_index, :]))
153
+ ):
154
+ jitter = 1e-10 ## to prevent identically zero covariance matrices when velocity is zero
155
+ ## compute velocity by fwd difference
156
+ s = (
157
+ np.linalg.norm(
158
+ data[player_index][time_index + 1,:]
159
+ - data[player_index][time_index,:] + jitter
160
+ )
161
+ / (t[time_index + 1] - t[time_index])
162
+ )
163
+ ## velocities in x,y directions
164
+ sxy = (
165
+ (data[player_index][time_index + 1, :] - data[player_index][time_index, :] + jitter)
166
+ / (t[time_index + 1] - t[time_index])
167
+ )
168
+ ## angle between velocity vector & x-axis
169
+ theta = np.arccos(sxy[0] / np.linalg.norm(sxy))
170
+ ## rotation matrix
171
+ R = np.array([[np.cos(theta), -np.sin(theta)],[np.sin(theta), np.cos(theta)]])
172
+ mu = data[player_index][time_index, :] + sxy * 0.5
173
+ Srat = (s / 13) ** 2
174
+ Ri = np.linalg.norm(locs_ball[time_index, :] - data[player_index][time_index, :])
175
+ ## don't think this function is specified in the paper but looks close enough to fig 9
176
+ Ri = np.minimum(4 + Ri ** 3/ (18 ** 3 / 6), 10)
177
+ S = np.array([[(1 + Srat) * Ri / 2, 0], [0, (1 - Srat) * Ri / 2]])
178
+ Sigma = np.matmul(R, S)
179
+ Sigma = np.matmul(Sigma, S)
180
+ Sigma = np.matmul(Sigma, np.linalg.inv(R)) ## this is not efficient, forgive me.
181
+ out = mvn.pdf(location, mu, Sigma) / mvn.pdf(data[player_index][time_index, :], mu, Sigma)
182
+ else:
183
+ print("Data is not finite.")
184
+ out = np.zeros(location.shape[0])
185
+ return out
186
+
187
+ def fit(self, half: int, tp: int, dt: int) -> tuple:
188
+ x = np.linspace(0, 105, dt)
189
+ y = np.linspace(0, 68, dt)
190
+ xx, yy = np.meshgrid(x, y)
191
+
192
+ Zh = np.zeros(dt*dt)
193
+ Za = np.zeros(dt*dt)
194
+
195
+ locations = np.c_[xx.flatten(),yy.flatten()]
196
+
197
+ for k in self.locs_home[half].keys():
198
+ if len(self.locs_home[half][k]) >= tp:
199
+ Zh += self.influence_function(k, locations, tp, 'h', half)
200
+ for k in self.locs_away[half].keys():
201
+ if len(self.locs_away[half][k]) >= tp:
202
+ Za += self.influence_function(k, locations, tp, 'a', half)
203
+
204
+ Zh = Zh.reshape((dt, dt))
205
+ Za = Za.reshape((dt, dt))
206
+ result = 1 / (1 + np.exp(-Za + Zh))
207
+
208
+ return result, xx, yy
209
+
210
+ def draw_pitch_control(
211
+ self,
212
+ half: int,
213
+ tp: int,
214
+ pitch_control: tuple = None,
215
+ save: bool = False,
216
+ dt: int = 200
217
+ ):
218
+ if pitch_control is None:
219
+ pitch_control, xx, yy = self.fit(half, tp, dt)
220
+
221
+ fig, ax = plt.subplots(figsize=(10.5, 6.8))
222
+ mpl.field("white", show=False, ax=ax)
223
+
224
+ plt.contourf(xx, yy, pitch_control)
225
+
226
+ for k in self.locs_home[half].keys():
227
+ if len(self.locs_home[half][k]) >= tp:
228
+ plt.scatter(
229
+ self.locs_home[half][k][tp, 0],
230
+ self.locs_home[half][k][tp, 1],
231
+ color='darkgrey'
232
+ )
233
+
234
+ for k in self.locs_away[half].keys():
235
+ if len(self.locs_away[half][k]) >= tp:
236
+ plt.scatter(
237
+ self.locs_away[half][k][tp, 0],
238
+ self.locs_away[half][k][tp, 1], color='black'
239
+ )
240
+
241
+ plt.scatter(
242
+ self.locs_ball[half][tp, 0],
243
+ self.locs_ball[half][tp, 1],
244
+ color='red'
245
+ )
246
+
247
+ if save:
248
+ plt.savefig('pitch_control.png', dpi=300)
249
+ else:
250
+ plt.show()
251
+
252
+ def animate_pitch_control(
253
+ self,
254
+ half: int,
255
+ tp: int,
256
+ filename: str,
257
+ dt: int = 200,
258
+ frames: int = 30,
259
+ interval: int = 1000
260
+ ):
261
+ """
262
+ ffmpeg should be installed on your machine.
263
+ """
264
+ fig, ax = plt.subplots(figsize=(10.5, 6.8))
265
+
266
+ def animate(i):
267
+ fr = tp + i
268
+ pitch_control, xx, yy = self.fit(half, tp, dt)
269
+
270
+ mpl.field("white", show=False, ax=ax)
271
+ ax.axis('off')
272
+
273
+ plt.contourf(xx, yy, pitch_control)
274
+
275
+ for k in self.locs_home[half].keys():
276
+ if len(self.locs_home[half][k]) >= fr:
277
+ plt.scatter(
278
+ self.locs_home[half][k][fr, 0],
279
+ self.locs_home[half][k][fr, 1],
280
+ color='darkgrey'
281
+ )
282
+ for k in self.locs_away[half].keys():
283
+ if len(self.locs_away[half][k]) >= fr:
284
+ plt.scatter(
285
+ self.locs_away[half][k][fr, 0],
286
+ self.locs_away[half][k][fr, 1],
287
+ color='black'
288
+ )
289
+
290
+ plt.scatter(
291
+ self.locs_ball[half][fr, 0],
292
+ self.locs_ball[half][fr, 1],
293
+ color='red'
294
+ )
295
+
296
+ return ax
297
+
298
+ x = np.linspace(0, 105, dt)
299
+ y = np.linspace(0, 68, dt)
300
+ xx, yy = np.meshgrid(x, y)
301
+
302
+ ani = animation.FuncAnimation(
303
+ fig=fig,
304
+ func=animate,
305
+ frames=min(frames, len(self.locs_ball[half]) - tp),
306
+ interval=interval,
307
+ blit=False
308
+ )
309
+
310
+ ani.save(f'{filename}.mp4', writer='ffmpeg')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rustat-python-api
3
- Version: 0.4.12
3
+ Version: 0.5.0
4
4
  Summary: A Python wrapper for RuStat API
5
5
  Home-page: https://github.com/dailydaniel/rustat-python-api
6
6
  Author: Daniel Zholkovsky
@@ -6,6 +6,7 @@ rustat_python_api/__init__.py
6
6
  rustat_python_api/config.py
7
7
  rustat_python_api/models_api.py
8
8
  rustat_python_api/parser.py
9
+ rustat_python_api/pitch_control.py
9
10
  rustat_python_api/processing.py
10
11
  rustat_python_api/urls.py
11
12
  rustat_python_api.egg-info/PKG-INFO
@@ -1,3 +1,6 @@
1
1
  requests==2.32.3
2
2
  pandas==2.2.3
3
3
  tqdm==4.66.5
4
+ scipy==1.14.1
5
+ matplotlib
6
+ matplotsoccer
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='rustat-python-api',
5
- version='0.4.12',
5
+ version='0.5.0',
6
6
  description='A Python wrapper for RuStat API',
7
7
  long_description=open('README.md').read(),
8
8
  long_description_content_type='text/markdown',
@@ -14,7 +14,10 @@ setup(
14
14
  install_requires=[
15
15
  'requests==2.32.3',
16
16
  'pandas==2.2.3',
17
- 'tqdm==4.66.5'
17
+ 'tqdm==4.66.5',
18
+ 'scipy==1.14.1',
19
+ 'matplotlib',
20
+ 'matplotsoccer'
18
21
  ],
19
22
  classifiers=[
20
23
  'Programming Language :: Python :: 3',
@@ -1,4 +0,0 @@
1
- from .parser import RuStatParser
2
- from .models_api import DynamoLab
3
-
4
- __all__ = ['RuStatParser', 'DynamoLab']