|
4 | 4 | from dataclasses import replace |
5 | 5 | from datetime import datetime, timedelta, timezone |
6 | 6 | from enum import Enum |
7 | | -from typing import Dict, List, Optional |
| 7 | +from typing import Dict, List, Optional, Union |
8 | 8 |
|
9 | 9 | from kloppy.domain import ( |
10 | 10 | BodyPart, |
|
39 | 39 | CarryResult, |
40 | 40 | ExpectedGoals, |
41 | 41 | PostShotExpectedGoals, |
| 42 | + Point3D, |
42 | 43 | ) |
43 | 44 | from kloppy.exceptions import DeserializationError, DeserializationWarning |
44 | 45 | from kloppy.utils import performance_logging |
@@ -110,24 +111,68 @@ def _flip_point(point: Point) -> Point: |
110 | 111 | return Point(x=100 - point.x, y=100 - point.y) |
111 | 112 |
|
112 | 113 |
|
113 | | -class ShotZoneResults(str, Enum): |
114 | | - GOAL_BOTTOM_LEFT = "glb" |
115 | | - GOAL_BOTTOM_RIGHT = "grb" |
116 | | - GOAL_BOTTOM_CENTER = "gb" |
117 | | - GOAL_CENTER_LEFT = "gl" |
118 | | - GOAL_CENTER = "gc" |
119 | | - GOAL_CENTER_RIGHT = "gr" |
120 | | - GOAL_TOP_LEFT = "glt" |
121 | | - GOAL_TOP_RIGHT = "grt" |
122 | | - GOAL_TOP_CENTER = "gt" |
123 | | - OUT_BOTTOM_RIGHT = "obr" |
124 | | - OUT_BOTTOM_LEFT = "olb" |
125 | | - OUT_RIGHT = "or" |
126 | | - OUT_LEFT = "ol" |
127 | | - OUT_LEFT_TOP = "olt" |
128 | | - OUT_TOP = "ot" |
129 | | - OUT_RIGHT_TOP = "ort" |
130 | | - BLOCKED = "bc" |
| 114 | +class ShotZoneResults(Enum): |
| 115 | + """ |
| 116 | + Wyscout does not provide end-coordinates of shots. Instead shots on goal |
| 117 | + are tagged with a zone. The zones and corresponding y and z-coordinates are depicted below. |
| 118 | +
|
| 119 | + (z-coordinate of zone or post) |
| 120 | +
|
| 121 | + olt | ot | otr 3.5 |
| 122 | + -------------------------------- |
| 123 | + ||=================|| 2.77 |
| 124 | + ------------------------------- |
| 125 | + || gtl | gt | gtr || 2 |
| 126 | + -------------------------------- |
| 127 | + ol || gl | gc | gr || or 1 |
| 128 | + -------------------------------- |
| 129 | + olb || glb | gb | gbr || orb 0 |
| 130 | +
|
| 131 | + 40 45 50 55 60 (y-coordinate of zone) |
| 132 | + 44.62 55.38 (y-coordinate of post) |
| 133 | +
|
| 134 | + Attributes: |
| 135 | + code (str): The string identifier of the shot result. |
| 136 | + point (Point3D): The predefined Point3D associated with the shot result. |
| 137 | + """ |
| 138 | + |
| 139 | + GOAL_BOTTOM_LEFT = ("glb", Point3D(100, 45, 0)) |
| 140 | + GOAL_BOTTOM_CENTER = ("gb", Point3D(100, 50, 0)) |
| 141 | + GOAL_BOTTOM_RIGHT = ("gbr", Point3D(100, 55, 0)) |
| 142 | + GOAL_CENTER_LEFT = ("gl", Point3D(100, 45, 1)) |
| 143 | + GOAL_CENTER = ("gc", Point3D(100, 50, 1)) |
| 144 | + GOAL_CENTER_RIGHT = ("gr", Point3D(100, 55, 1)) |
| 145 | + GOAL_TOP_LEFT = ("gtl", Point3D(100, 45, 2)) |
| 146 | + GOAL_TOP_CENTER = ("gt", Point3D(100, 50, 2)) |
| 147 | + GOAL_TOP_RIGHT = ("gtr", Point3D(100, 55, 2)) |
| 148 | + POST_BOTTOM_LEFT = ("plb", Point3D(100, 44.62, 0)) |
| 149 | + POST_BOTTOM_RIGHT = ("pbr", Point3D(100, 55.38, 0)) |
| 150 | + OUT_BOTTOM_LEFT = ("olb", Point3D(100, 40, 0)) |
| 151 | + OUT_BOTTOM_RIGHT = ("obr", Point3D(100, 60, 0)) |
| 152 | + POST_LEFT = ("pl", Point3D(100, 44.62, 1)) |
| 153 | + POST_RIGHT = ("pr", Point3D(100, 55.38, 1)) |
| 154 | + OUT_LEFT = ("ol", Point3D(100, 40, 1)) |
| 155 | + OUT_RIGHT = ("or", Point3D(100, 60, 1)) |
| 156 | + POST_TOP_LEFT = ("ptl", Point3D(100, 44.62, 2)) |
| 157 | + POST_TOP_RIGHT = ("ptr", Point3D(100, 55.38, 2)) |
| 158 | + OUT_TOP_LEFT = ("otl", Point3D(100, 40, 3.5)) |
| 159 | + OUT_TOP_RIGHT = ("otr", Point3D(100, 60, 3.5)) |
| 160 | + POST_TOP = ("pt", Point3D(100, 50, 2.77)) |
| 161 | + OUT_TOP = ("ot", Point3D(100, 50, 3.5)) |
| 162 | + BLOCKED = ("bc", None) |
| 163 | + |
| 164 | + def __init__(self, code: str, point: Point3D): |
| 165 | + self.code = code |
| 166 | + self.point = point |
| 167 | + |
| 168 | + @classmethod |
| 169 | + def point_from_code(cls, code: str) -> Optional[Point3D]: |
| 170 | + """Retrieves the enum member from a string code.""" |
| 171 | + for zone in cls: |
| 172 | + if zone.code == code: |
| 173 | + return zone.point |
| 174 | + |
| 175 | + warnings.warn(f"Invalid shot zone code: {code}") |
131 | 176 |
|
132 | 177 |
|
133 | 178 | def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: |
@@ -185,73 +230,18 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: |
185 | 230 |
|
186 | 231 |
|
187 | 232 | def _create_shot_result_coordinates(raw_event: Dict) -> Optional[Point]: |
188 | | - """Estimate the shot end location from the Wyscout tags. |
189 | | -
|
190 | | - Wyscout does not provide end-coordinates of shots. Instead shots on goal |
191 | | - are tagged with a zone. This function maps each of these zones to |
192 | | - a coordinate. The zones and corresponding y-coordinate are depicted below. |
193 | | -
|
194 | | -
|
195 | | - olt | ot | ort |
196 | | - -------------------------------- |
197 | | - ||=================|| |
198 | | - ------------------------------- |
199 | | - || glt | gt | grt || |
200 | | - -------------------------------- |
201 | | - ol || gl | gc | gr || or |
202 | | - -------------------------------- |
203 | | - olb || glb | gb | grb || orb |
204 | | -
|
205 | | - 40 45 50 55 60 (y-coordinate of zone) |
206 | | - 44.62 55.38 (y-coordiante of post) |
207 | | - """ |
208 | | - if ( |
209 | | - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_CENTER |
210 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER |
211 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_CENTER |
212 | | - ): |
213 | | - return Point(100.0, 50.0) |
214 | | - |
215 | | - if ( |
216 | | - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_RIGHT |
217 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER_RIGHT |
218 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_RIGHT |
219 | | - ): |
220 | | - return Point(100.0, 55.0) |
221 | | - |
222 | | - if ( |
223 | | - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_LEFT |
224 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER_LEFT |
225 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_LEFT |
226 | | - ): |
227 | | - return Point(100.0, 45.0) |
228 | | - |
229 | | - if raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_TOP: |
230 | | - return Point(100.0, 50.0) |
231 | | - |
232 | | - if ( |
233 | | - raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_RIGHT_TOP |
234 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_RIGHT |
235 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_BOTTOM_RIGHT |
236 | | - ): |
237 | | - return Point(100.0, 60.0) |
238 | | - |
239 | | - if ( |
240 | | - raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_LEFT_TOP |
241 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_LEFT |
242 | | - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_BOTTOM_LEFT |
243 | | - ): |
244 | | - return Point(100.0, 40.0) |
245 | | - |
246 | | - # If the shot is blocked, the start location is the best possible estimate |
247 | | - # for the shot's end location |
248 | | - if raw_event["shot"]["goalZone"] == ShotZoneResults.BLOCKED: |
| 233 | + """Estimate the shot end location from the Wyscout tags.""" |
| 234 | + if raw_event["shot"]["goalZone"] == ShotZoneResults.BLOCKED.code: |
249 | 235 | return Point( |
250 | 236 | x=float(raw_event["location"]["x"]), |
251 | 237 | y=float(raw_event["location"]["y"]), |
252 | 238 | ) |
253 | 239 |
|
254 | | - return None |
| 240 | + else: |
| 241 | + result_coordinates = ShotZoneResults.point_from_code( |
| 242 | + raw_event["shot"]["goalZone"] |
| 243 | + ) |
| 244 | + return result_coordinates |
255 | 245 |
|
256 | 246 |
|
257 | 247 | def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]: |
|
0 commit comments