rustat-python-api 0.7.1__tar.gz → 0.7.3__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 (24) hide show
  1. {rustat-python-api-0.7.1/rustat_python_api.egg-info → rustat-python-api-0.7.3}/PKG-INFO +1 -1
  2. rustat-python-api-0.7.3/rustat_python_api/matching/__init__.py +9 -0
  3. rustat-python-api-0.7.3/rustat_python_api/matching/dataloader.py +87 -0
  4. rustat-python-api-0.7.3/rustat_python_api/matching/pc_adder.py +219 -0
  5. rustat-python-api-0.7.3/rustat_python_api/matching/tr_adder.py +354 -0
  6. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3/rustat_python_api.egg-info}/PKG-INFO +1 -1
  7. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api.egg-info/SOURCES.txt +5 -1
  8. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/setup.py +1 -1
  9. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/LICENSE +0 -0
  10. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/README.md +0 -0
  11. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/pyproject.toml +0 -0
  12. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/__init__.py +0 -0
  13. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/config.py +0 -0
  14. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/kernels/__init__.py +0 -0
  15. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/kernels/maha.py +0 -0
  16. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/models_api.py +0 -0
  17. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/parser.py +0 -0
  18. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/pitch_control.py +0 -0
  19. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/processing.py +0 -0
  20. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api/urls.py +0 -0
  21. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api.egg-info/dependency_links.txt +0 -0
  22. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api.egg-info/requires.txt +0 -0
  23. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/rustat_python_api.egg-info/top_level.txt +0 -0
  24. {rustat-python-api-0.7.1 → rustat-python-api-0.7.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rustat-python-api
3
- Version: 0.7.1
3
+ Version: 0.7.3
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,9 @@
1
+ from .dataloader import MatchInferLoader
2
+ from .pc_adder import PitchControlAdder
3
+ from .tr_adder import TrackingFeaturesAdder
4
+
5
+ __all__ = [
6
+ "PitchControlAdder",
7
+ "TrackingFeaturesAdder",
8
+ "MatchInferLoader"
9
+ ]
@@ -0,0 +1,87 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from ast import literal_eval
4
+
5
+ from .pc_adder import PitchControlAdder
6
+ from .tr_adder import TrackingFeaturesAdder
7
+
8
+
9
+ class MatchInferLoader:
10
+ def __init__(
11
+ self,
12
+ events: pd.DataFrame, tracking: pd.DataFrame, ball: pd.DataFrame,
13
+ modes: list[str], rads: list[int],
14
+ radii: list[int], cone_degrees: list[int], k_list: list[int],
15
+ device: str = "cpu", backend: str = "pt"
16
+ ):
17
+ self.events = events
18
+ self.tracking = tracking
19
+ self.ball = ball
20
+ self.modes = modes
21
+ self.rads = rads
22
+ self.radii = radii
23
+ self.cone_degrees = cone_degrees
24
+ self.k_list = k_list
25
+
26
+ self.device = device
27
+ self.backend = backend
28
+
29
+ def _save_index(self):
30
+ self.events['orig_index'] = self.events.index
31
+
32
+ def _process_events(self):
33
+ self.events = process_events_after_loading(self.events)
34
+
35
+ def _add_pc_features(self):
36
+ pc_adder = PitchControlAdder(
37
+ self.events, self.tracking, self.ball,
38
+ device=self.device, backend=self.backend
39
+ )
40
+ pc_adder.run(modes=self.modes, rads=self.rads)
41
+ self.events = pc_adder.events
42
+
43
+ def _add_tr_features(self):
44
+ tr_adder = TrackingFeaturesAdder(self.events, self.tracking, self.ball)
45
+ tr_adder.run(
46
+ radii=self.radii,
47
+ cone_degrees=self.cone_degrees,
48
+ k_list=self.k_list
49
+ )
50
+ self.events = tr_adder.events
51
+
52
+ def fit(self, inplace: bool = False):
53
+ self._process_events()
54
+ self._add_pc_features()
55
+ self._add_tr_features()
56
+
57
+ if not inplace:
58
+ return self.events
59
+
60
+ def get_tracking_columns(self, events: pd.DataFrame = None) -> list[str]:
61
+ if events is None:
62
+ events = self.events
63
+
64
+ pc_columns = [column for column in events.columns if column.startswith("pc_")]
65
+ tr_columns = [column for column in events.columns if column.startswith("tf_")]
66
+
67
+ return pc_columns + tr_columns
68
+
69
+
70
+ def process_events_after_loading(events: pd.DataFrame) -> pd.DataFrame:
71
+ events = events[events['half'].isin([1, 2])]
72
+
73
+ events[[
74
+ 'pos_dest_x', 'pos_dest_y'
75
+ ]] = events[[
76
+ 'pos_dest_x', 'pos_dest_y'
77
+ ]].apply(pd.to_numeric, errors='coerce')
78
+
79
+ events['sub_tags'] = events['sub_tags'].astype(str).apply(literal_eval)
80
+ events['tags'] = events['tags'].astype(str).apply(literal_eval)
81
+ events['possession_team_name'] = events['possession_team_name'].astype(str).apply(
82
+ lambda x: literal_eval(x) if isinstance(x, str) and '[' in x else x
83
+ )
84
+
85
+ events['team_id'] = events['team_id'].astype(np.int64)
86
+
87
+ return events
@@ -0,0 +1,219 @@
1
+ from rustat_python_api import PitchControl
2
+ import pandas as pd
3
+ import numpy as np
4
+
5
+
6
+ class PitchControlAdder:
7
+ def __init__(
8
+ self,
9
+ events: pd.DataFrame, tracking: pd.DataFrame, ball: pd.DataFrame,
10
+ device: str = 'cpu', backend: str = 'pt'
11
+ ):
12
+ self.events = events
13
+ self.tracking = tracking
14
+ self.ball = ball
15
+
16
+ self.device = device
17
+ self.backend = backend
18
+
19
+ self.pitch_control = PitchControl(tracking, events, ball)
20
+ self.sec2timestamp = self._get_sec2timestamp()
21
+
22
+ @staticmethod
23
+ def to_tracking_second(sec: float) -> float:
24
+ return float(np.round(np.rint(sec * 30.0) / 30.0, 2))
25
+
26
+ def _get_sec2timestamp(self):
27
+ return {
28
+ half: {val: i for i, val in enumerate(self.pitch_control.t[half].tolist())}
29
+ for half in self.pitch_control.t.keys()
30
+ }
31
+
32
+ def _get_pc(self, sec: float, half: int, dt: int = 100) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
33
+ timestamp = self.sec2timestamp[half][sec]
34
+ pc, xx, yy = self.pitch_control.fit(half=half, tp=timestamp, dt=dt, backend=self.backend, device=str(self.device))
35
+
36
+ return pc, xx, yy
37
+
38
+ @staticmethod
39
+ def _get_pc_value_nearest(
40
+ pc, xx, yy,
41
+ x: float, y: float
42
+ ) -> float:
43
+ d2 = (xx - x) ** 2 + (yy - y) ** 2
44
+ i, j = np.unravel_index(np.argmin(d2), pc.shape)
45
+
46
+ return float(pc[i, j])
47
+
48
+ def _get_pc_value_mean(
49
+ self,
50
+ pc, xx, yy,
51
+ x: float, y: float, rad: float
52
+ ) -> float:
53
+ if rad is None or rad <= 0:
54
+ return self._get_pc_value_nearest(pc, xx, yy, x, y)
55
+
56
+ d2 = (xx - x) ** 2 + (yy - y) ** 2
57
+ mask = d2 <= (rad * rad)
58
+
59
+ if not np.any(mask):
60
+ return self._get_pc_value_nearest(pc, xx, yy, x, y)
61
+
62
+ return float(pc[mask].mean())
63
+
64
+ def _get_pc_value_gaussian(
65
+ self,
66
+ pc, xx, yy,
67
+ x: float, y: float, sigma: float
68
+ ) -> float:
69
+ if sigma is None or sigma <= 0:
70
+ return self._get_pc_value_nearest(pc, xx, yy, x, y)
71
+
72
+ d2 = (xx - x) ** 2 + (yy - y) ** 2
73
+ w = np.exp(-d2 / (2.0 * sigma * sigma))
74
+ z = w.sum()
75
+
76
+ if z <= 0:
77
+ return self._get_pc_value_nearest(pc, xx, yy, x, y)
78
+
79
+ return float((pc * w).sum() / z)
80
+
81
+ def _get_pc_value(
82
+ self,
83
+ pc: np.ndarray, xx: np.ndarray, yy: np.ndarray,
84
+ x: float, y: float, rad: float, mode: str = 'mean'
85
+ ) -> float:
86
+ """
87
+ mode: one of 'mean', 'sum', 'mass', 'gaussian', 'nearest'
88
+ """
89
+ match mode:
90
+ case 'mean':
91
+ return self._get_pc_value_mean(pc, xx, yy, x, y, rad)
92
+ # case 'sum':
93
+ # return self._get_pc_value_sum(pc, xx, yy, x, y, rad)
94
+ # case 'mass':
95
+ # return self._get_pc_value_mass(pc, xx, yy, x, y, rad)
96
+ case 'gaussian':
97
+ return self._get_pc_value_gaussian(pc, xx, yy, x, y, rad)
98
+ case _:
99
+ return self._get_pc_value_nearest(pc, xx, yy, x, y)
100
+
101
+ def run(
102
+ self,
103
+ modes: list[str], rads: list[float], dt: int = 100
104
+ ):
105
+ assert len(modes) == len(rads)
106
+
107
+ self.events['sec_tracking'] = self.events['second'].map(self.to_tracking_second)
108
+
109
+ right_by_half = {
110
+ int(h): {
111
+ int(tid)
112
+ for tid, side in self.pitch_control.side_by_half.get(int(h), {}).items()
113
+ if side == 'right'
114
+ }
115
+ for h in self.pitch_control.side_by_half.keys()
116
+ }
117
+
118
+ # Preallocate outputs: for each (mode, rad) keep src and dest arrays
119
+ n = self.events.index.size
120
+ pcs_src = [np.full(n, np.nan, dtype=float) for _ in modes]
121
+ pcs_dst = [np.full(n, np.nan, dtype=float) for _ in modes]
122
+
123
+ # Vectorized flip mask: team plays to the right in this half
124
+ flip_mask = np.zeros(n, dtype=bool)
125
+ halves_arr = self.events['half'].to_numpy()
126
+ team_series = self.events['team_id']
127
+
128
+ for h, right_set in right_by_half.items():
129
+ if not right_set:
130
+ continue
131
+
132
+ idx_h = (halves_arr == h)
133
+ idx_right = team_series.isin(right_set).to_numpy()
134
+ flip_mask |= (idx_h & idx_right)
135
+
136
+ # Coordinates (can contain NaN)
137
+ x = self.events['pos_x'].to_numpy()
138
+ y = self.events['pos_y'].to_numpy()
139
+ xd = self.events['pos_dest_x'].to_numpy()
140
+ yd = self.events['pos_dest_y'].to_numpy()
141
+
142
+ # Flip coordinates where necessary
143
+ x = np.where(flip_mask, 105.0 - x, x)
144
+ y = np.where(flip_mask, 68.0 - y, y)
145
+ xd = np.where(flip_mask, 105.0 - xd, xd)
146
+ yd = np.where(flip_mask, 68.0 - yd, yd)
147
+
148
+ # Masks
149
+ src_nan = np.isnan(x) | np.isnan(y)
150
+ dest_nan = np.isnan(xd) | np.isnan(yd)
151
+ eq_eps = 1e-6
152
+ eq_mask = (~dest_nan) & np.isclose(xd, x, atol=eq_eps) & np.isclose(yd, y, atol=eq_eps)
153
+
154
+ # Cache (half, sec) -> (pc, xx, yy)
155
+ cache: dict[tuple[int, float], tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
156
+ secs = self.events['sec_tracking'].to_numpy()
157
+
158
+ # for i in tqdm(range(n)):
159
+ for i in range(n):
160
+ sec = float(secs[i])
161
+ half = int(halves_arr[i])
162
+
163
+ # Ensure second exists in mapping for this half
164
+ sec_map = self.sec2timestamp.get(half)
165
+ if not sec_map or sec not in sec_map:
166
+ print(f"[WARNING] Second {sec} not found for half {half}")
167
+ continue
168
+
169
+ key = (half, sec)
170
+ if key in cache:
171
+ pc, xx, yy = cache[key]
172
+ else:
173
+ try:
174
+ pc, xx, yy = self._get_pc(sec=sec, half=half, dt=dt)
175
+ except Exception:
176
+ print(f"[WARNING] Failed to get PC for half {half}, second {sec}")
177
+ pc, xx, yy = (None, None, None)
178
+ cache[key] = (pc, xx, yy)
179
+
180
+ if pc is None:
181
+ print(f"[WARNING] PC is None for half {half}, second {sec}")
182
+ continue
183
+
184
+ team = team_series.iloc[i]
185
+ is_right_start = team in right_by_half.get(1, set())
186
+ # if not is_right_start:
187
+ # pc = 1 - pc
188
+
189
+ # Source
190
+ if not src_nan[i]:
191
+ xi = float(x[i])
192
+ yi = float(y[i])
193
+ for k, (mode, rad) in enumerate(zip(modes, rads)):
194
+ pc_value = self._get_pc_value(pc, xx, yy, xi, yi, float(rad), mode=mode)
195
+
196
+ if not is_right_start:
197
+ pc_value = 1 - pc_value
198
+
199
+ pcs_src[k][i] = pc_value
200
+
201
+ # Destination
202
+ if eq_mask[i]:
203
+ # reuse source value when dest == src
204
+ for k in range(len(modes)):
205
+ pcs_dst[k][i] = pcs_src[k][i]
206
+ elif not dest_nan[i]:
207
+ xdi = float(xd[i]); ydi = float(yd[i])
208
+ for k, (mode, rad) in enumerate(zip(modes, rads)):
209
+ pc_value = self._get_pc_value(pc, xx, yy, xdi, ydi, float(rad), mode=mode)
210
+
211
+ if not is_right_start:
212
+ pc_value = 1 - pc_value
213
+
214
+ pcs_dst[k][i] = pc_value
215
+
216
+ # Write columns
217
+ for k, (mode, rad) in enumerate(zip(modes, rads)):
218
+ self.events[f'pc_{mode}_src_r{rad:g}'] = pcs_src[k]
219
+ self.events[f'pc_{mode}_dest_r{rad:g}'] = pcs_dst[k]
@@ -0,0 +1,354 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+
4
+
5
+ class TrackingFeaturesAdder:
6
+ def __init__(self, events: pd.DataFrame, tracking: pd.DataFrame, ball: pd.DataFrame):
7
+ self.events = events
8
+ self.tracking = tracking
9
+ self.ball = ball
10
+
11
+ # Normalize dtypes
12
+ if 'team_id' in self.tracking.columns:
13
+ self.tracking['team_id'] = self.tracking['team_id'].astype(str)
14
+ if 'team_id' in self.events.columns:
15
+ # keep original, but store string view for joins/masks
16
+ self._events_team_str = self.events['team_id'].astype(str)
17
+ else:
18
+ self._events_team_str = pd.Series(index=self.events.index, dtype=str)
19
+
20
+ # Cache team ids for side mapping
21
+ self.team_ids = list(self.tracking['team_id'].dropna().astype(str).unique())
22
+
23
+ # Build side_by_half using tracking side_1h
24
+ # sides: Series index team_id -> np.ndarray like ["left"] or ["right"]
25
+ sides = self.tracking.groupby('team_id')['side_1h'].unique()
26
+ # Align to our team_ids and take the first value
27
+ sides = sides.reindex(self.team_ids)
28
+ side_by_team = {
29
+ tid: (arr[0] if isinstance(arr, (list, np.ndarray)) and len(arr) > 0 else np.nan)
30
+ for tid, arr in sides.items()
31
+ }
32
+ # Half 2 sides are swapped
33
+ self.side_by_half = {
34
+ 1: side_by_team,
35
+ 2: {
36
+ tid: ('left' if side == 'right' else 'right') if isinstance(side, str) else np.nan
37
+ for tid, side in side_by_team.items()
38
+ }
39
+ }
40
+
41
+ @staticmethod
42
+ def to_tracking_second(sec: float) -> float:
43
+ return float(np.round(np.rint(sec * 30.0) / 30.0, 2))
44
+
45
+ def _build_frames(self) -> dict:
46
+ """
47
+ Build dictionary: (half, sec_tracking) -> dict with arrays: x, y, team_id(str), player_id
48
+ """
49
+ tr = self.tracking.copy()
50
+ # Ensure numeric positions
51
+ tr['pos_x'] = pd.to_numeric(tr['pos_x'], errors='coerce')
52
+ tr['pos_y'] = pd.to_numeric(tr['pos_y'], errors='coerce')
53
+ tr['sec_tracking'] = tr['second'].apply(self.to_tracking_second)
54
+
55
+ frames = {}
56
+ cols_needed = ['team_id', 'pos_x', 'pos_y']
57
+ if 'player_id' in tr.columns:
58
+ cols_needed.append('player_id')
59
+ else:
60
+ tr['player_id'] = -1
61
+ cols_needed.append('player_id')
62
+
63
+ for (half, sec), df in tr.groupby(['half', 'sec_tracking'], sort=False):
64
+ frames[(int(half), float(sec))] = {
65
+ 'team': df['team_id'].astype(str).to_numpy(),
66
+ 'x': df['pos_x'].to_numpy(dtype=float),
67
+ 'y': df['pos_y'].to_numpy(dtype=float),
68
+ 'pid': df['player_id'].to_numpy(),
69
+ }
70
+ return frames
71
+
72
+ @staticmethod
73
+ def _flip_coords(x: np.ndarray, y: np.ndarray, do_flip: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
74
+ # Flip in TV orientation box 105x68
75
+ xf = np.where(do_flip, 105.0 - x, x)
76
+ yf = np.where(do_flip, 68.0 - y, y)
77
+ return xf, yf
78
+
79
+ def run(self, radii: list[float], cone_degrees: list[float] | None = None, k_list: list[int] | None = None) -> None:
80
+ """
81
+ Compute tracking-based features for each event and write them into self.events.
82
+
83
+ Features (for pos and pos_dest, unless noted otherwise):
84
+ - counts of teammates/opponents with x <, x > current point (attack-aligned)
85
+ - counts of teammates/opponents within given radii
86
+ - pos only: x of second-back (closest to own goal excluding GK via 2nd) and farthest x
87
+ """
88
+ # Prepare events times aligned to tracking
89
+ self.events['sec_tracking'] = self.events['second'].map(self.to_tracking_second)
90
+
91
+ # Build right-side sets per half for flipping event coordinates
92
+ right_by_half = {
93
+ int(h): {tid for tid, side in sides.items() if side == 'right'}
94
+ for h, sides in self.side_by_half.items()
95
+ }
96
+
97
+ # Event coordinate arrays
98
+ n = self.events.index.size
99
+ halves_arr = self.events['half'].to_numpy()
100
+ teams_ev_str = self._events_team_str.to_numpy()
101
+ x = self.events['pos_x'].to_numpy()
102
+ y = self.events['pos_y'].to_numpy()
103
+ xd = self.events['pos_dest_x'].to_numpy()
104
+ yd = self.events['pos_dest_y'].to_numpy()
105
+
106
+ # Flip mask for events where the event team plays RIGHT in this half (align to TV orientation)
107
+ flip_mask = np.zeros(n, dtype=bool)
108
+ for h, right_set in right_by_half.items():
109
+ if not right_set:
110
+ continue
111
+ idx_h = (halves_arr == h)
112
+ idx_right = np.isin(teams_ev_str, list(right_set))
113
+ flip_mask |= (idx_h & idx_right)
114
+
115
+ x, y = self._flip_coords(x, y, flip_mask)
116
+ xd, yd = self._flip_coords(xd, yd, flip_mask)
117
+
118
+ # Masks
119
+ src_nan = np.isnan(x) | np.isnan(y)
120
+ dest_nan = np.isnan(xd) | np.isnan(yd)
121
+ eq_eps = 1e-6
122
+ eq_mask = (~dest_nan) & np.isclose(xd, x, atol=eq_eps) & np.isclose(yd, y, atol=eq_eps)
123
+
124
+ # Build tracking frames map
125
+ frames = self._build_frames()
126
+ secs = self.events['sec_tracking'].to_numpy()
127
+ pids = self.events['player_id'].to_numpy() if 'player_id' in self.events.columns else np.full(n, -1)
128
+
129
+ # Preallocate outputs
130
+ # Source basic counts
131
+ src_tm_lt_x = np.full(n, np.nan)
132
+ src_tm_gt_x = np.full(n, np.nan)
133
+ src_opp_lt_x = np.full(n, np.nan)
134
+ src_opp_gt_x = np.full(n, np.nan)
135
+
136
+ # Dest basic counts
137
+ dst_tm_lt_x = np.full(n, np.nan)
138
+ dst_tm_gt_x = np.full(n, np.nan)
139
+ dst_opp_lt_x = np.full(n, np.nan)
140
+ dst_opp_gt_x = np.full(n, np.nan)
141
+
142
+ # Radii counts: dict rad -> arrays
143
+ radii = list(radii or [])
144
+ src_tm_in_r = {rad: np.full(n, np.nan) for rad in radii}
145
+ src_opp_in_r = {rad: np.full(n, np.nan) for rad in radii}
146
+ dst_tm_in_r = {rad: np.full(n, np.nan) for rad in radii}
147
+ dst_opp_in_r = {rad: np.full(n, np.nan) for rad in radii}
148
+
149
+ # Cone counts by degrees
150
+ cone_degrees = list(cone_degrees or [])
151
+ src_tm_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
152
+ src_opp_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
153
+ dst_tm_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
154
+ dst_opp_cone = {deg: np.full(n, np.nan) for deg in cone_degrees}
155
+
156
+ # k-NN mean distances
157
+ k_list = list(k_list or [])
158
+ src_tm_mean_k = {k: np.full(n, np.nan) for k in k_list}
159
+ src_opp_mean_k = {k: np.full(n, np.nan) for k in k_list}
160
+ dst_tm_mean_k = {k: np.full(n, np.nan) for k in k_list}
161
+ dst_opp_mean_k = {k: np.full(n, np.nan) for k in k_list}
162
+
163
+ # Pos-only opponent extremes
164
+ src_opp_second_back_x = np.full(n, np.nan)
165
+ src_opp_farthest_x = np.full(n, np.nan)
166
+
167
+ for i in range(n):
168
+ half = int(halves_arr[i])
169
+ sec = float(secs[i])
170
+ team_i = teams_ev_str[i]
171
+ pid_i = pids[i]
172
+
173
+ frame = frames.get((half, sec))
174
+ if frame is None:
175
+ continue
176
+
177
+ teams_f = frame['team']
178
+ xs = frame['x']
179
+ ys = frame['y']
180
+ pids_f = frame['pid']
181
+
182
+ # Masks relative to event team
183
+ tm_mask = (teams_f == team_i)
184
+ opp_mask = ~tm_mask
185
+
186
+ # Source position features
187
+ if not src_nan[i]:
188
+ xi, yi = float(x[i]), float(y[i])
189
+
190
+ # Align x with attack direction: for right-side team, ahead is smaller TV-x
191
+ side_x = self.side_by_half.get(half, {}).get(team_i, np.nan)
192
+ sign = 1.0 if side_x == 'left' else (-1.0 if side_x == 'right' else 1.0)
193
+ src_tm_lt_x[i] = np.sum(tm_mask & ((sign * (xs - xi)) < 0))
194
+ src_tm_gt_x[i] = np.sum(tm_mask & ((sign * (xs - xi)) > 0))
195
+
196
+ src_opp_lt_x[i] = np.sum(opp_mask & ((sign * (xs - xi)) < 0))
197
+ src_opp_gt_x[i] = np.sum(opp_mask & ((sign * (xs - xi)) > 0))
198
+
199
+ if radii:
200
+ d2 = (xs - xi) ** 2 + (ys - yi) ** 2
201
+ not_self = (pids_f != pid_i)
202
+ for rad in radii:
203
+ mask_r = d2 <= (float(rad) * float(rad))
204
+ src_tm_in_r[rad][i] = np.sum(tm_mask & not_self & mask_r)
205
+ src_opp_in_r[rad][i] = np.sum(opp_mask & mask_r)
206
+
207
+ # Cones (pos): counts within +/- deg around forward-to-goal direction
208
+ if cone_degrees:
209
+ side = self.side_by_half.get(half, {}).get(team_i, np.nan)
210
+ if isinstance(side, str):
211
+ f_sign = 1.0 if side == 'left' else -1.0 # +x if left-side team, else -x
212
+ dx = xs - xi
213
+ dy = ys - yi
214
+ d = np.sqrt(dx * dx + dy * dy) + 1e-8
215
+ cosang = (f_sign * dx) / d # dot with unit forward vector
216
+ not_self = (pids_f != pid_i)
217
+ for deg in cone_degrees:
218
+ cth = np.cos(np.deg2rad(float(deg)))
219
+ in_cone = cosang >= cth
220
+ src_tm_cone[deg][i] = np.sum(in_cone & tm_mask & not_self)
221
+ src_opp_cone[deg][i] = np.sum(in_cone & opp_mask)
222
+
223
+ # k-NN mean distances (pos)
224
+ if k_list:
225
+ dx = xs - xi
226
+ dy = ys - yi
227
+ d = np.sqrt(dx * dx + dy * dy)
228
+ # teammates excluding self
229
+ mask_tm_valid = tm_mask & (pids_f != pid_i)
230
+ dist_tm = d[mask_tm_valid]
231
+ dist_opp = d[opp_mask]
232
+ dist_tm.sort()
233
+ dist_opp.sort()
234
+ for k in k_list:
235
+ if dist_tm.size > 0:
236
+ src_tm_mean_k[k][i] = float(np.mean(dist_tm[:min(k, dist_tm.size)]))
237
+ if dist_opp.size > 0:
238
+ src_opp_mean_k[k][i] = float(np.mean(dist_opp[:min(k, dist_opp.size)]))
239
+
240
+ # Opponent extremes (TV orientation)
241
+ side = self.side_by_half.get(half, {}).get(team_i, np.nan)
242
+ if isinstance(side, str):
243
+ # Extremes in TV orientation (left-to-right)
244
+ x_opp_tv = xs[opp_mask]
245
+ if x_opp_tv.size >= 2:
246
+ opp_side = 'left' if side == 'right' else 'right'
247
+ if opp_side == 'left':
248
+ x_sorted = np.sort(x_opp_tv)
249
+ src_opp_farthest_x[i] = float(np.max(x_opp_tv))
250
+ if x_sorted.size >= 2:
251
+ src_opp_second_back_x[i] = float(x_sorted[1]) # 2nd smallest
252
+ else: # opp_side == 'right'
253
+ x_sorted = np.sort(x_opp_tv)[::-1]
254
+ src_opp_farthest_x[i] = float(np.min(x_opp_tv))
255
+ if x_sorted.size >= 2:
256
+ src_opp_second_back_x[i] = float(x_sorted[1]) # 2nd largest
257
+
258
+ # Destination position features
259
+ if eq_mask[i]:
260
+ # copy from source if dest coincides with source
261
+ dst_tm_lt_x[i] = src_tm_lt_x[i]
262
+ dst_tm_gt_x[i] = src_tm_gt_x[i]
263
+ dst_opp_lt_x[i] = src_opp_lt_x[i]
264
+ dst_opp_gt_x[i] = src_opp_gt_x[i]
265
+ for rad in radii:
266
+ dst_tm_in_r[rad][i] = src_tm_in_r[rad][i]
267
+ dst_opp_in_r[rad][i] = src_opp_in_r[rad][i]
268
+ elif not dest_nan[i]:
269
+ xdi, ydi = float(xd[i]), float(yd[i])
270
+
271
+ side_x = self.side_by_half.get(half, {}).get(team_i, np.nan)
272
+ sign = 1.0 if side_x == 'left' else (-1.0 if side_x == 'right' else 1.0)
273
+ dst_tm_lt_x[i] = np.sum(tm_mask & ((sign * (xs - xdi)) < 0))
274
+ dst_tm_gt_x[i] = np.sum(tm_mask & ((sign * (xs - xdi)) > 0))
275
+
276
+ dst_opp_lt_x[i] = np.sum(opp_mask & ((sign * (xs - xdi)) < 0))
277
+ dst_opp_gt_x[i] = np.sum(opp_mask & ((sign * (xs - xdi)) > 0))
278
+
279
+ if radii:
280
+ d2d = (xs - xdi) ** 2 + (ys - ydi) ** 2
281
+ not_self = (pids_f != pid_i)
282
+ for rad in radii:
283
+ mask_rd = d2d <= (float(rad) * float(rad))
284
+ dst_tm_in_r[rad][i] = np.sum(tm_mask & not_self & mask_rd)
285
+ dst_opp_in_r[rad][i] = np.sum(opp_mask & mask_rd)
286
+
287
+ # Cones (dest)
288
+ if cone_degrees:
289
+ side = self.side_by_half.get(half, {}).get(team_i, np.nan)
290
+ if isinstance(side, str):
291
+ f_sign = 1.0 if side == 'left' else -1.0
292
+ dx = xs - xdi
293
+ dy = ys - ydi
294
+ d = np.sqrt(dx * dx + dy * dy) + 1e-8
295
+ cosang = (f_sign * dx) / d
296
+ not_self = (pids_f != pid_i)
297
+ for deg in cone_degrees:
298
+ cth = np.cos(np.deg2rad(float(deg)))
299
+ in_cone = cosang >= cth
300
+ dst_tm_cone[deg][i] = np.sum(in_cone & tm_mask & not_self)
301
+ dst_opp_cone[deg][i] = np.sum(in_cone & opp_mask)
302
+
303
+ # k-NN mean distances (dest)
304
+ if k_list:
305
+ dx = xs - xdi
306
+ dy = ys - ydi
307
+ d = np.sqrt(dx * dx + dy * dy)
308
+ mask_tm_valid = tm_mask & (pids_f != pid_i)
309
+ dist_tm = d[mask_tm_valid]
310
+ dist_opp = d[opp_mask]
311
+ dist_tm.sort()
312
+ dist_opp.sort()
313
+ for k in k_list:
314
+ if dist_tm.size > 0:
315
+ dst_tm_mean_k[k][i] = float(np.mean(dist_tm[:min(k, dist_tm.size)]))
316
+ if dist_opp.size > 0:
317
+ dst_opp_mean_k[k][i] = float(np.mean(dist_opp[:min(k, dist_opp.size)]))
318
+
319
+ # Write columns to events
320
+ self.events['tf_src_tm_lt_x'] = src_tm_lt_x
321
+ self.events['tf_src_tm_gt_x'] = src_tm_gt_x
322
+ self.events['tf_src_opp_lt_x'] = src_opp_lt_x
323
+ self.events['tf_src_opp_gt_x'] = src_opp_gt_x
324
+
325
+ self.events['tf_dest_tm_lt_x'] = dst_tm_lt_x
326
+ self.events['tf_dest_tm_gt_x'] = dst_tm_gt_x
327
+ self.events['tf_dest_opp_lt_x'] = dst_opp_lt_x
328
+ self.events['tf_dest_opp_gt_x'] = dst_opp_gt_x
329
+
330
+ for rad in radii:
331
+ r_tag = f"r{float(rad):g}"
332
+ self.events[f'tf_src_tm_in_{r_tag}'] = src_tm_in_r[rad]
333
+ self.events[f'tf_src_opp_in_{r_tag}'] = src_opp_in_r[rad]
334
+ self.events[f'tf_dest_tm_in_{r_tag}'] = dst_tm_in_r[rad]
335
+ self.events[f'tf_dest_opp_in_{r_tag}'] = dst_opp_in_r[rad]
336
+
337
+ # Cone features
338
+ for deg in cone_degrees:
339
+ d_tag = f"deg{float(deg):g}"
340
+ self.events[f'tf_src_tm_cone_{d_tag}'] = src_tm_cone[deg]
341
+ self.events[f'tf_src_opp_cone_{d_tag}'] = src_opp_cone[deg]
342
+ self.events[f'tf_dest_tm_cone_{d_tag}'] = dst_tm_cone[deg]
343
+ self.events[f'tf_dest_opp_cone_{d_tag}'] = dst_opp_cone[deg]
344
+
345
+ # k-NN mean distances
346
+ for k in k_list:
347
+ self.events[f'tf_src_tm_mean_dist_k{k}'] = src_tm_mean_k[k]
348
+ self.events[f'tf_src_opp_mean_dist_k{k}'] = src_opp_mean_k[k]
349
+ self.events[f'tf_dest_tm_mean_dist_k{k}'] = dst_tm_mean_k[k]
350
+ self.events[f'tf_dest_opp_mean_dist_k{k}'] = dst_opp_mean_k[k]
351
+
352
+ # Pos-only opponent extremes
353
+ self.events['tf_src_opp_second_back_x'] = src_opp_second_back_x
354
+ self.events['tf_src_opp_farthest_x'] = src_opp_farthest_x
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rustat-python-api
3
- Version: 0.7.1
3
+ Version: 0.7.3
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
@@ -15,4 +15,8 @@ rustat_python_api.egg-info/dependency_links.txt
15
15
  rustat_python_api.egg-info/requires.txt
16
16
  rustat_python_api.egg-info/top_level.txt
17
17
  rustat_python_api/kernels/__init__.py
18
- rustat_python_api/kernels/maha.py
18
+ rustat_python_api/kernels/maha.py
19
+ rustat_python_api/matching/__init__.py
20
+ rustat_python_api/matching/dataloader.py
21
+ rustat_python_api/matching/pc_adder.py
22
+ rustat_python_api/matching/tr_adder.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='rustat-python-api',
5
- version='0.7.1',
5
+ version='0.7.3',
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',