Skip to content

Commit 6e20b4d

Browse files
authored
feat(wyscout v3): add shot result z coordinates (#427)
1 parent 8f14c35 commit 6e20b4d

File tree

2 files changed

+76
-83
lines changed

2 files changed

+76
-83
lines changed

kloppy/infra/serializers/event/wyscout/deserializer_v3.py

Lines changed: 71 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import replace
55
from datetime import datetime, timedelta, timezone
66
from enum import Enum
7-
from typing import Dict, List, Optional
7+
from typing import Dict, List, Optional, Union
88

99
from kloppy.domain import (
1010
BodyPart,
@@ -39,6 +39,7 @@
3939
CarryResult,
4040
ExpectedGoals,
4141
PostShotExpectedGoals,
42+
Point3D,
4243
)
4344
from kloppy.exceptions import DeserializationError, DeserializationWarning
4445
from kloppy.utils import performance_logging
@@ -110,24 +111,68 @@ def _flip_point(point: Point) -> Point:
110111
return Point(x=100 - point.x, y=100 - point.y)
111112

112113

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}")
131176

132177

133178
def _parse_team(raw_events, wyId: str, ground: Ground) -> Team:
@@ -185,73 +230,18 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team:
185230

186231

187232
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:
249235
return Point(
250236
x=float(raw_event["location"]["x"]),
251237
y=float(raw_event["location"]["y"]),
252238
)
253239

254-
return None
240+
else:
241+
result_coordinates = ShotZoneResults.point_from_code(
242+
raw_event["shot"]["goalZone"]
243+
)
244+
return result_coordinates
255245

256246

257247
def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]:

kloppy/tests/test_wyscout.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
SetPieceType,
2929
ShotResult,
3030
Time,
31+
Point3D,
3132
)
3233

3334

@@ -284,12 +285,14 @@ def test_shot_event(self, dataset: EventDataset):
284285
off_target_shot = dataset.get_event_by_id(1927028562)
285286
assert off_target_shot.event_type == EventType.SHOT
286287
assert off_target_shot.result == ShotResult.OFF_TARGET
287-
assert off_target_shot.result_coordinates is None
288+
assert off_target_shot.result_coordinates == Point3D(
289+
x=100, y=40, z=3.5
290+
)
288291
# on target shot
289292
on_target_shot = dataset.get_event_by_id(1927028637)
290293
assert on_target_shot.event_type == EventType.SHOT
291294
assert on_target_shot.result == ShotResult.SAVED
292-
assert on_target_shot.result_coordinates == Point(100.0, 45.0)
295+
assert on_target_shot.result_coordinates == Point3D(x=100, y=45, z=1)
293296

294297
def test_foul_committed_event(self, dataset: EventDataset):
295298
foul_committed_event = dataset.get_event_by_id(1927028873)

0 commit comments

Comments
 (0)