diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index c29cfd568..1c1c24463 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -4,7 +4,7 @@ from dataclasses import replace from datetime import datetime, timedelta, timezone from enum import Enum -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from kloppy.domain import ( BodyPart, @@ -39,6 +39,7 @@ CarryResult, ExpectedGoals, PostShotExpectedGoals, + Point3D, ) from kloppy.exceptions import DeserializationError, DeserializationWarning from kloppy.utils import performance_logging @@ -110,24 +111,68 @@ def _flip_point(point: Point) -> Point: return Point(x=100 - point.x, y=100 - point.y) -class ShotZoneResults(str, Enum): - GOAL_BOTTOM_LEFT = "glb" - GOAL_BOTTOM_RIGHT = "grb" - GOAL_BOTTOM_CENTER = "gb" - GOAL_CENTER_LEFT = "gl" - GOAL_CENTER = "gc" - GOAL_CENTER_RIGHT = "gr" - GOAL_TOP_LEFT = "glt" - GOAL_TOP_RIGHT = "grt" - GOAL_TOP_CENTER = "gt" - OUT_BOTTOM_RIGHT = "obr" - OUT_BOTTOM_LEFT = "olb" - OUT_RIGHT = "or" - OUT_LEFT = "ol" - OUT_LEFT_TOP = "olt" - OUT_TOP = "ot" - OUT_RIGHT_TOP = "ort" - BLOCKED = "bc" +class ShotZoneResults(Enum): + """ + Wyscout does not provide end-coordinates of shots. Instead shots on goal + are tagged with a zone. The zones and corresponding y and z-coordinates are depicted below. + + (z-coordinate of zone or post) + + olt | ot | otr 3.5 + -------------------------------- + ||=================|| 2.77 + ------------------------------- + || gtl | gt | gtr || 2 + -------------------------------- + ol || gl | gc | gr || or 1 + -------------------------------- + olb || glb | gb | gbr || orb 0 + + 40 45 50 55 60 (y-coordinate of zone) + 44.62 55.38 (y-coordinate of post) + + Attributes: + code (str): The string identifier of the shot result. + point (Point3D): The predefined Point3D associated with the shot result. + """ + + GOAL_BOTTOM_LEFT = ("glb", Point3D(100, 45, 0)) + GOAL_BOTTOM_CENTER = ("gb", Point3D(100, 50, 0)) + GOAL_BOTTOM_RIGHT = ("gbr", Point3D(100, 55, 0)) + GOAL_CENTER_LEFT = ("gl", Point3D(100, 45, 1)) + GOAL_CENTER = ("gc", Point3D(100, 50, 1)) + GOAL_CENTER_RIGHT = ("gr", Point3D(100, 55, 1)) + GOAL_TOP_LEFT = ("gtl", Point3D(100, 45, 2)) + GOAL_TOP_CENTER = ("gt", Point3D(100, 50, 2)) + GOAL_TOP_RIGHT = ("gtr", Point3D(100, 55, 2)) + POST_BOTTOM_LEFT = ("plb", Point3D(100, 44.62, 0)) + POST_BOTTOM_RIGHT = ("pbr", Point3D(100, 55.38, 0)) + OUT_BOTTOM_LEFT = ("olb", Point3D(100, 40, 0)) + OUT_BOTTOM_RIGHT = ("obr", Point3D(100, 60, 0)) + POST_LEFT = ("pl", Point3D(100, 44.62, 1)) + POST_RIGHT = ("pr", Point3D(100, 55.38, 1)) + OUT_LEFT = ("ol", Point3D(100, 40, 1)) + OUT_RIGHT = ("or", Point3D(100, 60, 1)) + POST_TOP_LEFT = ("ptl", Point3D(100, 44.62, 2)) + POST_TOP_RIGHT = ("ptr", Point3D(100, 55.38, 2)) + OUT_TOP_LEFT = ("otl", Point3D(100, 40, 3.5)) + OUT_TOP_RIGHT = ("otr", Point3D(100, 60, 3.5)) + POST_TOP = ("pt", Point3D(100, 50, 2.77)) + OUT_TOP = ("ot", Point3D(100, 50, 3.5)) + BLOCKED = ("bc", None) + + def __init__(self, code: str, point: Point3D): + self.code = code + self.point = point + + @classmethod + def point_from_code(cls, code: str) -> Optional[Point3D]: + """Retrieves the enum member from a string code.""" + for zone in cls: + if zone.code == code: + return zone.point + + warnings.warn(f"Invalid shot zone code: {code}") def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: @@ -172,73 +217,18 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: def _create_shot_result_coordinates(raw_event: Dict) -> Optional[Point]: - """Estimate the shot end location from the Wyscout tags. - - Wyscout does not provide end-coordinates of shots. Instead shots on goal - are tagged with a zone. This function maps each of these zones to - a coordinate. The zones and corresponding y-coordinate are depicted below. - - - olt | ot | ort - -------------------------------- - ||=================|| - ------------------------------- - || glt | gt | grt || - -------------------------------- - ol || gl | gc | gr || or - -------------------------------- - olb || glb | gb | grb || orb - - 40 45 50 55 60 (y-coordinate of zone) - 44.62 55.38 (y-coordiante of post) - """ - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_CENTER - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_CENTER - ): - return Point(100.0, 50.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_RIGHT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER_RIGHT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_RIGHT - ): - return Point(100.0, 55.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_LEFT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER_LEFT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_LEFT - ): - return Point(100.0, 45.0) - - if raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_TOP: - return Point(100.0, 50.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_RIGHT_TOP - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_RIGHT - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_BOTTOM_RIGHT - ): - return Point(100.0, 60.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_LEFT_TOP - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_LEFT - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_BOTTOM_LEFT - ): - return Point(100.0, 40.0) - - # If the shot is blocked, the start location is the best possible estimate - # for the shot's end location - if raw_event["shot"]["goalZone"] == ShotZoneResults.BLOCKED: + """Estimate the shot end location from the Wyscout tags.""" + if raw_event["shot"]["goalZone"] == ShotZoneResults.BLOCKED.code: return Point( x=float(raw_event["location"]["x"]), y=float(raw_event["location"]["y"]), ) - return None + else: + result_coordinates = ShotZoneResults.point_from_code( + raw_event["shot"]["goalZone"] + ) + return result_coordinates def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]: diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index c1b752bf7..a0b65ff47 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -28,6 +28,7 @@ SetPieceType, ShotResult, Time, + Point3D, ) @@ -283,12 +284,14 @@ def test_shot_event(self, dataset: EventDataset): off_target_shot = dataset.get_event_by_id(1927028562) assert off_target_shot.event_type == EventType.SHOT assert off_target_shot.result == ShotResult.OFF_TARGET - assert off_target_shot.result_coordinates is None + assert off_target_shot.result_coordinates == Point3D( + x=100, y=40, z=3.5 + ) # on target shot on_target_shot = dataset.get_event_by_id(1927028637) assert on_target_shot.event_type == EventType.SHOT assert on_target_shot.result == ShotResult.SAVED - assert on_target_shot.result_coordinates == Point(100.0, 45.0) + assert on_target_shot.result_coordinates == Point3D(x=100, y=45, z=1) def test_foul_committed_event(self, dataset: EventDataset): foul_committed_event = dataset.get_event_by_id(1927028873)