msds-comms-plotter 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,313 @@
1
+ """Fetch and build FIFA World Cup 2022 player statistics from StatsBomb open data.
2
+
3
+ Source: https://github.com/statsbomb/open-data (free, event-level data).
4
+ FIFA World Cup 2022 -> competition_id=43, season_id=106 (64 matches).
5
+
6
+ This module uses **pandas** only (plus the standard library and ``requests``).
7
+ Raw JSON is cached under ``data/raw/`` and the processed per-player-per-match
8
+ table is written to ``data/processed/`` as Parquet (never CSV).
9
+
10
+ Run as a script::
11
+
12
+ python -m msds_comms_plotter.worldcup
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import pandas as pd
22
+ import requests
23
+
24
+ BASE = "https://raw.githubusercontent.com/statsbomb/open-data/master/data"
25
+ COMPETITION_ID = 43
26
+ SEASON_ID = 106
27
+
28
+ # Resolve project directories relative to this file (src/msds_comms_plotter/).
29
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
30
+ RAW_DIR = PROJECT_ROOT / "data" / "raw" / "statsbomb"
31
+ PROCESSED_DIR = PROJECT_ROOT / "data" / "processed"
32
+
33
+ SHOTS_ON_TARGET = {"Goal", "Saved", "Saved to Post"}
34
+ YELLOW = {"Yellow Card", "Second Yellow"}
35
+ RED = {"Red Card", "Second Yellow"}
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # Fetching (with a simple on-disk cache so we never re-download)
40
+ # --------------------------------------------------------------------------- #
41
+ def _get_json(url: str, cache_path: Path, session: requests.Session):
42
+ """Return parsed JSON for ``url``, caching the raw bytes at ``cache_path``."""
43
+ if cache_path.exists():
44
+ return json.loads(cache_path.read_text())
45
+ for attempt in range(4):
46
+ try:
47
+ resp = session.get(url, timeout=60)
48
+ resp.raise_for_status()
49
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
50
+ cache_path.write_bytes(resp.content)
51
+ return resp.json()
52
+ except requests.RequestException:
53
+ if attempt == 3:
54
+ raise
55
+ time.sleep(2 ** attempt)
56
+
57
+
58
+ def get_matches(session: requests.Session) -> list:
59
+ """Return the list of match metadata dicts for the 2022 World Cup."""
60
+ url = f"{BASE}/matches/{COMPETITION_ID}/{SEASON_ID}.json"
61
+ return _get_json(url, RAW_DIR / "matches.json", session)
62
+
63
+
64
+ def get_events(match_id: int, session: requests.Session) -> list:
65
+ url = f"{BASE}/events/{match_id}.json"
66
+ return _get_json(url, RAW_DIR / "events" / f"{match_id}.json", session)
67
+
68
+
69
+ def get_lineups(match_id: int, session: requests.Session) -> list:
70
+ url = f"{BASE}/lineups/{match_id}.json"
71
+ return _get_json(url, RAW_DIR / "lineups" / f"{match_id}.json", session)
72
+
73
+
74
+ # --------------------------------------------------------------------------- #
75
+ # Parsing helpers
76
+ # --------------------------------------------------------------------------- #
77
+ def _name(d, key):
78
+ """Safely pull a nested ``{key: {'name': ...}}`` value."""
79
+ v = d.get(key)
80
+ return v.get("name") if isinstance(v, dict) else None
81
+
82
+
83
+ def _blank_record():
84
+ return {
85
+ "minutes_played": 0,
86
+ "starter": False,
87
+ "position": None,
88
+ "goals": 0,
89
+ "penalty_goals": 0,
90
+ "own_goals": 0,
91
+ "assists": 0,
92
+ "key_passes": 0,
93
+ "shots": 0,
94
+ "shots_on_target": 0,
95
+ "xg": 0.0,
96
+ "passes": 0,
97
+ "passes_completed": 0,
98
+ "crosses": 0,
99
+ "dribbles": 0,
100
+ "dribbles_completed": 0,
101
+ "tackles": 0,
102
+ "interceptions": 0,
103
+ "blocks": 0,
104
+ "ball_recoveries": 0,
105
+ "clearances": 0,
106
+ "fouls_committed": 0,
107
+ "fouls_won": 0,
108
+ "yellow_cards": 0,
109
+ "red_cards": 0,
110
+ "free_kicks_taken": 0,
111
+ }
112
+
113
+
114
+ def _match_end_minute(events: list) -> int:
115
+ """Approximate the final whistle minute from the last recorded event.
116
+
117
+ Penalty shootouts (period 5) are excluded so they don't inflate minutes.
118
+ """
119
+ return max((e.get("minute", 0) for e in events if e.get("period") != 5),
120
+ default=90)
121
+
122
+
123
+ def build_match_stats(match: dict, events: list, lineups: list) -> pd.DataFrame:
124
+ """Build a per-player stats table for a single match."""
125
+ match_id = match["match_id"]
126
+ end_minute = _match_end_minute(events)
127
+
128
+ # Seed every player from the official lineups so bench players who never
129
+ # touched the ball still appear (with zeroed stats).
130
+ records: dict[int, dict] = {}
131
+ meta: dict[int, dict] = {}
132
+ for team in lineups:
133
+ team_name = team.get("team_name")
134
+ for pl in team.get("lineup", []):
135
+ pid = pl["player_id"]
136
+ records[pid] = _blank_record()
137
+ meta[pid] = {"player": pl.get("player_name"), "team": team_name}
138
+
139
+ def rec(ev):
140
+ pl = ev.get("player")
141
+ if not pl:
142
+ return None
143
+ pid = pl["id"]
144
+ if pid not in records: # safety net for anyone missing from lineups
145
+ records[pid] = _blank_record()
146
+ meta[pid] = {"player": pl.get("name"), "team": _name(ev, "team")}
147
+ return records[pid]
148
+
149
+ # Minutes: starters play from 0; subs/red cards adjust the window.
150
+ on_min: dict[int, int] = {}
151
+ off_min: dict[int, int] = {}
152
+
153
+ for ev in events:
154
+ # Skip penalty-shootout events (period 5): shootout goals are not
155
+ # counted as match goals in official statistics.
156
+ if ev.get("period") == 5:
157
+ continue
158
+
159
+ etype = _name(ev, "type")
160
+
161
+ if etype == "Starting XI":
162
+ for pl in ev.get("tactics", {}).get("lineup", []):
163
+ pid = pl["player"]["id"]
164
+ if pid in records:
165
+ records[pid]["starter"] = True
166
+ records[pid]["position"] = _name(pl, "position")
167
+ on_min[pid] = 0
168
+ continue
169
+
170
+ r = rec(ev)
171
+ if r is None:
172
+ continue
173
+ minute = ev.get("minute", 0)
174
+
175
+ if etype == "Substitution":
176
+ off_min[ev["player"]["id"]] = minute
177
+ repl = ev.get("substitution", {}).get("replacement")
178
+ if repl and repl["id"] in records:
179
+ on_min[repl["id"]] = minute
180
+ if records[repl["id"]]["position"] is None:
181
+ records[repl["id"]]["position"] = _name(ev, "position")
182
+
183
+ elif etype == "Shot":
184
+ shot = ev.get("shot", {})
185
+ r["shots"] += 1
186
+ r["xg"] += shot.get("statsbomb_xg", 0.0) or 0.0
187
+ outcome = _name(shot, "outcome")
188
+ is_pen = _name(shot, "type") == "Penalty"
189
+ if _name(shot, "type") == "Free Kick":
190
+ r["free_kicks_taken"] += 1
191
+ if outcome in SHOTS_ON_TARGET:
192
+ r["shots_on_target"] += 1
193
+ if outcome == "Goal":
194
+ r["goals"] += 1
195
+ if is_pen:
196
+ r["penalty_goals"] += 1
197
+
198
+ elif etype == "Pass":
199
+ p = ev.get("pass", {})
200
+ r["passes"] += 1
201
+ if p.get("outcome") is None: # StatsBomb omits outcome on completions
202
+ r["passes_completed"] += 1
203
+ if p.get("goal_assist"):
204
+ r["assists"] += 1
205
+ if p.get("shot_assist"):
206
+ r["key_passes"] += 1
207
+ if p.get("cross"):
208
+ r["crosses"] += 1
209
+ if _name(p, "type") == "Free Kick":
210
+ r["free_kicks_taken"] += 1
211
+
212
+ elif etype == "Dribble":
213
+ r["dribbles"] += 1
214
+ if _name(ev.get("dribble", {}), "outcome") == "Complete":
215
+ r["dribbles_completed"] += 1
216
+
217
+ elif etype == "Duel":
218
+ if _name(ev.get("duel", {}), "type") == "Tackle":
219
+ r["tackles"] += 1
220
+
221
+ elif etype == "Interception":
222
+ r["interceptions"] += 1
223
+ elif etype == "Block":
224
+ r["blocks"] += 1
225
+ elif etype == "Ball Recovery":
226
+ r["ball_recoveries"] += 1
227
+ elif etype == "Clearance":
228
+ r["clearances"] += 1
229
+
230
+ elif etype == "Foul Committed":
231
+ r["fouls_committed"] += 1
232
+ card = _name(ev.get("foul_committed", {}), "card")
233
+ if card in YELLOW:
234
+ r["yellow_cards"] += 1
235
+ if card in RED:
236
+ r["red_cards"] += 1
237
+ off_min[ev["player"]["id"]] = minute
238
+
239
+ elif etype == "Foul Won":
240
+ r["fouls_won"] += 1
241
+
242
+ elif etype == "Bad Behaviour":
243
+ card = _name(ev.get("bad_behaviour", {}), "card")
244
+ if card in YELLOW:
245
+ r["yellow_cards"] += 1
246
+ if card in RED:
247
+ r["red_cards"] += 1
248
+ off_min[ev["player"]["id"]] = minute
249
+
250
+ elif etype == "Own Goal Against":
251
+ r["own_goals"] += 1
252
+
253
+ # Finalise minutes played.
254
+ for pid, r in records.items():
255
+ if pid in on_min:
256
+ start = on_min[pid]
257
+ end = off_min.get(pid, end_minute)
258
+ r["minutes_played"] = max(0, end - start)
259
+
260
+ df = pd.DataFrame.from_dict(records, orient="index")
261
+ df.insert(0, "player_id", df.index)
262
+ df.insert(1, "player", [meta[p]["player"] for p in df.index])
263
+ df.insert(2, "team", [meta[p]["team"] for p in df.index])
264
+ df.reset_index(drop=True, inplace=True)
265
+
266
+ # Attach match context.
267
+ df["match_id"] = match_id
268
+ df["match_date"] = match.get("match_date")
269
+ df["stage"] = _name(match, "competition_stage")
270
+ df["home_team"] = (match.get("home_team") or {}).get("home_team_name")
271
+ df["away_team"] = (match.get("away_team") or {}).get("away_team_name")
272
+ df["home_score"] = match.get("home_score")
273
+ df["away_score"] = match.get("away_score")
274
+ return df
275
+
276
+
277
+ def build_all(limit: int | None = None) -> pd.DataFrame:
278
+ """Download every match and return the combined per-player-per-match table."""
279
+ session = requests.Session()
280
+ matches = get_matches(session)
281
+ matches.sort(key=lambda m: m.get("match_date", ""))
282
+ if limit:
283
+ matches = matches[:limit]
284
+
285
+ frames = []
286
+ for i, match in enumerate(matches, 1):
287
+ mid = match["match_id"]
288
+ print(f"[{i}/{len(matches)}] match {mid}: "
289
+ f"{(match.get('home_team') or {}).get('home_team_name')} vs "
290
+ f"{(match.get('away_team') or {}).get('away_team_name')}")
291
+ events = get_events(mid, session)
292
+ lineups = get_lineups(mid, session)
293
+ frames.append(build_match_stats(match, events, lineups))
294
+
295
+ df = pd.concat(frames, ignore_index=True)
296
+ df["pass_completion_pct"] = (
297
+ (df["passes_completed"] / df["passes"]).where(df["passes"] > 0) * 100
298
+ ).round(1)
299
+ return df
300
+
301
+
302
+ def main():
303
+ df = build_all()
304
+ PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
305
+ out = PROCESSED_DIR / "wc2022_player_match_stats.parquet"
306
+ df.to_parquet(out, index=False)
307
+ print(f"\nWrote {len(df):,} player-match rows to {out}")
308
+ print(f"Players: {df['player_id'].nunique()} Matches: {df['match_id'].nunique()}")
309
+ return df
310
+
311
+
312
+ if __name__ == "__main__":
313
+ main()