diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 87ba360b..8e2143aa 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -2,50 +2,42 @@ import logging from dataclasses import replace from datetime import timedelta, timezone +from enum import Enum +from typing import Dict, List, Optional + from dateutil.parser import parse -from typing import Dict, List from kloppy.domain import ( - BallOutEvent, BodyPart, BodyPartQualifier, - CardEvent, CardType, + CarryResult, CounterAttackQualifier, - Dimension, - DuelType, DuelQualifier, DuelResult, + DuelType, EventDataset, - FoulCommittedEvent, - GenericEvent, - GoalkeeperQualifier, + FormationType, GoalkeeperActionType, + GoalkeeperQualifier, Ground, InterceptionResult, Metadata, Orientation, - PassEvent, PassQualifier, PassResult, PassType, Period, - PitchDimensions, Player, Point, + PositionType, Provider, Qualifier, - RecoveryEvent, SetPieceQualifier, SetPieceType, - ShotEvent, ShotResult, - TakeOnEvent, TakeOnResult, Team, - FormationType, - CarryResult, - PositionType, ) from kloppy.exceptions import DeserializationError from kloppy.utils import performance_logging @@ -53,7 +45,6 @@ from ..deserializer import EventDataDeserializer from .deserializer_v2 import WyscoutInputs - logger = logging.getLogger(__name__) @@ -118,6 +109,26 @@ 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" + + def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: # Get the first formation description first_period_formation_info = raw_events["formations"][wyId]["1H"] @@ -159,6 +170,76 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: return 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: + return Point( + x=float(raw_event["location"]["x"]), + y=float(raw_event["location"]["y"]), + ) + + return None + + def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]: qualifiers: List[Qualifier] = [] @@ -191,10 +272,7 @@ def _parse_shot(raw_event: Dict) -> Dict: return { "result": result, - "result_coordinates": Point( - x=float(0), - y=float(0), - ), + "result_coordinates": _create_shot_result_coordinates(raw_event), "qualifiers": qualifiers, } @@ -677,9 +755,11 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: periods.append( Period( id=period_id, - start_timestamp=timedelta(seconds=0) - if len(periods) == 0 - else periods[-1].end_timestamp, + start_timestamp=( + timedelta(seconds=0) + if len(periods) == 0 + else periods[-1].end_timestamp + ), end_timestamp=None, ) ) @@ -703,16 +783,20 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: generic_event_args = { "event_id": raw_event["id"], "raw_event": raw_event, - "coordinates": Point( - x=float(raw_event["location"]["x"]), - y=float(raw_event["location"]["y"]), - ) - if raw_event["location"] - else None, + "coordinates": ( + Point( + x=float(raw_event["location"]["x"]), + y=float(raw_event["location"]["y"]), + ) + if raw_event["location"] + else None + ), "team": team, - "player": players[team_id][player_id] - if player_id != INVALID_PLAYER - else None, + "player": ( + players[team_id][player_id] + if player_id != INVALID_PLAYER + else None + ), "ball_owning_team": ball_owning_team, "ball_state": None, "period": periods[-1], diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index 5d28f64e..d5cb8b2f 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -2,32 +2,33 @@ from pathlib import Path import pytest + +from kloppy import wyscout from kloppy.domain import ( BodyPart, BodyPartQualifier, - Point, - EventDataset, - SetPieceType, - SetPieceQualifier, + CardQualifier, + CardType, DatasetType, DuelQualifier, DuelType, + EventDataset, EventType, - GoalkeeperQualifier, + FormationType, GoalkeeperActionType, - CardQualifier, - CardType, + GoalkeeperQualifier, Orientation, + PassQualifier, PassResult, - FormationType, - Time, PassType, - PassQualifier, + Point, PositionType, + SetPieceQualifier, + SetPieceType, + ShotResult, + Time, ) -from kloppy import wyscout - @pytest.fixture(scope="session") def event_v2_data(base_dir: Path) -> Path: @@ -268,12 +269,25 @@ def test_shot_assist_event(self, dataset: EventDataset): ) def test_shot_event(self, dataset: EventDataset): - shot_event = dataset.get_event_by_id(1927028534) - assert shot_event.event_type == EventType.SHOT + # a blocked free kick shot + blocked_shot_event = dataset.get_event_by_id(1927028534) + assert blocked_shot_event.event_type == EventType.SHOT + assert blocked_shot_event.result == ShotResult.BLOCKED + assert blocked_shot_event.result_coordinates == Point(x=77.0, y=21.0) assert ( - shot_event.get_qualifier_value(SetPieceQualifier) + blocked_shot_event.get_qualifier_value(SetPieceQualifier) == SetPieceType.FREE_KICK ) + # off target shot + 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 + # 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) def test_foul_committed_event(self, dataset: EventDataset): foul_committed_event = dataset.get_event_by_id(1927028873)