From 8930e91e2ad3fcd60c06c2835713e63616dfe347 Mon Sep 17 00:00:00 2001 From: Pieter Robberechts Date: Sun, 3 Dec 2023 23:09:28 +0100 Subject: [PATCH 1/2] Fix shot end coordinates for Opta deserializer The end coordinates of blocked and saved shots were based on the projection of the shot on the goal mouth instead of the shot's actual end location. This commit uses the x and y coordinates of the location were the shots was blocked (qualifiers 146 and 147) and inversely projects the goalmouth z coordinate (qualifier 103) on the location were the shot was blocked. Fixes #244 --- .../serializers/event/opta/deserializer.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 460ebc2f..e200da5b 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -1,3 +1,4 @@ +import math from typing import Tuple, Dict, List, NamedTuple, IO, Optional import logging from datetime import datetime @@ -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( @@ -502,28 +505,43 @@ 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 and 103 in raw_qualifiers: x = float(100) y = float(raw_qualifiers[102]) - 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 From b6b523b2a2dc3a3e07843555ad98b8dda1e27bb5 Mon Sep 17 00:00:00 2001 From: Pieter Robberechts Date: Thu, 14 Dec 2023 16:26:27 +0100 Subject: [PATCH 2/2] Add test for opta shot end coordinates --- .../serializers/event/opta/deserializer.py | 5 +- kloppy/tests/test_opta.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index e200da5b..e453dc4e 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -532,10 +532,11 @@ def _get_end_coordinates( z = z_proj / adj_proj * adj_block # passed the goal line - elif 102 in raw_qualifiers and 103 in raw_qualifiers: + elif 102 in raw_qualifiers: x = float(100) y = float(raw_qualifiers[102]) - 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) diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 7f5cf50d..5890358f 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -1,3 +1,5 @@ +import math + import pytest from kloppy.domain import ( @@ -21,6 +23,7 @@ SetPieceQualifier, CounterAttackQualifier, BodyPartQualifier, + Point, Point3D, ) @@ -31,6 +34,9 @@ ) from kloppy import opta +from kloppy.infra.serializers.event.opta.deserializer import ( + _get_end_coordinates, +) class TestOpta: @@ -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,