Repository: PySport/kloppy

Issue:
[Stats Perform] Deduce receiving player information for Pass event 
Unlike other data providers, Stats Perform does not give information about the pass receiver in the raw data of their pass events.

We could, however, deduce the receiving player ID from the next event. If we consider the next event after a successful pass and use the ID of the player executing that event, if the next event is a ball owning event.

@koenvo do you think this is a good idea?

Hints from issue comments:
@DriesDeprest: Bump :-)@DriesDeprest: @probberechts what do you think about this?@probberechts: Yes, this is a good idea. If you can *reliably infer* who is the intendend recipient of a pass, you can impute the `receiver_player`. It probably requires a few more checks though (e.g., filter out deflected passes, passes interrupted by a foul, etc.).

Solution patch:
diff --git a/kloppy/infra/serializers/event/statsbomb/specification.py b/kloppy/infra/serializers/event/statsbomb/specification.py
index 32a6370..536e85f 100644
--- a/kloppy/infra/serializers/event/statsbomb/specification.py
+++ b/kloppy/infra/serializers/event/statsbomb/specification.py
@@ -1274,6 +1274,18 @@ class BALL_RECOVERY(EVENT):
     def _create_events(
         self, event_factory: EventFactory, **generic_event_kwargs
     ) -> List[Event]:
+        recovery_dict = self.raw_event.get("ball_recovery", {})
+        recovery_failure = recovery_dict.get("recovery_failure", False)
+        if recovery_failure:
+            duel_event = event_factory.build_duel(
+                result=DuelResult.LOST,
+                qualifiers=[
+                    DuelQualifier(value=DuelType.LOOSE_BALL),
+                ],
+                **generic_event_kwargs,
+            )
+            return [duel_event]
+
         recovery_event = event_factory.build_recovery(
             result=None,
             qualifiers=None,
diff --git a/kloppy/infra/serializers/event/statsperform/deserializer.py b/kloppy/infra/serializers/event/statsperform/deserializer.py
index 47d6b4a..b897ae3 100644
--- a/kloppy/infra/serializers/event/statsperform/deserializer.py
+++ b/kloppy/infra/serializers/event/statsperform/deserializer.py
@@ -1,49 +1,50 @@
-import math
-from typing import Dict, List, NamedTuple, IO, Optional
 import logging
+import math
 from datetime import datetime, timedelta
+from typing import IO, Dict, List, NamedTuple, Optional
 
 import pytz
 
 from kloppy.domain import (
-    EventDataset,
-    Team,
-    Point,
-    Point3D,
     BallState,
+    BodyPart,
+    BodyPartQualifier,
+    CardQualifier,
+    CardType,
+    CounterAttackQualifier,
     DatasetFlag,
-    Orientation,
-    PassResult,
-    ShotResult,
-    TakeOnResult,
+    DuelQualifier,
     DuelResult,
     DuelType,
-    DuelQualifier,
-    Provider,
-    Metadata,
+    EventDataset,
+    ExpectedGoals,
+    GoalkeeperActionType,
+    GoalkeeperQualifier,
     InterceptionResult,
-    CardType,
-    CardQualifier,
-    Qualifier,
-    SetPieceQualifier,
-    SetPieceType,
-    BodyPartQualifier,
-    BodyPart,
-    PassType,
+    Metadata,
+    Orientation,
     PassQualifier,
-    GoalkeeperQualifier,
-    GoalkeeperActionType,
-    CounterAttackQualifier,
+    PassResult,
+    PassType,
+    Period,
+    Point,
+    Point3D,
     PositionType,
-    ExpectedGoals,
     PostShotExpectedGoals,
+    Provider,
+    Qualifier,
+    SetPieceQualifier,
+    SetPieceType,
+    ShotResult,
+    TakeOnResult,
+    Team,
 )
 from kloppy.exceptions import DeserializationError
 from kloppy.infra.serializers.event.deserializer import EventDataDeserializer
 from kloppy.utils import performance_logging
 
-from .parsers import get_parser, OptaEvent
-from .formation_mapping import formation_position_mapping, formation_id_mapping
+from .formation_mapping import formation_id_mapping, formation_position_mapping
+from .parsers import OptaEvent, get_parser
 
 logger = logging.getLogger(__name__)
 
@@ -157,6 +158,8 @@ EVENT_QUALIFIER_FORMATION_PLAYER_POSITIONS = 131
 EVENT_QUALIFIER_XG = 321
 EVENT_QUALIFIER_POST_SHOT_XG = 322
 
+EVENT_TYPE_CHALLENGE = 45
+
 event_type_names = {
     1: "pass",
     2: "offside pass",
@@ -246,38 +249,71 @@ position_line_mapping = {
 
 
 def _parse_pass(
-    raw_event: OptaEvent, next_event: OptaEvent, next_next_event: OptaEvent
+    raw_event: OptaEvent,
+    next_event: OptaEvent,
+    next_next_event: OptaEvent,
+    team: Team,
+    period: Period,
 ) -> Dict:
-    if raw_event.outcome:
-        result = PassResult.COMPLETE
-    else:
-        result = PassResult.INCOMPLETE
+    result = (
+        PassResult.COMPLETE if raw_event.outcome else PassResult.INCOMPLETE
+    )
     receiver_coordinates = _get_end_coordinates(raw_event.qualifiers)
-    pass_qualifiers = _get_pass_qualifiers(raw_event.qualifiers)
-    overall_qualifiers = _get_event_qualifiers(raw_event.qualifiers)
+    qualifiers = _get_pass_qualifiers(
+        raw_event.qualifiers
+    ) + _get_event_qualifiers(raw_event.qualifiers)
 
-    qualifiers = pass_qualifiers + overall_qualifiers
+    # Look for a ball receipt event based on the result and sequence of events
+    ball_receipt_event = None
+    if result == PassResult.COMPLETE and next_event is not None:
+        if (
+            next_event.contestant_id == team.team_id
+            and next_event.type_id in BALL_OWNING_EVENTS
+        ):
+            ball_receipt_event = next_event
+        elif (
+            next_event.type_id == EVENT_TYPE_CHALLENGE
+            and next_next_event is not None
+            and next_next_event.contestant_id == team.team_id
+            and next_next_event.type_id in BALL_OWNING_EVENTS
+        ):
+            ball_receipt_event = next_next_event
+    # Assign the receiver_player and receive_timestamp
+    # if a ball_receipt_event was found
+    if (
+        ball_receipt_event is not None
+        and ball_receipt_event.player_id is not None
+    ):
+        receiver_player = team.get_player_by_id(ball_receipt_event.player_id)
+        receive_timestamp = (
+            ball_receipt_event.timestamp - period.start_timestamp
+        )
+    else:
+        receiver_player = None
+        receive_timestamp = None
 
     # Set the end location of a deflected pass to the start location
     # of the next action and the outcome to "success" if the deflected
     # pass reached a teammate
-    if next_event is not None and next_next_event is not None:
-        if (
-            next_event.type_id == EVENT_TYPE_BALL_TOUCH
-            and next_event.outcome == 1
-            and next_next_event.contestant_id == raw_event.contestant_id
-        ):
-            result = PassResult.COMPLETE
-            receiver_coordinates = Point(
-                x=next_next_event.x,
-                y=next_next_event.y,
-            )
+    if (
+        next_event is not None
+        and next_next_event is not None
+        and next_event.type_id == EVENT_TYPE_BALL_TOUCH
+        and next_event.outcome == 1
+        and next_next_event.contestant_id == raw_event.contestant_id
+    ):
+        result = PassResult.COMPLETE
+        receiver_coordinates = Point(
+            x=next_next_event.x,
+            y=next_next_event.y,
+        )
+        receive_timestamp = next_next_event.timestamp - period.start_timestamp
 
     return dict(
         result=result,
         receiver_coordinates=receiver_coordinates,
-        receiver_player=None,
-        receive_timestamp=None,
+        receiver_player=receiver_player,
+        receive_timestamp=receive_timestamp,
         qualifiers=qualifiers,
     )
 
@@ -785,7 +821,7 @@ class StatsPerformDeserializer(EventDataDeserializer[StatsPerformInputs]):
                         ball_owning_team=possession_team,
                         ball_state=ball_state,
                         # from Event
-                        event_id=raw_event.id,
+                        event_id=str(raw_event.id),
                         team=team,
                         player=player,
                         coordinates=Point(x=raw_event.x, y=raw_event.y),
@@ -794,7 +830,11 @@ class StatsPerformDeserializer(EventDataDeserializer[StatsPerformInputs]):
 
                     if raw_event.type_id == EVENT_TYPE_PASS:
                         pass_event_kwargs = _parse_pass(
-                            raw_event, next_event, next_next_event
+                            raw_event,
+                            next_event,
+                            next_next_event,
+                            team,
+                            period,
                         )
                         event = self.event_factory.build_pass(
                             **pass_event_kwargs,
diff --git a/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py b/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py
index 0b2383b..677ac72 100644
--- a/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py
+++ b/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py
@@ -175,8 +175,12 @@ class MA1JSONParser(OptaJSONParser):
                         "team_id": team_id,
                         "jersey_no": player["shirtNumber"],
                         "name": player["matchName"],
-                        "first_name": player["shortFirstName"],
-                        "last_name": player["shortLastName"],
+                        "first_name": player["shortFirstName"]
+                        if "shortFirstName" in player
+                        else player["firstName"],
+                        "last_name": player["shortLastName"]
+                        if "shortLastName" in player
+                        else player["lastName"],
                         "starting": starting,
                         "position": player_position,
                     }
diff --git a/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py b/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py
index f6e315e..9a2cc72 100644
--- a/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py
+++ b/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py
@@ -135,8 +135,12 @@ class MA1XMLParser(OptaXMLParser):
                         "team_id": team_id,
                         "jersey_no": int(player_attributes["shirtNumber"]),
                         "name": player_attributes["matchName"],
-                        "first_name": player_attributes["shortFirstName"],
-                        "last_name": player_attributes["shortLastName"],
+                        "first_name": player_attributes["shortFirstName"]
+                        if "shortFirstName" in player_attributes
+                        else player_attributes["firstName"],
+                        "last_name": player_attributes["shortLastName"]
+                        if "shortLastName" in player_attributes
+                        else player_attributes["lastName"],
                         "starting": starting,
                         "position": player_position,
                     }
diff --git a/kloppy/infra/serializers/tracking/secondspectrum.py b/kloppy/infra/serializers/tracking/secondspectrum.py
index 3cd2b43..f6a7b73 100644
--- a/kloppy/infra/serializers/tracking/secondspectrum.py
+++ b/kloppy/infra/serializers/tracking/secondspectrum.py
@@ -155,8 +155,8 @@ class SecondSpectrumDeserializer(
                     first_byte + inputs.meta_data.read()
                 ).match
                 frame_rate = int(match.attrib["iFrameRateFps"])
-                pitch_size_height = float(match.attrib["fPitchYSizeMeters"])
-                pitch_size_width = float(match.attrib["fPitchXSizeMeters"])
+                pitch_size_height = float(match.attrib["fPitchXSizeMeters"])
+                pitch_size_width = float(match.attrib["fPitchYSizeMeters"])
 
                 periods = []
                 for period in match.iterchildren(tag="period"):
@@ -243,7 +243,7 @@ class SecondSpectrumDeserializer(
         # Handles the tracking frame data
         with performance_logging("Loading data", logger=logger):
             transformer = self.get_transformer(
-                pitch_length=pitch_size_width, pitch_width=pitch_size_height
+                pitch_length=pitch_size_height, pitch_width=pitch_size_width
             )
 
             def _iter():


Test patch:
diff --git a/kloppy/tests/files/statsperform_tracking_ma1.json b/kloppy/tests/files/statsperform_tracking_ma1.json
index 79727d6..0a5c149 100644
--- a/kloppy/tests/files/statsperform_tracking_ma1.json
+++ b/kloppy/tests/files/statsperform_tracking_ma1.json
@@ -341,8 +341,6 @@
                         "playerId": "6wfwy94p5bm0zv3aku0urfq39",
                         "firstName": "Benjamin Pascal",
                         "lastName": "Lecomte",
-                        "shortFirstName": "Benjamin",
-                        "shortLastName": "Lecomte",
                         "matchName": "B. Lecomte",
                         "shirtNumber": 40,
                         "position": "Goalkeeper",
diff --git a/kloppy/tests/files/statsperform_tracking_ma1.xml b/kloppy/tests/files/statsperform_tracking_ma1.xml
index a8043c9..5912d45 100644
--- a/kloppy/tests/files/statsperform_tracking_ma1.xml
+++ b/kloppy/tests/files/statsperform_tracking_ma1.xml
@@ -47,7 +47,7 @@
         <substitute playerOnId="ckt75xb064etqh7aep6k5pfqh" playerOnName="W. Geubbels" playerOffId="72m5u1rlgh6e229zpfvp7f5jp" playerOffName="Gelson Martins" subReason="Tactical" contestantId="4t4hod56fsj7utpjdor8so5q6" periodId="2" timeMin="81" timeMinSec="80:05" lastUpdated="2023-06-07T19:26:19Z" timestamp="2020-08-23T12:41:27Z"/>
         <substitute playerOnId="88nw24pkg2ikj9c91tccvqol" playerOnName="J. Aholou" playerOffId="28x7i681wkjueanvkevew3bh1" playerOffName="A. Golovin" subReason="Tactical" contestantId="4t4hod56fsj7utpjdor8so5q6" periodId="2" timeMin="87" timeMinSec="86:51" lastUpdated="2023-03-28T07:34:02Z" timestamp="2020-08-23T12:48:14Z"/>
         <lineUp contestantId="4t4hod56fsj7utpjdor8so5q6" formationUsed="433">
-            <player playerId="6wfwy94p5bm0zv3aku0urfq39" firstName="Benjamin Pascal" lastName="Lecomte" shortFirstName="Benjamin" shortLastName="Lecomte" matchName="B. Lecomte" shirtNumber="40" position="Goalkeeper" positionSide="Centre" formationPlace="1"/>
+            <player playerId="6wfwy94p5bm0zv3aku0urfq39" firstName="Benjamin Pascal" lastName="Lecomte" matchName="B. Lecomte" shirtNumber="40" position="Goalkeeper" positionSide="Centre" formationPlace="1"/>
             <player playerId="dmrj53hcw8vcffxx0lfudbob9" firstName="Fodé" lastName="Ballo-Touré" shortFirstName="Fodé" shortLastName="Ballo-Touré" matchName="F. Ballo-Touré" shirtNumber="2" position="Defender" positionSide="Left" formationPlace="3"/>
             <player playerId="5g5wwp5luxo1rz1kp6chvz0x6" firstName="Benoît Ntambue" lastName="Badiashile Mukinayi Baya" shortFirstName="Benoît" shortLastName="Badiashile" matchName="B. Badiashile" shirtNumber="32" position="Defender" positionSide="Left/Centre" formationPlace="6"/>
             <player playerId="3djmsd2atvq16ysmocq7twsfd" firstName="Axel Wilson Arthur" lastName="Disasi Mhakinis Belho" shortFirstName="Axel" shortLastName="Disasi" matchName="A. Disasi" shirtNumber="20" position="Defender" positionSide="Centre/Right" formationPlace="5"/>
diff --git a/kloppy/tests/test_state_builder.py b/kloppy/tests/test_state_builder.py
index d27f619..10326d5 100644
--- a/kloppy/tests/test_state_builder.py
+++ b/kloppy/tests/test_state_builder.py
@@ -1,9 +1,9 @@
 from itertools import groupby
 
-from kloppy.domain import EventType, Event, EventDataset, FormationType
+from kloppy import statsbomb
+from kloppy.domain import Event, EventDataset, EventType, FormationType
 from kloppy.domain.services.state_builder.builder import StateBuilder
 from kloppy.utils import performance_logging
-from kloppy import statsbomb
 
 
 class TestStateBuilder:
@@ -51,7 +51,7 @@ class TestStateBuilder:
             events_per_sequence[sequence_id] = len(events)
 
         assert events_per_sequence[0] == 4
-        assert events_per_sequence[51] == 7
+        assert events_per_sequence[51] == 10
 
     def test_lineup_state_builder(self, base_dir):
         dataset = self._load_dataset(base_dir, base_filename="statsbomb_15986")
diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py
index 470a003..19f7938 100644
--- a/kloppy/tests/test_statsbomb.py
+++ b/kloppy/tests/test_statsbomb.py
@@ -957,8 +957,8 @@ class TestStatsBombDuelEvent:
         """It should deserialize all duel and 50/50 events"""
         events = dataset.find_all("duel")
         assert (
-            len(events) == 59 + 4 + 26
-        )  # duels + 50/50 + aerial won attribute
+            len(events) == 59 + 4 + 26 + 4
+        )  # duels + 50/50 + aerial won attribute + failed recoveries
 
     def test_attributes(self, dataset: EventDataset):
         """Verify specific attributes of duels"""
@@ -1171,10 +1171,21 @@ class TestStatsBombPlayerOnEvent:
 class TestStatsBombRecoveryEvent:
     """Tests related to deserializing 23/Recovery events"""
 
-    def test_deserialize_all(self, dataset: EventDataset):
-        """It should deserialize all ball recovery events"""
+    def test_deserialize_successful(self, dataset: EventDataset):
+        """It should deserialize all successful ball recovery events"""
         events = dataset.find_all("recovery")
-        assert len(events) == 97
+        assert len(events) == 93
+
+    def test_deserialize_failed(self, dataset: EventDataset):
+        """It should deserialize all failed ball recovery events as loose ball duels"""
+        failed_recovery = dataset.get_event_by_id(
+            "0df4c1d6-1c4a-407b-876d-d9ac80fd7eee"
+        )
+        assert failed_recovery.event_type == EventType.DUEL
+        assert failed_recovery.get_qualifier_values(DuelQualifier) == [
+            DuelType.LOOSE_BALL,
+        ]
+        assert failed_recovery.result == DuelResult.LOST
 
 
 class TestStatsBombTacticalShiftEvent:
diff --git a/kloppy/tests/test_statsperform.py b/kloppy/tests/test_statsperform.py
index 45ab3b7..38af05e 100644
--- a/kloppy/tests/test_statsperform.py
+++ b/kloppy/tests/test_statsperform.py
@@ -9,13 +9,14 @@ from kloppy.domain import (
     EventDataset,
     OptaCoordinateSystem,
     Orientation,
+    PassResult,
     Point,
     Point3D,
+    PositionType,
     Provider,
     SportVUCoordinateSystem,
-    TrackingDataset,
     Time,
-    PositionType,
+    TrackingDataset,
 )
 
 
@@ -95,6 +96,13 @@ class TestStatsPerformMetadata:
     def test_provider(self, tracking_dataset: TrackingDataset):
         assert tracking_dataset.metadata.provider == Provider.STATSPERFORM
 
+    def test_orientation(self, event_dataset: EventDataset):
+        assert event_dataset.metadata.coordinate_system == OptaCoordinateSystem(
+            # StatsPerform does not provide pitch dimensions
+            pitch_length=None,
+            pitch_width=None,
+        )
+
     def test_teams(self, tracking_dataset: TrackingDataset):
         home_team = tracking_dataset.metadata.teams[0]
         home_player = home_team.players[2]
@@ -124,6 +132,10 @@ class TestStatsPerformMetadata:
         assert not away_substitute.starting
         assert away_substitute.team == away_team
 
+        home_gk = home_team.get_player_by_id("6wfwy94p5bm0zv3aku0urfq39")
+        assert home_gk.first_name == "Benjamin Pascal"
+        assert home_gk.last_name == "Lecomte"
+
     def test_periods(self, tracking_dataset: TrackingDataset):
         assert len(tracking_dataset.metadata.periods) == 2
         assert tracking_dataset.metadata.periods[0].id == 1
@@ -172,11 +184,7 @@ class TestStatsPerformEvent:
 
     def test_deserialize_all(self, event_dataset: EventDataset):
         assert event_dataset.metadata.provider == Provider.STATSPERFORM
-        assert event_dataset.metadata.coordinate_system == OptaCoordinateSystem(
-            # StatsPerform does not provide pitch dimensions
-            pitch_length=None,
-            pitch_width=None,
-        )
+
         assert len(event_dataset.records) == 1643
 
         substitution_events = event_dataset.find_all("substitution")
@@ -198,6 +206,64 @@ class TestStatsPerformEvent:
         assert first_sub.player == m_wintzheimer
         assert first_sub.replacement_player == b_jatta
 
+    def test_pass_receiver(self, event_dataset: EventDataset):
+        """It should impute the intended receiver of a pass."""
+        # Completed passes should get a receiver
+        complete_pass = event_dataset.get_event_by_id("2328589789")
+        assert (
+            complete_pass.receiver_player.player_id
+            == "apdrig6xt1hxub1986s3uh1x"
+        )
+        assert (
+            complete_pass.receive_timestamp
+            == complete_pass.next_record.timestamp
+        )
+
+        # When a pass is challenged the receipt is the next next event
+        challenged_pass = event_dataset.get_event_by_id("2328600271")
+        assert (
+            challenged_pass.receiver_player.player_id
+            == "ci4pwzieoc94uj3i1371bsatx"
+        )
+        assert (
+            challenged_pass.receive_timestamp
+            == challenged_pass.next_record.next_record.timestamp
+        )
+
+        # Passes should be received within 30 seconds
+        assert all(
+            [
+                p.receive_timestamp.total_seconds()
+                - p.timestamp.total_seconds()
+                < 30
+                for p in event_dataset.find_all("pass")
+                if p.receive_timestamp is not None
+            ]
+        )
+
+        # Passes that result in a loss of possession should not have a receiver
+        turnover_passes = [
+            p
+            for p in event_dataset.find_all("pass")
+            if p.next_record
+            and p.ball_owning_team != p.next_record.ball_owning_team
+        ]
+        assert all(p.receiver_player is None for p in turnover_passes)
+
+        # Failed passes should not have a receiver
+        failed_pass = event_dataset.get_event_by_id("2328591011")
+        assert failed_pass.receiver_player is None
+        out_pass = event_dataset.get_event_by_id("2328590733")
+        assert out_pass.receiver_player is None
+
+        # When a pass is interrupted by a foul the receiver is not set
+        fouled_pass = event_dataset.get_event_by_id("2328589929")
+        assert fouled_pass.receiver_player is None
+
+        # Deflected passes should not have a receiver
+        deflected_pass = event_dataset.get_event_by_id("2328596237")
+        assert deflected_pass.receiver_player is None
+
 
 class TestStatsPerformTracking:
     """Tests related to deserializing tracking data delivered by StatsPerform."""