Skip to content

Commit

Permalink
Merge pull request #247 from probberechts/fix/opta-shot-result-coordi…
Browse files Browse the repository at this point in the history
…nates

Fix shot end coordinates for Opta deserializer
  • Loading branch information
koenvo authored Dec 26, 2023
2 parents a288d08 + b6b523b commit 8160d98
Show file tree
Hide file tree
Showing 2 changed files with 108 additions and 9 deletions.
37 changes: 28 additions & 9 deletions kloppy/infra/serializers/event/opta/deserializer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
from typing import Tuple, Dict, List, NamedTuple, IO, Optional
import logging
from datetime import datetime
Expand Down Expand Up @@ -355,7 +356,9 @@ def _parse_shot(
result = None

qualifiers = _get_event_qualifiers(raw_qualifiers)
result_coordinates = _get_end_coordinates(raw_qualifiers)
result_coordinates = _get_end_coordinates(
raw_qualifiers, start_coordinates=coordinates
)
if result == ShotResult.OWN_GOAL:
if isinstance(result_coordinates, Point3D):
result_coordinates = Point3D(
Expand Down Expand Up @@ -502,28 +505,44 @@ def _team_from_xml_elm(team_elm, f7_root) -> Team:
return team


def _get_end_coordinates(raw_qualifiers: Dict[int, str]) -> Optional[Point]:
def _get_end_coordinates(
raw_qualifiers: Dict[int, str], start_coordinates: Optional[Point] = None
) -> Optional[Point]:
x, y, z = None, None, None

# pass
if 140 in raw_qualifiers:
if 140 in raw_qualifiers and 141 in raw_qualifiers:
x = float(raw_qualifiers[140])
if 141 in raw_qualifiers:
y = float(raw_qualifiers[141])

# blocked shot
if 146 in raw_qualifiers:
elif 146 in raw_qualifiers and 147 in raw_qualifiers:
x = float(raw_qualifiers[146])
if 147 in raw_qualifiers:
y = float(raw_qualifiers[147])
if 102 in raw_qualifiers and 103 in raw_qualifiers:
# the goal mouth z-coordinate is projected back to the location
# where the shot was blocked
assert start_coordinates is not None
x0, y0 = start_coordinates.x, start_coordinates.y
x_proj = float(100)
y_proj = float(raw_qualifiers[102])
z_proj = float(raw_qualifiers[103])
adj_proj = math.sqrt((x_proj - x0) ** 2 + (y_proj - y0) ** 2)
adj_block = math.sqrt((x - x0) ** 2 + (y - y0) ** 2)
z = z_proj / adj_proj * adj_block

# passed the goal line
if 102 in raw_qualifiers:
elif 102 in raw_qualifiers:
x = float(100)
y = float(raw_qualifiers[102])
if 103 in raw_qualifiers:
z = float(raw_qualifiers[103])
if 103 in raw_qualifiers:
z = float(raw_qualifiers[103])

if x is not None and y is not None and z is not None:
return Point3D(x=x, y=y, z=z)
if x is not None and y is not None:
return Point(x=x, y=y)

return None


Expand Down
80 changes: 80 additions & 0 deletions kloppy/tests/test_opta.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import math

import pytest

from kloppy.domain import (
Expand All @@ -21,6 +23,7 @@
SetPieceQualifier,
CounterAttackQualifier,
BodyPartQualifier,
Point,
Point3D,
)

Expand All @@ -31,6 +34,9 @@
)

from kloppy import opta
from kloppy.infra.serializers.event.opta.deserializer import (
_get_end_coordinates,
)


class TestOpta:
Expand Down Expand Up @@ -190,6 +196,80 @@ def test_shot(self, f7_data: str, f24_data: str):
shot.get_qualifier_value(BodyPartQualifier) == BodyPart.LEFT_FOOT
)

def test_shot_end_coordinates(self):
"""Shots should receive the correct end coordinates."""
# When no end coordinates are available, we return None
assert _get_end_coordinates({}) is None

# When a shot was not blocked, the goalmouth coordinates should be used.
# The y- and z-coordinate are specified by qualifiers; the
# x-coordinate is 100.0 (i.e., the goal line)
shot_on_target_qualifiers = {
102: "52.1", # goal mouth y-coordinate
103: "18.4", # goal mouth z-coordinate
}
assert _get_end_coordinates(shot_on_target_qualifiers) == Point3D(
x=100.0, y=52.1, z=18.4
)

# When the z-coordinate is missing, we return 2D coordinates
incomplete_shot_qualifiers = {
102: "52.1", # goal mouth y-coordinate
}
assert _get_end_coordinates(incomplete_shot_qualifiers) == Point(
x=100, y=52.1
)

# When the y-coordinate is missing, we return None
incomplete_shot_qualifiers = {
103: "18.4", # goal mouth z-coordinate
}
assert _get_end_coordinates(incomplete_shot_qualifiers) is None

# When a shot is blocked, the end coordinates should correspond to the
# location where the shot was blocked.
blocked_shot_qualifiers = {
146: "99.1", # blocked x-coordiante
147: "52.5", # blocked y-coordinate
}
assert _get_end_coordinates(blocked_shot_qualifiers) == Point(
x=99.1, y=52.5
)

# When a shot was blocked and goal mouth locations are provided too,
# the z-coordinate of the goal mouth coordinates should be inversely
# projected on the location where the shot was blocked
blocked_shot_on_target_qualifiers = {
**shot_on_target_qualifiers,
**blocked_shot_qualifiers,
}
start_coordinates = Point(x=92.6, y=57.9)
# This requires some trigonometry. We can define two
# right-angle triangles:
# - a large triangle between the start coordinates and goal mouth
# coordinates.
# - an enclosed smaller triangle between the start coordinates and
# the location where the shot was blocked.
# We need to compute the length of the opposite side of the small
# triangle. Therefore, we compute:
# - the length of the adjacent side of the large triangle
adj_large = math.sqrt(
(100 - start_coordinates.x) ** 2
+ (52.1 - start_coordinates.y) ** 2
)
# - the length of the adjacent side of the small triangle
adj_small = math.sqrt(
(99.1 - start_coordinates.x) ** 2
+ (52.5 - start_coordinates.y) ** 2
)
# - the angle of the large triangle (== the angle of the small triangle)
alpha_large = math.atan2(18.4, adj_large)
# - the opposite side of the small triangle
opp_small = math.tan(alpha_large) * adj_small
assert _get_end_coordinates(
blocked_shot_on_target_qualifiers, start_coordinates
) == Point3D(x=99.1, y=52.5, z=opp_small)

def test_own_goal(self, f7_data: str, f24_data: str):
dataset = opta.load(
f24_data=f24_data,
Expand Down

0 comments on commit 8160d98

Please sign in to comment.