Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 71 additions & 81 deletions kloppy/infra/serializers/event/wyscout/deserializer_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,6 +39,7 @@
CarryResult,
ExpectedGoals,
PostShotExpectedGoals,
Point3D,
)
from kloppy.exceptions import DeserializationError, DeserializationWarning
from kloppy.utils import performance_logging
Expand Down Expand Up @@ -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
Comment on lines +171 to +173
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can avoid the loop here:

Suggested change
for zone in cls:
if zone.code == code:
return zone.point
if code in cls.__members__:
return cls[code].point
warnings.warn(f"Invalid shot zone code: {code}")
return None

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

        if code in cls.__members__:
            return cls[code].point
        warnings.warn(f"Invalid shot zone code: {code}")
        return None

didn't work for me, so I kept it as is.


warnings.warn(f"Invalid shot zone code: {code}")


def _parse_team(raw_events, wyId: str, ground: Ground) -> Team:
Expand Down Expand Up @@ -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]:
Expand Down
7 changes: 5 additions & 2 deletions kloppy/tests/test_wyscout.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
SetPieceType,
ShotResult,
Time,
Point3D,
)


Expand Down Expand Up @@ -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)
Expand Down
Loading