From da95d6a4e27f5a99b9b5a2ba1d256601d23ed843 Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Tue, 4 Jul 2023 09:07:16 +0200 Subject: [PATCH 1/7] Intermediate commit --- kloppy/domain/models/event.py | 91 ++++++++ kloppy/domain/services/event_factory.py | 4 + .../serializers/event/opta/deserializer.py | 42 +++- .../event/statsbomb/deserializer.py | 101 +++++++++ .../event/wyscout/deserializer_v3.py | 45 +++- kloppy/tests/files/opta_f24.xml | 6 +- kloppy/tests/files/statsbomb_event.json | 123 +++++++++- kloppy/tests/files/wyscout_events_v3.json | 210 ++++++++++++++++++ kloppy/tests/test_helpers.py | 2 +- kloppy/tests/test_opta.py | 7 + kloppy/tests/test_statsbomb.py | 28 ++- requirements.txt | 6 +- 12 files changed, 635 insertions(+), 30 deletions(-) diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index 542af5f8..35d02630 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -133,6 +133,26 @@ def is_success(self): return self == self.COMPLETE +class DuelResult(ResultType): + """ + DuelResult + + Attributes: + WON (DuelResult): When winning the duel (player touching the ball first) + LOST (DuelResult): When losing the duel (opponent touches the ball first) + """ + + WON = "WON" + LOST = "LOST" + + @property + def is_success(self): + """ + Returns if the duel was won + """ + return self == self.WON + + class CardType(Enum): """ CardType @@ -156,6 +176,7 @@ class EventType(Enum): SHOT (EventType): TAKE_ON (EventType): CARRY (EventType): + DUEL (EventType): SUBSTITUTION (EventType): CARD (EventType): PLAYER_ON (EventType): @@ -172,6 +193,7 @@ class EventType(Enum): SHOT = "SHOT" TAKE_ON = "TAKE_ON" CARRY = "CARRY" + DUEL = "DUEL" SUBSTITUTION = "SUBSTITUTION" CARD = "CARD" PLAYER_ON = "PLAYER_ON" @@ -354,6 +376,30 @@ class GoalkeeperActionQualifier(EnumQualifier): value: GoalkeeperAction +class DuelType(Enum): + """ + DuelType + + Attributes: + AERIAL (DuelType): A duel when the ball is in the air and loose. + GROUND (DuelType): A duel when the ball is on the ground. + LOOSE_BALL (DuelType): When the ball is not under the control of any particular player or team. + SLIDING_TACKLE (DuelType): A duel where the player slides on the ground to kick the ball away from an opponent. + STANDING_TACKLE (DuelType): A duel where the player makes a standing tackle. + """ + + AERIAL = "AERIAL" + GROUND = "GROUND" + LOOSE_BALL = "LOOSE_BALL" + SLIDING_TACKLE = "SLIDING_TACKLE" + STANDING_TACKLE = "STANDING_TACKLE" + + +@dataclass +class DuelQualifier(EnumQualifier): + value: DuelType + + @dataclass class CounterAttackQualifier(BoolQualifier): pass @@ -423,6 +469,31 @@ def get_qualifier_value(self, qualifier_type: Type[Qualifier]): return qualifier.value return None + + def get_qualifier_values(self, qualifier_type: Type[Qualifier]): + """ + Returns all Qualifiers of a certain type, or None if qualifier is not present. + + Arguments: + qualifier_type: one of the following QualifierTypes: [`SetPieceQualifier`][kloppy.domain.models.event.SetPieceQualifier] + [`BodyPartQualifier`][kloppy.domain.models.event.BodyPartQualifier] [`PassQualifier`][kloppy.domain.models.event.PassQualifier] + + Examples: + >>> from kloppy.domain import SetPieceQualifier + >>> pass_event.get_qualifier_value(SetPieceQualifier) + + """ + qualifiers = [] + if self.qualifiers: + for qualifier in self.qualifiers: + if isinstance(qualifier, qualifier_type): + qualifiers.append(qualifier) + + if qualifiers: + return qualifiers + + return None + def get_related_events(self) -> List["Event"]: if not self.dataset: raise OrphanedRecordError() @@ -650,6 +721,22 @@ class CarryEvent(Event): event_name: str = "carry" +@dataclass(repr=False) +@docstring_inherit_attributes(Event) +class DuelEvent(Event): + """ + DuelEvent + + Attributes: + event_type (EventType): `EventType.DUEL` (See [`EventType`][kloppy.domain.models.event.EventType]) + event_name (str): `"duel"` + + """ + + event_type: EventType = EventType.DUEL + event_name: str = "duel" + + @dataclass(repr=False) @docstring_inherit_attributes(Event) class SubstitutionEvent(Event): @@ -887,4 +974,8 @@ def generic_record_converter(event: Event): "GoalkeeperAction", "GoalkeeperActionQualifier", "CounterAttackQualifier", + "DuelEvent", + "DuelType", + "DuelQualifier", + "DuelResult", ] diff --git a/kloppy/domain/services/event_factory.py b/kloppy/domain/services/event_factory.py index 01d35207..571febb3 100644 --- a/kloppy/domain/services/event_factory.py +++ b/kloppy/domain/services/event_factory.py @@ -10,6 +10,7 @@ TakeOnEvent, RecoveryEvent, CarryEvent, + DuelEvent, FormationChangeEvent, BallOutEvent, PlayerOnEvent, @@ -82,6 +83,9 @@ def build_take_on(self, **kwargs) -> TakeOnEvent: def build_carry(self, **kwargs) -> CarryEvent: return create_event(CarryEvent, **kwargs) + def build_duel(self, **kwargs) -> DuelEvent: + return create_event(DuelEvent, **kwargs) + def build_formation_change(self, **kwargs) -> FormationChangeEvent: return create_event(FormationChangeEvent, **kwargs) diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 29a6871f..7e9011ce 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -19,6 +19,7 @@ PassResult, ShotResult, TakeOnResult, + DuelResult, Ground, Score, Provider, @@ -40,6 +41,8 @@ BodyPart, PassType, PassQualifier, + DuelType, + DuelQualifier ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer @@ -54,6 +57,9 @@ EVENT_TYPE_PASS = 1 EVENT_TYPE_OFFSIDE_PASS = 2 EVENT_TYPE_TAKE_ON = 3 +EVENT_TYPE_TACKLE = 7 +EVENT_TYPE_AERIAL = 44 +EVENT_TYPE_50_50 = 67 EVENT_TYPE_SHOT_MISS = 13 EVENT_TYPE_SHOT_POST = 14 EVENT_TYPE_SHOT_SAVED = 15 @@ -66,6 +72,7 @@ EVENT_TYPE_FORMATION_CHANGE = 40 BALL_OUT_EVENTS = [EVENT_TYPE_BALL_OUT, EVENT_TYPE_CORNER_AWARDED] +DUEL_EVENTS = [EVENT_TYPE_TACKLE, EVENT_TYPE_AERIAL, EVENT_TYPE_50_50] BALL_OWNING_EVENTS = ( EVENT_TYPE_PASS, @@ -310,6 +317,22 @@ def _parse_shot( return dict(coordinates=coordinates, result=result, qualifiers=qualifiers) +def _parse_duel(raw_qualifiers: List, type_id: int, outcome: int) -> Dict: + qualifiers = _get_event_qualifiers(raw_qualifiers) + duel_qualifiers = _get_duel_qualifiers(type_id) + qualifiers.extend(duel_qualifiers) + + if outcome: + result = DuelResult.WON + else: + result = DuelResult.LOST + + return dict( + result=result, + qualifiers=qualifiers, + ) + + def _parse_team_players( f7_root, team_ref: str ) -> Tuple[str, Dict[str, Dict[str, str]]]: @@ -452,6 +475,17 @@ def _get_event_card_qualifiers(raw_qualifiers: List) -> List[Qualifier]: return qualifiers +def _get_duel_qualifiers(type_id: int) -> List[Qualifier]: + if type_id == EVENT_TYPE_TACKLE: + duel_qualifiers = [DuelQualifier(value=DuelType.GROUND), DuelQualifier(value=DuelType.STANDING_TACKLE)] + elif type_id == EVENT_TYPE_AERIAL: + duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)] + elif type_id == EVENT_TYPE_50_50: + duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.GROUND)] + + return duel_qualifiers + + def _get_event_type_name(type_id: int) -> str: return event_type_names.get(type_id, "unknown") @@ -607,7 +641,6 @@ def deserialize(self, inputs: OptaInputs) -> EventDataset: elif type_id == EVENT_TYPE_TAKE_ON: take_on_event_kwargs = _parse_take_on(outcome) event = self.event_factory.build_take_on( - qualifiers=None, **take_on_event_kwargs, **generic_event_kwargs, ) @@ -643,7 +676,12 @@ def deserialize(self, inputs: OptaInputs) -> EventDataset: qualifiers=None, **generic_event_kwargs, ) - + elif type_id in DUEL_EVENTS: + duel_event_kwargs = _parse_duel(raw_qualifiers, type_id, outcome) + event = self.event_factory.build_duel( + **duel_event_kwargs, + **generic_event_kwargs, + ) elif type_id == EVENT_TYPE_FOUL_COMMITTED: event = self.event_factory.build_foul_committed( result=None, diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index 7f98674c..97f82a7b 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -15,6 +15,9 @@ ShotResult, TakeOnResult, CarryResult, + DuelResult, + DuelQualifier, + DuelType, Metadata, Ground, Player, @@ -43,9 +46,11 @@ logger = logging.getLogger(__name__) SB_EVENT_TYPE_RECOVERY = 2 +SB_EVENT_TYPE_DUEL = 4 SB_EVENT_TYPE_DRIBBLE = 14 SB_EVENT_TYPE_SHOT = 16 SB_EVENT_TYPE_PASS = 30 +SB_EVENT_TYPE_50_50 = 33 SB_EVENT_TYPE_CARRY = 43 SB_EVENT_TYPE_HALF_START = 18 @@ -79,6 +84,28 @@ SB_SHOT_OUTCOME_SAVED_OFF_TARGET = 115 SB_SHOT_OUTCOME_SAVED_TO_POST = 116 +SB_EVENT_TYPE_AERIAL_LOST = 10 +SB_EVENT_TYPE_TACKLE = 11 + +# SB_50_50_OUTCOME_WON = 108 +# SB_50_50_OUTCOME_LOST = 109 +# SB_50_50_OUTCOME_SUCCESS_TO_OPPOSITION = 2 # TODO: Documentation says: 148 - Asking StatsBomb support +# SB_50_50_OUTCOME_SUCCESS_TO_TEAM = 3 # TODO: Documentation says: 147 - Asking StatsBomb support +# +# SB_DUEL_OUTCOME_LOST = 1 +# SB_DUEL_OUTCOME_WON = 4 +# SB_DUEL_OUTCOME_LOST_IN_PLAY = 13 +# SB_DUEL_OUTCOME_LOST_OUT = 14 +# SB_DUEL_OUTCOME_SUCCESS = 15 +# SB_DUEL_OUTCOME_SUCCESS_IN_PLAY = 16 +# SB_DUEL_OUTCOME_SUCCESS_OUT = 17 +DUEL_WON_NAMES = ["Won", "Success To Team", "Success", "Success In Play", "Success Out"] +DUEL_LOST_NAMES = ["Lost", "Aerial Lost", "Success To Opposition", "Lost In Play", "Lost Out"] +# DUEL_WON_IDS = [SB_50_50_OUTCOME_WON, SB_50_50_OUTCOME_SUCCESS_TO_TEAM, SB_DUEL_OUTCOME_WON, SB_DUEL_OUTCOME_SUCCESS, +# SB_DUEL_OUTCOME_SUCCESS_IN_PLAY, SB_DUEL_OUTCOME_SUCCESS_OUT] +# DUEL_LOST_IDS = [SB_50_50_OUTCOME_LOST, SB_50_50_OUTCOME_SUCCESS_TO_OPPOSITION, SB_DUEL_OUTCOME_LOST, +# SB_DUEL_OUTCOME_LOST_IN_PLAY, SB_DUEL_OUTCOME_LOST_OUT, SB_EVENT_TYPE_AERIAL_LOST] + SB_EVENT_TYPE_FREE_KICK = 62 SB_EVENT_TYPE_THROW_IN = 67 SB_EVENT_TYPE_KICK_OFF = 65 @@ -416,6 +443,58 @@ def _parse_take_on(take_on_dict: Dict) -> Dict: } +def _parse_duel(raw_event: dict, event_type: int, ) -> Dict: + qualifiers = [] + + if event_type == SB_EVENT_TYPE_DUEL: + duel_dict = raw_event["duel"] + if "type" in duel_dict: + type_id = duel_dict["type"]["id"] + if type_id == SB_EVENT_TYPE_AERIAL_LOST: + duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)] + elif type_id == SB_EVENT_TYPE_TACKLE: + duel_qualifiers = [DuelQualifier(value=DuelType.GROUND), DuelQualifier(value=DuelType.STANDING_TACKLE)] + elif event_type == SB_EVENT_TYPE_50_50: + duel_dict = raw_event["50_50"] + duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.GROUND)] + + qualifiers.extend(duel_qualifiers) + body_part_qualifiers = _get_body_part_qualifiers(duel_dict) + qualifiers.extend(body_part_qualifiers) + + if "outcome" in duel_dict: + outcome_name = duel_dict["outcome"]["name"] + else: + outcome_name = duel_dict["type"]["name"] + + result = None + if outcome_name in DUEL_WON_NAMES: + result = DuelResult.WON + elif outcome_name in DUEL_LOST_NAMES: + result = DuelResult.LOST + + return { + "result": result, + "qualifiers": qualifiers + } + + +def _parse_aerial_won_duel(raw_event: dict, type_name: str) -> Dict: + qualifiers = [] + aerial_won_dict = raw_event[type_name] + duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)] + result = DuelResult.WON + + qualifiers.extend(duel_qualifiers) + body_part_qualifiers = _get_body_part_qualifiers(aerial_won_dict) + qualifiers.extend(body_part_qualifiers) + + return { + "result": result, + "qualifiers": qualifiers + } + + def _parse_substitution(substitution_dict: Dict, team: Team) -> Dict: replacement_player = None for player in team.players: @@ -724,6 +803,15 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **generic_event_kwargs, ) new_events.append(carry_event) + elif event_type in [SB_EVENT_TYPE_DUEL, SB_EVENT_TYPE_50_50]: + duel_event_kwargs = _parse_duel( + raw_event=raw_event, event_type=event_type + ) + duel_event = self.event_factory.build_duel( + **duel_event_kwargs, + **generic_event_kwargs, + ) + new_events.append(duel_event) # lineup affecting events elif event_type == SB_EVENT_TYPE_SUBSTITUTION: @@ -820,6 +908,19 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: ) new_events.append(generic_event) + # Add possible aerial won - Last, since applicable to multiple event types + for type_name in ["shot", "clearance", "miscontrol", "pass"]: + if type_name in raw_event and "aerial_won" in raw_event[type_name]: + duel_event_kwargs = _parse_aerial_won_duel( + raw_event=raw_event, type_name=type_name + ) + duel_event = self.event_factory.build_duel( + **duel_event_kwargs, + **generic_event_kwargs, + ) + new_events.append(duel_event) + + for event in new_events: if self.should_include_event(event): transformed_event = transformer.transform_event(event) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 413fa35a..7804e7c7 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -10,6 +10,9 @@ CardType, CounterAttackQualifier, Dimension, + DuelType, + DuelQualifier, + DuelResult, EventDataset, FoulCommittedEvent, GenericEvent, @@ -248,24 +251,46 @@ def _parse_set_piece(raw_event: Dict, next_event: Dict, team: Team) -> Dict: return result -def _parse_takeon(raw_event: Dict) -> Dict: +def _parse_duel(raw_event: Dict) -> Dict: qualifiers = _generic_qualifiers(raw_event) + result = None + duel_qualifiers = [] + if "loose_ball_duel" in raw_event["type"]["secondary"]: + duel_qualifiers.extend([DuelQualifier(value=DuelType.LOOSE_BALL)]) + if "ground_duel" in raw_event["type"]["secondary"]: + duel_qualifiers.extend([DuelQualifier(value=DuelType.GROUND)]) + if "sliding_tackle" in raw_event["type"]["secondary"]: + duel_qualifiers.extend([DuelQualifier(value=DuelType.GROUND), DuelQualifier(value=DuelType.SLIDING_TACKLE)]) + if "aerial_duel" in raw_event["type"]["secondary"]: + duel_qualifiers.extend([DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)]) + + + qualifiers.extend(duel_qualifiers) + + # get result value if "offensive_duel" in raw_event["type"]["secondary"]: if raw_event["groundDuel"]["keptPossession"]: - result = TakeOnResult.COMPLETE + result = DuelResult.WON else: - result = TakeOnResult.INCOMPLETE + result = DuelResult.LOST elif "defensive_duel" in raw_event["type"]["secondary"]: if raw_event["groundDuel"]["recoveredPossession"]: - result = TakeOnResult.COMPLETE + result = DuelResult.WON else: - result = TakeOnResult.INCOMPLETE + result = DuelResult.LOST elif "aerial_duel" in raw_event["type"]["secondary"]: + duel_qualifiers.extend([DuelQualifier(value=DuelType.AERIAL)]) if raw_event["aerialDuel"]["firstTouch"]: - result = TakeOnResult.COMPLETE + result = DuelResult.WON else: - result = TakeOnResult.INCOMPLETE + result = DuelResult.LOST + # elif "sliding_tackle" in raw_event["type"]["secondary"]: + # duel_qualifiers.extend([DuelQualifier(value=DuelType.AERIAL)]) + # if raw_event["aerialDuel"]["firstTouch"]: + # result = DuelResult.WON + # else: + # result = DuelResult.LOST return {"result": result, "qualifiers": qualifiers} @@ -363,9 +388,9 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: **pass_event_args, **generic_event_args ) elif primary_event_type == "duel": - takeon_event_args = _parse_takeon(raw_event) - event = self.event_factory.build_take_on( - **takeon_event_args, **generic_event_args + duel_event_args = _parse_duel(raw_event) + event = self.event_factory.build_duel( + **duel_event_args, **generic_event_args ) elif ( (primary_event_type in ["throw_in", "goal_kick"]) diff --git a/kloppy/tests/files/opta_f24.xml b/kloppy/tests/files/opta_f24.xml index c91ec792..a127603f 100644 --- a/kloppy/tests/files/opta_f24.xml +++ b/kloppy/tests/files/opta_f24.xml @@ -85,10 +85,8 @@ - - - - + + diff --git a/kloppy/tests/files/statsbomb_event.json b/kloppy/tests/files/statsbomb_event.json index df9cdbfb..6af07749 100644 --- a/kloppy/tests/files/statsbomb_event.json +++ b/kloppy/tests/files/statsbomb_event.json @@ -170128,6 +170128,126 @@ "name" : "Diving" } } +}, { + "id" : "d9152217-8772-454c-b461-9d1d66070859", + "index" : 1501, + "period" : 2, + "timestamp" : "00:48:01.770", + "minute" : 93, + "second" : 1, + "type" : { + "id" : 33, + "name" : "50/50" + }, + "possession" : 144, + "possession_team" : { + "id" : 217, + "name" : "Barcelona" + }, + "play_pattern" : { + "id" : 1, + "name" : "Regular Play" + }, + "team" : { + "id" : 217, + "name" : "Barcelona" + }, + "player" : { + "id" : 5503, + "name" : "Lionel Andrés Messi Cuccittini" + }, + "position" : { + "id" : 17, + "name" : "Right Wing" + }, + "location" : [ 47.4, 22.4 ], + "duration" : 0.0, + "under_pressure" : true, + "related_events" : [ "10bf8575-16df-43b2-b4b7-9854bb708944" ], + "50_50" : { + "outcome" : { + "id" : 3, + "name" : "Success To Team" + } + } +}, { + "id" : "d9152217-8772-454c-b461-9d1d66070129", + "index" : 1501, + "period" : 2, + "timestamp" : "00:48:01.770", + "minute" : 93, + "second" : 1, + "type" : { + "id" : 9, + "name" : "Clearance" + }, + "possession" : 144, + "possession_team" : { + "id" : 217, + "name" : "Barcelona" + }, + "play_pattern" : { + "id" : 1, + "name" : "Regular Play" + }, + "team" : { + "id" : 217, + "name" : "Barcelona" + }, + "player" : { + "id" : 5503, + "name" : "Lionel Andrés Messi Cuccittini" + }, + "position" : { + "id" : 17, + "name" : "Right Wing" + }, + "location" : [ 47.4, 22.4 ], + "duration" : 0.0, + "under_pressure" : true, + "related_events" : [ "54a0f549-fba3-4baa-8695-0bd92a2039bb", "8096dfc1-4842-41e1-8090-aa4c7117a499" ], + "clearance" : { + "aerial_won" : true + } +}, { + "id" : "d9152217-8772-454c-b461-9d1d42070129", + "index" : 1809, + "period" : 2, + "timestamp" : "00:48:01.770", + "minute" : 93, + "second" : 1, + "type" : { + "id" : 38, + "name" : "Miscontrol" + }, + "possession" : 144, + "possession_team" : { + "id" : 217, + "name" : "Barcelona" + }, + "play_pattern" : { + "id" : 1, + "name" : "Regular Play" + }, + "team" : { + "id" : 217, + "name" : "Barcelona" + }, + "player" : { + "id" : 5503, + "name" : "Lionel Andrés Messi Cuccittini" + }, + "position" : { + "id" : 17, + "name" : "Right Wing" + }, + "location" : [ 47.4, 22.4 ], + "duration" : 0.0, + "under_pressure" : true, + "related_events" : [ "09feb961-9f36-4c0e-a11d-9ab8eee9bf87" ], + "miscontrol" : { + "aerial_won" : true + } }, { "id" : "e1cc4d5e-ba55-4b6b-88cc-dae13311c1d9", "index" : 4001, @@ -170180,4 +170300,5 @@ }, "duration" : 0.0, "related_events" : [ "e1cc4d5e-ba55-4b6b-88cc-dae13311c1d9" ] -} ] \ No newline at end of file +} +] \ No newline at end of file diff --git a/kloppy/tests/files/wyscout_events_v3.json b/kloppy/tests/files/wyscout_events_v3.json index 2f012ddd..d7eeba1d 100644 --- a/kloppy/tests/files/wyscout_events_v3.json +++ b/kloppy/tests/files/wyscout_events_v3.json @@ -434,6 +434,216 @@ "name": "Bologna" }, "videoTimestamp": "8.148438" + }, + { + "id": 663291421, + "type": { + "primary": "duel", + "secondary": [ + "ground_duel", + "offensive_duel" + ] + }, + "location": { + "x": 95, + "y": 7 + }, + "matchId": 2852835, + "matchPeriod": "1H", + "matchTimestamp": "00:00:08.295", + "minute": 0, + "opponentTeam": { + "formation": "3-4-3", + "id": 3185, + "name": "Torino" + }, + "shot": null, + "groundDuel": { + "opponent": { + "id": 636942, + "name": "N. Ngoy", + "position": "RCB3" + }, + "duelType": "offensive_duel", + "keptPossession": true, + "progressedWithBall": true, + "stoppedProgress": null, + "recoveredPossession": null, + "takeOn": false, + "side": null, + "relatedDuelId": 1331978561 + }, + "aerialDuel": null, + "player": { + "id": 20583, + "name": "Danilo", + "position": "RCB" + }, + "possession": { + "id": 663291837, + "duration": "1.261821", + "types": [ + "corner", + "set_piece_attack" + ], + "eventsNumber": 1, + "eventIndex": 0, + "startLocation": { + "x": 100, + "y": 0 + }, + "endLocation": { + "x": 98, + "y": 55 + }, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "attack": null + }, + "second": 8, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "videoTimestamp": "8.148438" + }, + { + "id": 663291840, + "type": { + "primary": "duel", + "secondary": [ + "aerial_duel", + "loss" + ] + }, + "location": { + "x": 96, + "y": 39 + }, + "matchId": 2852835, + "matchPeriod": "1H", + "matchTimestamp": "00:00:08.295", + "minute": 0, + "opponentTeam": { + "formation": "3-4-3", + "id": 3185, + "name": "Torino" + }, + "shot": null, + "groundDuel": null, + "aerialDuel": { + "opponent": { + "id": 0, + "name": null, + "position": null, + "height": null + }, + "firstTouch": true, + "height": 185, + "relatedDuelId": 1331979623 + }, + "player": { + "id": 20583, + "name": "Danilo", + "position": "RCB" + }, + "possession": { + "id": 663291837, + "duration": "1.261821", + "types": [ + "corner", + "set_piece_attack" + ], + "eventsNumber": 1, + "eventIndex": 0, + "startLocation": { + "x": 100, + "y": 0 + }, + "endLocation": { + "x": 98, + "y": 55 + }, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "attack": null + }, + "second": 8, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "videoTimestamp": "8.148438" + }, + { + "id": 663291840, + "type": { + "primary": "duel", + "secondary": [ + "loose_ball_duel", + "sliding_tackle" + ] + }, + "location": { + "x": 26, + "y": 32 + }, + "matchId": 2852835, + "matchPeriod": "1H", + "matchTimestamp": "00:00:08.295", + "minute": 0, + "opponentTeam": { + "formation": "3-4-3", + "id": 3185, + "name": "Torino" + }, + "shot": null, + "groundDuel": null, + "aerialDuel": null, + "player": { + "id": 20583, + "name": "Danilo", + "position": "RCB" + }, + "possession": { + "id": 663291837, + "duration": "1.261821", + "types": [ + "corner", + "set_piece_attack" + ], + "eventsNumber": 1, + "eventIndex": 0, + "startLocation": { + "x": 100, + "y": 0 + }, + "endLocation": { + "x": 98, + "y": 55 + }, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "attack": null + }, + "second": 8, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "videoTimestamp": "8.148438" } ], "formations": { diff --git a/kloppy/tests/test_helpers.py b/kloppy/tests/test_helpers.py index f678e8f2..d320b6fa 100644 --- a/kloppy/tests/test_helpers.py +++ b/kloppy/tests/test_helpers.py @@ -374,7 +374,7 @@ def test_event_dataset_to_polars(self, base_dir): import polars as pl c = df.select(pl.col("event_id").count())[0, 0] - assert c == 4023 + assert c == 4039 def test_tracking_dataset_to_polars(self): """ diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 6d1a6347..179355a9 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -13,6 +13,8 @@ DatasetType, CardType, FormationType, + DuelQualifier, + DuelType, ) from kloppy import opta @@ -107,6 +109,11 @@ def test_correct_deserialization(self, f7_data: str, f24_data: str): # Check OFFSIDE pass has end_coordinates assert dataset.events[20].receiver_coordinates.x == 89.3 # 2360555167 + # Check DuelQualifiers + assert dataset.events[7].get_qualifier_values(DuelQualifier)[1].value == DuelType.AERIAL + assert dataset.events[8].get_qualifier_values(DuelQualifier)[1].value == DuelType.GROUND + assert dataset.events[16].get_qualifier_values(DuelQualifier)[1].value == DuelType.STANDING_TACKLE + def test_correct_normalized_deserialization( self, f7_data: str, f24_data: str ): diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 6c463dc0..82020ba6 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -8,6 +8,8 @@ BodyPart, BodyPartQualifier, DatasetType, + DuelQualifier, + DuelType, Orientation, Period, Point, @@ -50,7 +52,7 @@ def test_correct_deserialization( assert dataset.metadata.provider == Provider.STATSBOMB assert dataset.dataset_type == DatasetType.EVENT - assert len(dataset.events) == 4023 + assert len(dataset.events) == 4039 assert len(dataset.metadata.periods) == 2 assert ( dataset.metadata.orientation == Orientation.ACTION_EXECUTING_TEAM @@ -93,7 +95,7 @@ def test_correct_deserialization( assert dataset.events[10].coordinates == Point(34.5, 20.5) assert ( - dataset.events[792].get_qualifier_value(BodyPartQualifier) + dataset.events[794].get_qualifier_value(BodyPartQualifier) == BodyPart.HEAD ) @@ -107,46 +109,50 @@ def test_correct_deserialization( ) assert ( - dataset.events[1433].get_qualifier_value(PassQualifier) + dataset.events[1438].get_qualifier_value(PassQualifier) == PassType.CROSS ) assert ( - dataset.events[1552].get_qualifier_value(PassQualifier) + dataset.events[1557].get_qualifier_value(PassQualifier) == PassType.THROUGH_BALL ) assert ( - dataset.events[443].get_qualifier_value(PassQualifier) + dataset.events[444].get_qualifier_value(PassQualifier) == PassType.SWITCH_OF_PLAY ) assert ( - dataset.events[3438].get_qualifier_value(PassQualifier) + dataset.events[101].get_qualifier_value(PassQualifier) == PassType.LONG_BALL ) assert ( - dataset.events[2266].get_qualifier_value(PassQualifier) + dataset.events[17].get_qualifier_value(PassQualifier) == PassType.HIGH_PASS ) assert ( - dataset.events[653].get_qualifier_value(PassQualifier) + dataset.events[654].get_qualifier_value(PassQualifier) == PassType.HEAD_PASS ) assert ( - dataset.events[3134].get_qualifier_value(PassQualifier) + dataset.events[3145].get_qualifier_value(PassQualifier) == PassType.HAND_PASS ) assert ( - dataset.events[3611].get_qualifier_value(PassQualifier) + dataset.events[3622].get_qualifier_value(PassQualifier) == PassType.ASSIST ) - assert dataset.events[3392].get_qualifier_value(PassQualifier) is None + assert dataset.events[3400].get_qualifier_value(PassQualifier) is None + + assert dataset.events[194].get_qualifier_values(DuelQualifier)[1].value == DuelType.AERIAL + assert dataset.events[307].get_qualifier_values(DuelQualifier)[1].value == DuelType.STANDING_TACKLE + assert dataset.events[4032].get_qualifier_values(DuelQualifier)[1].value == DuelType.GROUND def test_correct_normalized_deserialization( self, lineup_data: Path, event_data: Path diff --git a/requirements.txt b/requirements.txt index 910874d3..e6a99000 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,8 @@ networkx>=2.4 pytest pandas>=1.0.0 polars>=0.16.6 -pre-commit \ No newline at end of file +pre-commit +kloppy~=3.11.0 +pytz~=2023.3 +python-dateutil~=2.8.2 +setuptools~=67.8.0 \ No newline at end of file From 00da3ff29ed7da3f91bda31072baca646d467b1e Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Wed, 5 Jul 2023 16:46:29 +0200 Subject: [PATCH 2/7] =?UTF-8?q?Notes:=201.=09Remained=20with=20Pieter?= =?UTF-8?q?=E2=80=99s=20initial=20proposal=20of=20only=20adding:=20AERIAL,?= =?UTF-8?q?=20GROUND,=20LOOSE=5FBALL=20&=20SLIDING=5FTACKLE=202.=09StatsBo?= =?UTF-8?q?mb:=20Checked=20qualifiers=20with=20=E2=80=9Cname=E2=80=9D=20in?= =?UTF-8?q?stead=20of=20id,=20since=20ids=20are=20not=20consistent=20in=20?= =?UTF-8?q?StatsBomb=20open=20data.=20As=20per=20StatsBomb=20helpdesk.=203?= =?UTF-8?q?.=09Added=20a=20method:=20.get=5Fqualifier=5Fvalues()=20.=20Whi?= =?UTF-8?q?ch=20returns=20a=20list=20of=20Qualifiers=20instead=20of=20.get?= =?UTF-8?q?=5Fqualifier=5Fvalue(),=20that=20returns=20the=20first=20Qualif?= =?UTF-8?q?ier.=204.=09Also=20Added=20NEUTRAL=20as=20outcome,=20since=20th?= =?UTF-8?q?is=20is=20provided=20in=20wyscout=5Fv2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kloppy/_providers/datafactory.py | 1 - kloppy/_providers/opta.py | 1 - kloppy/_providers/statsbomb.py | 1 - kloppy/domain/models/common.py | 4 - kloppy/domain/models/event.py | 5 +- .../domain/services/state_builder/__init__.py | 1 - .../state_builder/builders/sequence.py | 1 - .../domain/services/transformers/attribute.py | 1 - .../services/transformers/data_record.py | 1 - .../domain/services/transformers/dataset.py | 13 --- .../event/datafactory/deserializer.py | 1 - .../event/metrica/json_deserializer.py | 5 - .../serializers/event/opta/deserializer.py | 39 ++++--- .../event/statsbomb/deserializer.py | 100 ++++++++---------- .../event/wyscout/deserializer_v2.py | 50 +++++++-- .../event/wyscout/deserializer_v3.py | 88 +++++++++------ .../event/wyscout/wyscout_events.py | 1 + .../infra/serializers/tracking/metrica_csv.py | 1 - .../tracking/metrica_epts/deserializer.py | 1 - .../serializers/tracking/secondspectrum.py | 4 - kloppy/infra/serializers/tracking/tracab.py | 1 - kloppy/tests/files/wyscout_events_v3.json | 3 +- kloppy/tests/test_opta.py | 11 +- kloppy/tests/test_state_builder.py | 8 +- kloppy/tests/test_statsbomb.py | 11 +- kloppy/tests/test_to_records.py | 2 +- kloppy/tests/test_wyscout.py | 34 +++++- kloppy/utils.py | 3 - 28 files changed, 222 insertions(+), 170 deletions(-) diff --git a/kloppy/_providers/datafactory.py b/kloppy/_providers/datafactory.py index 4ab71932..4b642e4e 100644 --- a/kloppy/_providers/datafactory.py +++ b/kloppy/_providers/datafactory.py @@ -28,7 +28,6 @@ def load( event_factory=event_factory or get_config("event_factory"), ) with open_as_file(event_data) as event_data_fp: - return deserializer.deserialize( inputs=DatafactoryInputs(event_data=event_data_fp), ) diff --git a/kloppy/_providers/opta.py b/kloppy/_providers/opta.py index 729057c1..c62f0395 100644 --- a/kloppy/_providers/opta.py +++ b/kloppy/_providers/opta.py @@ -32,7 +32,6 @@ def load( with open_as_file(f7_data) as f7_data_fp, open_as_file( f24_data ) as f24_data_fp: - return deserializer.deserialize( inputs=OptaInputs(f7_data=f7_data_fp, f24_data=f24_data_fp), ) diff --git a/kloppy/_providers/statsbomb.py b/kloppy/_providers/statsbomb.py index 1fd26e35..1d750bc0 100644 --- a/kloppy/_providers/statsbomb.py +++ b/kloppy/_providers/statsbomb.py @@ -48,7 +48,6 @@ def load( ) as lineup_data_fp, open_as_file( Source.create(three_sixty_data, optional=True) ) as three_sixty_data_fp: - return deserializer.deserialize( inputs=StatsBombInputs( event_data=event_data_fp, diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index b6b86018..4b60b893 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -426,7 +426,6 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - if self.length is not None and self.width is not None: return PitchDimensions( x_dim=Dimension(0, 1), @@ -656,7 +655,6 @@ def pitch_dimensions(self) -> PitchDimensions: def build_coordinate_system(provider: Provider, **kwargs): - if provider == Provider.TRACAB: return TracabCoordinateSystem(normalized=False, **kwargs) @@ -966,7 +964,6 @@ def to_records( as_list: bool = True, **named_columns: "Column", ) -> Union[List[Dict[str, Any]], Iterable[Dict[str, Any]]]: - from ..services.transformers.data_record import get_transformer_cls transformer = get_transformer_cls(self.dataset_type)( @@ -984,7 +981,6 @@ def to_dict( orient: Literal["list"] = "list", **named_columns: "Column", ) -> Dict[str, List[Any]]: - if orient == "list": from ..services.transformers.data_record import get_transformer_cls diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index 35d02630..befc5e28 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -140,10 +140,12 @@ class DuelResult(ResultType): Attributes: WON (DuelResult): When winning the duel (player touching the ball first) LOST (DuelResult): When losing the duel (opponent touches the ball first) + NEUTRAL (DuelResult): When neither player wins duel [Mainly for WyScout v2] """ WON = "WON" LOST = "LOST" + NEUTRAL = "NEUTRAL" @property def is_success(self): @@ -385,14 +387,12 @@ class DuelType(Enum): GROUND (DuelType): A duel when the ball is on the ground. LOOSE_BALL (DuelType): When the ball is not under the control of any particular player or team. SLIDING_TACKLE (DuelType): A duel where the player slides on the ground to kick the ball away from an opponent. - STANDING_TACKLE (DuelType): A duel where the player makes a standing tackle. """ AERIAL = "AERIAL" GROUND = "GROUND" LOOSE_BALL = "LOOSE_BALL" SLIDING_TACKLE = "SLIDING_TACKLE" - STANDING_TACKLE = "STANDING_TACKLE" @dataclass @@ -469,7 +469,6 @@ def get_qualifier_value(self, qualifier_type: Type[Qualifier]): return qualifier.value return None - def get_qualifier_values(self, qualifier_type: Type[Qualifier]): """ Returns all Qualifiers of a certain type, or None if qualifier is not present. diff --git a/kloppy/domain/services/state_builder/__init__.py b/kloppy/domain/services/state_builder/__init__.py index 7577cb16..3fcca8bb 100644 --- a/kloppy/domain/services/state_builder/__init__.py +++ b/kloppy/domain/services/state_builder/__init__.py @@ -36,7 +36,6 @@ def add_state(dataset: EventDataset, *builder_keys: List[str]) -> EventDataset: events = [] for event in dataset.events: - state = { builder_key: builder.reduce_before(state[builder_key], event) for builder_key, builder in builders.items() diff --git a/kloppy/domain/services/state_builder/builders/sequence.py b/kloppy/domain/services/state_builder/builders/sequence.py index 8bebde82..e851f430 100644 --- a/kloppy/domain/services/state_builder/builders/sequence.py +++ b/kloppy/domain/services/state_builder/builders/sequence.py @@ -43,7 +43,6 @@ def reduce_before(self, state: Sequence, event: Event) -> Sequence: return state def reduce_after(self, state: Sequence, event: Event) -> Sequence: - if isinstance(event, CLOSE_SEQUENCE): state = replace( state, sequence_id=state.sequence_id + 1, team=None diff --git a/kloppy/domain/services/transformers/attribute.py b/kloppy/domain/services/transformers/attribute.py index 0ab53d19..09bdcf02 100644 --- a/kloppy/domain/services/transformers/attribute.py +++ b/kloppy/domain/services/transformers/attribute.py @@ -276,7 +276,6 @@ def __call__(self, frame: Frame) -> Dict[str, Any]: else None, ) for player, player_data in frame.players_data.items(): - row.update( { f"{player.player_id}_x": player_data.coordinates.x diff --git a/kloppy/domain/services/transformers/data_record.py b/kloppy/domain/services/transformers/data_record.py index 93ace117..8eb8f503 100644 --- a/kloppy/domain/services/transformers/data_record.py +++ b/kloppy/domain/services/transformers/data_record.py @@ -25,7 +25,6 @@ def __init__( **named_columns: Union[str, Callable[[T], Any]], ): if not columns and not named_columns: - converter = self.default_transformer() else: default = self.default_transformer() diff --git a/kloppy/domain/services/transformers/dataset.py b/kloppy/domain/services/transformers/dataset.py index b2001895..22ee0a70 100644 --- a/kloppy/domain/services/transformers/dataset.py +++ b/kloppy/domain/services/transformers/dataset.py @@ -30,7 +30,6 @@ def __init__( to_pitch_dimensions: Optional[PitchDimensions] = None, to_orientation: Optional[Orientation] = None, ): - if ( from_pitch_dimensions and from_coordinate_system @@ -90,7 +89,6 @@ def _needs_pitch_dimensions_change(self): def change_point_dimensions( self, point: Union[Point, Point3D, None] ) -> Union[Point, Point3D, None]: - if point is None: return None @@ -108,7 +106,6 @@ def change_point_dimensions( def flip_point( self, point: Union[Point, Point3D, None] ) -> Union[Point, Point3D, None]: - if not point: return None @@ -160,7 +157,6 @@ def __needs_flip( return flip def transform_frame(self, frame: Frame) -> Frame: - # Change coordinate system if self._needs_coordinate_system_change: frame = self.__change_frame_coordinate_system(frame) @@ -178,7 +174,6 @@ def transform_frame(self, frame: Frame) -> Frame: return frame def __change_frame_coordinate_system(self, frame: Frame): - return Frame( # doesn't change timestamp=frame.timestamp, @@ -205,7 +200,6 @@ def __change_frame_coordinate_system(self, frame: Frame): ) def __change_frame_dimensions(self, frame: Frame): - return Frame( # doesn't change timestamp=frame.timestamp, @@ -234,7 +228,6 @@ def __change_frame_dimensions(self, frame: Frame): def __change_point_coordinate_system( self, point: Union[Point, Point3D, None] ) -> Union[Point, Point3D, None]: - if not point: return None @@ -257,7 +250,6 @@ def __change_point_coordinate_system( return Point(x=x, y=y) def __flip_frame(self, frame: Frame): - players_data = {} for player, data in frame.players_data.items(): players_data[player] = PlayerData( @@ -281,7 +273,6 @@ def __flip_frame(self, frame: Frame): ) def transform_event(self, event: Event) -> Event: - # Change coordinate system if self._needs_coordinate_system_change: event = self.__change_event_coordinate_system(event) @@ -303,7 +294,6 @@ def transform_event(self, event: Event) -> Event: return event def __change_event_coordinate_system(self, event: Event): - position_changes = { field.name: self.__change_point_coordinate_system( getattr(event, field.name) @@ -316,7 +306,6 @@ def __change_event_coordinate_system(self, event: Event): return replace(event, **position_changes) def __change_event_dimensions(self, event: Event): - position_changes = { field.name: self.change_point_dimensions( getattr(event, field.name) @@ -329,7 +318,6 @@ def __change_event_dimensions(self, event: Event): return replace(event, **position_changes) def __flip_event(self, event: Event): - position_changes = { field.name: self.flip_point(getattr(event, field.name)) for field in fields(event) @@ -350,7 +338,6 @@ def transform_dataset( to_orientation: Optional[Orientation] = None, to_coordinate_system: Optional[CoordinateSystem] = None, ) -> Dataset: - if ( to_pitch_dimensions is None and to_orientation is None diff --git a/kloppy/infra/serializers/event/datafactory/deserializer.py b/kloppy/infra/serializers/event/datafactory/deserializer.py index c32a3653..2bac39c0 100644 --- a/kloppy/infra/serializers/event/datafactory/deserializer.py +++ b/kloppy/infra/serializers/event/datafactory/deserializer.py @@ -353,7 +353,6 @@ def provider(self) -> Provider: return Provider.DATAFACTORY def deserialize(self, inputs: DatafactoryInputs) -> EventDataset: - transformer = self.get_transformer(length=2, width=2) with performance_logging("load data", logger=logger): diff --git a/kloppy/infra/serializers/event/metrica/json_deserializer.py b/kloppy/infra/serializers/event/metrica/json_deserializer.py index 0320d475..8100afc1 100644 --- a/kloppy/infra/serializers/event/metrica/json_deserializer.py +++ b/kloppy/infra/serializers/event/metrica/json_deserializer.py @@ -118,7 +118,6 @@ def _parse_subtypes(event: dict) -> List: def _parse_pass( event: Dict, previous_event: Dict, subtypes: List, team: Team ) -> Dict: - event_type_id = event["type"]["id"] if event_type_id == MS_PASS_OUTCOME_COMPLETE: @@ -157,7 +156,6 @@ def _parse_pass( def _get_event_qualifiers( event: Dict, previous_event: Dict, subtypes: List ) -> List[Qualifier]: - qualifiers = [] qualifiers.extend(_get_event_setpiece_qualifiers(previous_event, subtypes)) @@ -169,7 +167,6 @@ def _get_event_qualifiers( def _get_event_setpiece_qualifiers( previous_event: Dict, subtypes: List ) -> List[Qualifier]: - qualifiers = [] previous_event_type_id = previous_event["type"]["id"] if previous_event_type_id == MS_SET_PIECE: @@ -193,7 +190,6 @@ def _get_event_setpiece_qualifiers( def _get_event_bodypart_qualifiers(subtypes: List) -> List[Qualifier]: - qualifiers = [] if subtypes and MS_BODY_PART_HEAD in subtypes: qualifiers.append(BodyPartQualifier(value=BodyPart.HEAD)) @@ -274,7 +270,6 @@ def deserialize(self, inputs: MetricaJsonEventDataInputs) -> EventDataset: with performance_logging("parse data", logger=logger): events = [] for i, raw_event in enumerate(raw_events["data"]): - if raw_event["team"]["id"] == metadata.teams[0].team_id: team = metadata.teams[0] elif raw_event["team"]["id"] == metadata.teams[1].team_id: diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 7e9011ce..e19f59b3 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -20,6 +20,8 @@ ShotResult, TakeOnResult, DuelResult, + DuelType, + DuelQualifier, Ground, Score, Provider, @@ -41,8 +43,6 @@ BodyPart, PassType, PassQualifier, - DuelType, - DuelQualifier ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer @@ -319,8 +319,22 @@ def _parse_shot( def _parse_duel(raw_qualifiers: List, type_id: int, outcome: int) -> Dict: qualifiers = _get_event_qualifiers(raw_qualifiers) - duel_qualifiers = _get_duel_qualifiers(type_id) - qualifiers.extend(duel_qualifiers) + if type_id == EVENT_TYPE_TACKLE: + qualifiers.extend([DuelQualifier(value=DuelType.GROUND)]) + elif type_id == EVENT_TYPE_AERIAL: + qualifiers.extend( + [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.AERIAL), + ] + ) + elif type_id == EVENT_TYPE_50_50: + qualifiers.extend( + [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.GROUND), + ] + ) if outcome: result = DuelResult.WON @@ -475,17 +489,6 @@ def _get_event_card_qualifiers(raw_qualifiers: List) -> List[Qualifier]: return qualifiers -def _get_duel_qualifiers(type_id: int) -> List[Qualifier]: - if type_id == EVENT_TYPE_TACKLE: - duel_qualifiers = [DuelQualifier(value=DuelType.GROUND), DuelQualifier(value=DuelType.STANDING_TACKLE)] - elif type_id == EVENT_TYPE_AERIAL: - duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)] - elif type_id == EVENT_TYPE_50_50: - duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.GROUND)] - - return duel_qualifiers - - def _get_event_type_name(type_id: int) -> str: return event_type_names.get(type_id, "unknown") @@ -676,12 +679,16 @@ def deserialize(self, inputs: OptaInputs) -> EventDataset: qualifiers=None, **generic_event_kwargs, ) + elif type_id in DUEL_EVENTS: - duel_event_kwargs = _parse_duel(raw_qualifiers, type_id, outcome) + duel_event_kwargs = _parse_duel( + raw_qualifiers, type_id, outcome + ) event = self.event_factory.build_duel( **duel_event_kwargs, **generic_event_kwargs, ) + elif type_id == EVENT_TYPE_FOUL_COMMITTED: event = self.event_factory.build_foul_committed( result=None, diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index 97f82a7b..e7708043 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -87,24 +87,13 @@ SB_EVENT_TYPE_AERIAL_LOST = 10 SB_EVENT_TYPE_TACKLE = 11 -# SB_50_50_OUTCOME_WON = 108 -# SB_50_50_OUTCOME_LOST = 109 -# SB_50_50_OUTCOME_SUCCESS_TO_OPPOSITION = 2 # TODO: Documentation says: 148 - Asking StatsBomb support -# SB_50_50_OUTCOME_SUCCESS_TO_TEAM = 3 # TODO: Documentation says: 147 - Asking StatsBomb support -# -# SB_DUEL_OUTCOME_LOST = 1 -# SB_DUEL_OUTCOME_WON = 4 -# SB_DUEL_OUTCOME_LOST_IN_PLAY = 13 -# SB_DUEL_OUTCOME_LOST_OUT = 14 -# SB_DUEL_OUTCOME_SUCCESS = 15 -# SB_DUEL_OUTCOME_SUCCESS_IN_PLAY = 16 -# SB_DUEL_OUTCOME_SUCCESS_OUT = 17 -DUEL_WON_NAMES = ["Won", "Success To Team", "Success", "Success In Play", "Success Out"] -DUEL_LOST_NAMES = ["Lost", "Aerial Lost", "Success To Opposition", "Lost In Play", "Lost Out"] -# DUEL_WON_IDS = [SB_50_50_OUTCOME_WON, SB_50_50_OUTCOME_SUCCESS_TO_TEAM, SB_DUEL_OUTCOME_WON, SB_DUEL_OUTCOME_SUCCESS, -# SB_DUEL_OUTCOME_SUCCESS_IN_PLAY, SB_DUEL_OUTCOME_SUCCESS_OUT] -# DUEL_LOST_IDS = [SB_50_50_OUTCOME_LOST, SB_50_50_OUTCOME_SUCCESS_TO_OPPOSITION, SB_DUEL_OUTCOME_LOST, -# SB_DUEL_OUTCOME_LOST_IN_PLAY, SB_DUEL_OUTCOME_LOST_OUT, SB_EVENT_TYPE_AERIAL_LOST] +DUEL_WON_NAMES = [ + "Won", + "Success To Team", + "Success", + "Success In Play", + "Success Out", +] SB_EVENT_TYPE_FREE_KICK = 62 SB_EVENT_TYPE_THROW_IN = 67 @@ -443,56 +432,55 @@ def _parse_take_on(take_on_dict: Dict) -> Dict: } -def _parse_duel(raw_event: dict, event_type: int, ) -> Dict: - qualifiers = [] +def _parse_duel( + raw_event: dict, + event_type: int, +) -> Dict: + duel_dict = None + duel_qualifiers = [] if event_type == SB_EVENT_TYPE_DUEL: - duel_dict = raw_event["duel"] - if "type" in duel_dict: - type_id = duel_dict["type"]["id"] - if type_id == SB_EVENT_TYPE_AERIAL_LOST: - duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)] - elif type_id == SB_EVENT_TYPE_TACKLE: - duel_qualifiers = [DuelQualifier(value=DuelType.GROUND), DuelQualifier(value=DuelType.STANDING_TACKLE)] + duel_dict = raw_event.get("duel", {}) + type_id = duel_dict.get("type", {}).get("id") + if type_id == SB_EVENT_TYPE_AERIAL_LOST: + duel_qualifiers = [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.AERIAL), + ] + elif type_id == SB_EVENT_TYPE_TACKLE: + duel_qualifiers = [DuelQualifier(value=DuelType.GROUND)] elif event_type == SB_EVENT_TYPE_50_50: - duel_dict = raw_event["50_50"] - duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.GROUND)] + duel_dict = raw_event.get("50_50", {}) + duel_qualifiers = [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.GROUND), + ] - qualifiers.extend(duel_qualifiers) - body_part_qualifiers = _get_body_part_qualifiers(duel_dict) - qualifiers.extend(body_part_qualifiers) + qualifiers = duel_qualifiers + _get_body_part_qualifiers(duel_dict) - if "outcome" in duel_dict: - outcome_name = duel_dict["outcome"]["name"] - else: - outcome_name = duel_dict["type"]["name"] + outcome_name = duel_dict.get("outcome", {}).get("name") or duel_dict.get( + "type", {} + ).get("name") - result = None if outcome_name in DUEL_WON_NAMES: result = DuelResult.WON - elif outcome_name in DUEL_LOST_NAMES: + else: result = DuelResult.LOST - return { - "result": result, - "qualifiers": qualifiers - } + return {"result": result, "qualifiers": qualifiers} def _parse_aerial_won_duel(raw_event: dict, type_name: str) -> Dict: - qualifiers = [] aerial_won_dict = raw_event[type_name] - duel_qualifiers = [DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)] - result = DuelResult.WON + duel_qualifiers = [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.AERIAL), + ] + qualifiers = duel_qualifiers + _get_body_part_qualifiers(aerial_won_dict) - qualifiers.extend(duel_qualifiers) - body_part_qualifiers = _get_body_part_qualifiers(aerial_won_dict) - qualifiers.extend(body_part_qualifiers) + result = DuelResult.WON - return { - "result": result, - "qualifiers": qualifiers - } + return {"result": result, "qualifiers": qualifiers} def _parse_substitution(substitution_dict: Dict, team: Team) -> Dict: @@ -908,9 +896,12 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: ) new_events.append(generic_event) - # Add possible aerial won - Last, since applicable to multiple event types + # Add possible aerial won - Applicable to multiple event types for type_name in ["shot", "clearance", "miscontrol", "pass"]: - if type_name in raw_event and "aerial_won" in raw_event[type_name]: + if ( + type_name in raw_event + and "aerial_won" in raw_event[type_name] + ): duel_event_kwargs = _parse_aerial_won_duel( raw_event=raw_event, type_name=type_name ) @@ -920,7 +911,6 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: ) new_events.append(duel_event) - for event in new_events: if self.should_include_event(event): transformed_event = transformer.transform_event(event) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py index 3fae4729..d989ebf4 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py @@ -10,6 +10,9 @@ CardType, CounterAttackQualifier, Dimension, + DuelResult, + DuelQualifier, + DuelType, EventDataset, FoulCommittedEvent, GenericEvent, @@ -33,8 +36,6 @@ SetPieceType, ShotEvent, ShotResult, - TakeOnEvent, - TakeOnResult, Team, ) from kloppy.utils import performance_logging @@ -245,13 +246,44 @@ def _parse_set_piece(raw_event: Dict, next_event: Dict) -> Dict: return result -def _parse_takeon(raw_event: Dict) -> Dict: +def _parse_duel(raw_event: Dict) -> Dict: qualifiers = _generic_qualifiers(raw_event) + duel_qualifiers = [] + + sub_event_id = raw_event["subEventId"] + + if sub_event_id == wyscout_events.DUEL.AERIAL: + duel_qualifiers.extend( + [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.AERIAL), + ] + ) + elif sub_event_id in [ + wyscout_events.DUEL.GROUND_ATTACKING, + wyscout_events.DUEL.GROUND_DEFENDING, + ]: + duel_qualifiers.extend([DuelQualifier(value=DuelType.GROUND)]) + elif sub_event_id == wyscout_events.DUEL.GROUND_LOOSE_BALL: + duel_qualifiers.extend( + [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.GROUND), + ] + ) + + if _has_tag(raw_event, wyscout_tags.SLIDING_TACKLE): + duel_qualifiers.extend([DuelQualifier(value=DuelType.SLIDING_TACKLE)]) + + qualifiers.extend(duel_qualifiers) + result = None - if _has_tag(raw_event, wyscout_tags.LOST): - result = TakeOnResult.INCOMPLETE if _has_tag(raw_event, wyscout_tags.WON): - result = TakeOnResult.COMPLETE + result = DuelResult.WON + elif _has_tag(raw_event, wyscout_tags.LOST): + result = DuelResult.LOST + elif _has_tag(raw_event, wyscout_tags.NEUTRAL): + result = DuelResult.NEUTRAL return {"result": result, "qualifiers": qualifiers} @@ -386,9 +418,9 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: **recovery_event_args, **generic_event_args ) elif raw_event["eventId"] == wyscout_events.DUEL.EVENT: - takeon_event_args = _parse_takeon(raw_event) - event = self.event_factory.build_take_on( - **takeon_event_args, **generic_event_args + duel_event_args = _parse_duel(raw_event) + event = self.event_factory.build_duel( + **duel_event_args, **generic_event_args ) elif raw_event["eventId"] not in [ wyscout_events.SAVE.EVENT, diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 7804e7c7..de599b5d 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -253,44 +253,64 @@ def _parse_set_piece(raw_event: Dict, next_event: Dict, team: Team) -> Dict: def _parse_duel(raw_event: Dict) -> Dict: qualifiers = _generic_qualifiers(raw_event) - - result = None duel_qualifiers = [] - if "loose_ball_duel" in raw_event["type"]["secondary"]: - duel_qualifiers.extend([DuelQualifier(value=DuelType.LOOSE_BALL)]) - if "ground_duel" in raw_event["type"]["secondary"]: - duel_qualifiers.extend([DuelQualifier(value=DuelType.GROUND)]) - if "sliding_tackle" in raw_event["type"]["secondary"]: - duel_qualifiers.extend([DuelQualifier(value=DuelType.GROUND), DuelQualifier(value=DuelType.SLIDING_TACKLE)]) - if "aerial_duel" in raw_event["type"]["secondary"]: - duel_qualifiers.extend([DuelQualifier(value=DuelType.LOOSE_BALL), DuelQualifier(value=DuelType.AERIAL)]) - + secondary_types = raw_event["type"]["secondary"] + + if "ground_duel" in secondary_types: + duel_qualifiers.append(DuelQualifier(value=DuelType.GROUND)) + elif "aerial_duel" in secondary_types: + duel_qualifiers.extend( + [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.AERIAL), + ] + ) + else: + if ( + "loose_ball_duel" in secondary_types + and "sliding_tackle" in secondary_types + ): + duel_qualifiers.extend( + [ + DuelQualifier(value=DuelType.GROUND), + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.SLIDING_TACKLE), + ] + ) + elif "loose_ball_duel" in secondary_types: + duel_qualifiers.extend( + [ + DuelQualifier(value=DuelType.GROUND), + DuelQualifier(value=DuelType.LOOSE_BALL), + ] + ) + elif "sliding_tackle" in secondary_types: + duel_qualifiers.extend( + [ + DuelQualifier(value=DuelType.GROUND), + DuelQualifier(value=DuelType.SLIDING_TACKLE), + ] + ) qualifiers.extend(duel_qualifiers) - # get result value - if "offensive_duel" in raw_event["type"]["secondary"]: - if raw_event["groundDuel"]["keptPossession"]: - result = DuelResult.WON - else: - result = DuelResult.LOST - elif "defensive_duel" in raw_event["type"]["secondary"]: - if raw_event["groundDuel"]["recoveredPossession"]: - result = DuelResult.WON - else: - result = DuelResult.LOST - elif "aerial_duel" in raw_event["type"]["secondary"]: - duel_qualifiers.extend([DuelQualifier(value=DuelType.AERIAL)]) - if raw_event["aerialDuel"]["firstTouch"]: - result = DuelResult.WON - else: - result = DuelResult.LOST - # elif "sliding_tackle" in raw_event["type"]["secondary"]: - # duel_qualifiers.extend([DuelQualifier(value=DuelType.AERIAL)]) - # if raw_event["aerialDuel"]["firstTouch"]: - # result = DuelResult.WON - # else: - # result = DuelResult.LOST + if ( + "offensive_duel" in secondary_types + and raw_event["groundDuel"]["keptPossession"] + ): + result = DuelResult.WON + elif ( + "defensive_duel" in secondary_types + and raw_event["groundDuel"]["recoveredPossession"] + ): + result = DuelResult.WON + elif ( + "aerial_duel" in secondary_types + and raw_event["aerialDuel"]["firstTouch"] + ): + result = DuelResult.WON + else: + result = DuelResult.LOST return {"result": result, "qualifiers": qualifiers} diff --git a/kloppy/infra/serializers/event/wyscout/wyscout_events.py b/kloppy/infra/serializers/event/wyscout/wyscout_events.py index 13370070..02154718 100644 --- a/kloppy/infra/serializers/event/wyscout/wyscout_events.py +++ b/kloppy/infra/serializers/event/wyscout/wyscout_events.py @@ -4,6 +4,7 @@ class DUEL: EVENT = 1 + AERIAL = 10 GROUND_ATTACKING = 11 GROUND_DEFENDING = 12 GROUND_LOOSE_BALL = 13 diff --git a/kloppy/infra/serializers/tracking/metrica_csv.py b/kloppy/infra/serializers/tracking/metrica_csv.py index 47f08158..fa61690c 100644 --- a/kloppy/infra/serializers/tracking/metrica_csv.py +++ b/kloppy/infra/serializers/tracking/metrica_csv.py @@ -123,7 +123,6 @@ def __create_iterator( def __validate_partials( home_partial_frame: __PartialFrame, away_partial_frame: __PartialFrame ): - if home_partial_frame.frame_id != away_partial_frame.frame_id: raise ValueError( f"frame_id mismatch: home {home_partial_frame.frame_id}, " diff --git a/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py b/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py index 7e9dac7a..efb0d5b3 100644 --- a/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py +++ b/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py @@ -51,7 +51,6 @@ def _frame_from_row( players_data = {} for team in metadata.teams: for player in team.players: - other_data = {} for sensor in other_sensors: player_sensor_field_str = f"player_{player.player_id}_{sensor.channels[0].channel_id}" diff --git a/kloppy/infra/serializers/tracking/secondspectrum.py b/kloppy/infra/serializers/tracking/secondspectrum.py index 57277c23..cd3f4e0c 100644 --- a/kloppy/infra/serializers/tracking/secondspectrum.py +++ b/kloppy/infra/serializers/tracking/secondspectrum.py @@ -55,7 +55,6 @@ def provider(self) -> Provider: @classmethod def _frame_from_framedata(cls, teams, period, frame_data): - frame_id = frame_data["frameIdx"] frame_timestamp = frame_data["gameClock"] @@ -75,7 +74,6 @@ def _frame_from_framedata(cls, teams, period, frame_data): players_data = {} for team, team_str in zip(teams, ["homePlayers", "awayPlayers"]): for player_data in frame_data[team_str]: - jersey_no = player_data["number"] x, y, _ = player_data["xyz"] player = team.get_player_by_jersey_number(jersey_no) @@ -111,7 +109,6 @@ def __validate_inputs(inputs: Dict[str, Readable]): raise ValueError("Please specify a value for 'raw_data'") def deserialize(self, inputs: SecondSpectrumInputs) -> TrackingDataset: - metadata = None # Handles the XML metadata that contains the pitch dimensions and frame info @@ -201,7 +198,6 @@ def deserialize(self, inputs: SecondSpectrumInputs) -> TrackingDataset: teams, ["homePlayers", "awayPlayers"] ): for player_data in metadata[team_str]: - # We use the attributes field of Player to store the extra IDs provided by the # metadata. We designate the player_id to be the 'optaId' field as this is what's # used as 'player_id' in the raw frame data file diff --git a/kloppy/infra/serializers/tracking/tracab.py b/kloppy/infra/serializers/tracking/tracab.py index 03962e3b..f7a0e162 100644 --- a/kloppy/infra/serializers/tracking/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -156,7 +156,6 @@ def deserialize(self, inputs: TRACABInputs) -> TrackingDataset: ) with performance_logging("Loading data", logger=logger): - transformer = self.get_transformer( length=pitch_size_width, width=pitch_size_height ) diff --git a/kloppy/tests/files/wyscout_events_v3.json b/kloppy/tests/files/wyscout_events_v3.json index d7eeba1d..689a0abf 100644 --- a/kloppy/tests/files/wyscout_events_v3.json +++ b/kloppy/tests/files/wyscout_events_v3.json @@ -441,7 +441,8 @@ "primary": "duel", "secondary": [ "ground_duel", - "offensive_duel" + "offensive_duel", + "loose_ball_duel" ] }, "location": { diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 179355a9..1be4888c 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -110,9 +110,14 @@ def test_correct_deserialization(self, f7_data: str, f24_data: str): assert dataset.events[20].receiver_coordinates.x == 89.3 # 2360555167 # Check DuelQualifiers - assert dataset.events[7].get_qualifier_values(DuelQualifier)[1].value == DuelType.AERIAL - assert dataset.events[8].get_qualifier_values(DuelQualifier)[1].value == DuelType.GROUND - assert dataset.events[16].get_qualifier_values(DuelQualifier)[1].value == DuelType.STANDING_TACKLE + assert ( + dataset.events[7].get_qualifier_values(DuelQualifier)[1].value + == DuelType.AERIAL + ) + assert ( + dataset.events[8].get_qualifier_values(DuelQualifier)[1].value + == DuelType.GROUND + ) def test_correct_normalized_deserialization( self, f7_data: str, f24_data: str diff --git a/kloppy/tests/test_state_builder.py b/kloppy/tests/test_state_builder.py index 343c73e7..592812ac 100644 --- a/kloppy/tests/test_state_builder.py +++ b/kloppy/tests/test_state_builder.py @@ -29,10 +29,10 @@ def test_score_state_builder(self, base_dir): events_per_score[str(score)] = len(events) assert events_per_score == { - "0-0": 2898, + "0-0": 2909, "1-0": 717, "2-0": 405, - "3-0": 3, + "3-0": 8, } def test_sequence_state_builder(self, base_dir): @@ -92,8 +92,8 @@ def test_formation_state_builder(self, base_dir): events_per_formation_change[str(formation)] = len(events) # inspect FormationChangeEvent usage and formation state_builder - assert events_per_formation_change["4-1-4-1"] == 3074 - assert events_per_formation_change["4-4-2"] == 949 + assert events_per_formation_change["4-1-4-1"] == 3085 + assert events_per_formation_change["4-4-2"] == 954 assert dataset.metadata.teams[0].starting_formation == FormationType( "4-4-2" diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 82020ba6..4a6d329c 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -150,9 +150,14 @@ def test_correct_deserialization( assert dataset.events[3400].get_qualifier_value(PassQualifier) is None - assert dataset.events[194].get_qualifier_values(DuelQualifier)[1].value == DuelType.AERIAL - assert dataset.events[307].get_qualifier_values(DuelQualifier)[1].value == DuelType.STANDING_TACKLE - assert dataset.events[4032].get_qualifier_values(DuelQualifier)[1].value == DuelType.GROUND + assert ( + dataset.events[194].get_qualifier_values(DuelQualifier)[1].value + == DuelType.AERIAL + ) + assert ( + dataset.events[4032].get_qualifier_values(DuelQualifier)[1].value + == DuelType.GROUND + ) def test_correct_normalized_deserialization( self, lineup_data: Path, event_data: Path diff --git a/kloppy/tests/test_to_records.py b/kloppy/tests/test_to_records.py index 72201016..3fc1f4e8 100644 --- a/kloppy/tests/test_to_records.py +++ b/kloppy/tests/test_to_records.py @@ -29,7 +29,7 @@ def dataset(self, event_data: Path, lineup_data: Path) -> EventDataset: def test_default_columns(self, dataset: EventDataset): records = dataset.to_records() - assert len(records) == 4023 + assert len(records) == 4039 assert list(records[0].keys()) == [ "event_id", "event_type", diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index fab413aa..dde07963 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -1,7 +1,13 @@ from pathlib import Path import pytest -from kloppy.domain import Point, SetPieceType, SetPieceQualifier +from kloppy.domain import ( + Point, + SetPieceType, + SetPieceQualifier, + DuelQualifier, + DuelType, +) from kloppy import wyscout @@ -25,6 +31,19 @@ def test_correct_v3_deserialization(self, event_v3_data: Path): ) assert dataset.records[2].coordinates == Point(36.0, 78.0) + assert ( + dataset.events[5].get_qualifier_value(DuelQualifier) + == DuelType.GROUND + ) + assert ( + dataset.events[6].get_qualifier_values(DuelQualifier)[1].value + == DuelType.AERIAL + ) + assert ( + dataset.events[7].get_qualifier_values(DuelQualifier)[2].value + == DuelType.SLIDING_TACKLE + ) + def test_correct_normalized_v3_deserialization(self, event_v3_data: Path): dataset = wyscout.load(event_data=event_v3_data, data_version="V3") assert dataset.records[2].coordinates == Point(0.36, 0.78) @@ -37,6 +56,19 @@ def test_correct_v2_deserialization(self, event_v2_data: Path): ) assert dataset.records[2].coordinates == Point(29.0, 6.0) + assert ( + dataset.events[39].get_qualifier_value(DuelQualifier) + == DuelType.GROUND + ) + assert ( + dataset.events[43].get_qualifier_values(DuelQualifier)[1].value + == DuelType.AERIAL + ) + assert ( + dataset.events[258].get_qualifier_values(DuelQualifier)[2].value + == DuelType.SLIDING_TACKLE + ) + def test_correct_auto_recognize_deserialization(self, event_v2_data: Path): dataset = wyscout.load(event_data=event_v2_data, coordinates="wyscout") assert dataset.records[2].coordinates == Point(29.0, 6.0) diff --git a/kloppy/utils.py b/kloppy/utils.py index fe71fae4..e235ff0c 100644 --- a/kloppy/utils.py +++ b/kloppy/utils.py @@ -96,7 +96,6 @@ def deprecated(reason): """ if isinstance(reason, string_types): - # The @deprecated is used with a 'reason'. # # .. code-block:: python @@ -106,7 +105,6 @@ def deprecated(reason): # pass def decorator(func1): - if inspect.isclass(func1): fmt1 = "Call to deprecated class {name} ({reason})." else: @@ -128,7 +126,6 @@ def new_func1(*args, **kwargs): return decorator elif inspect.isclass(reason) or inspect.isfunction(reason): - # The @deprecated is used without any 'reason'. # # .. code-block:: python From edd180528273a3a848010157267c6ec800be2d86 Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Wed, 5 Jul 2023 16:58:08 +0200 Subject: [PATCH 3/7] Updated test (test_issue_60.py) --- kloppy/tests/issues/issue_60/test_issue_60.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kloppy/tests/issues/issue_60/test_issue_60.py b/kloppy/tests/issues/issue_60/test_issue_60.py index 8841ca13..9af83093 100644 --- a/kloppy/tests/issues/issue_60/test_issue_60.py +++ b/kloppy/tests/issues/issue_60/test_issue_60.py @@ -18,5 +18,5 @@ def test_deleted_event_opta(self): # OPTA F24 file: Pass -> Deleted Event -> Tackle assert event_dataset.events[14].event_name == "pass" assert ( - event_dataset.events[15].event_name == "tackle" + event_dataset.events[15].event_name == "duel" ) # Deleted Event is filter out From 2c9666a4780244f923117dafa11dcb8d3dc41d15 Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Fri, 7 Jul 2023 16:04:34 +0200 Subject: [PATCH 4/7] Small Changes - Koen Feedback --- kloppy/domain/models/event.py | 5 +---- kloppy/infra/serializers/event/opta/deserializer.py | 5 +---- .../infra/serializers/event/statsbomb/deserializer.py | 10 +++++----- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index befc5e28..48d4af2f 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -488,10 +488,7 @@ def get_qualifier_values(self, qualifier_type: Type[Qualifier]): if isinstance(qualifier, qualifier_type): qualifiers.append(qualifier) - if qualifiers: - return qualifiers - - return None + return qualifiers def get_related_events(self) -> List["Event"]: if not self.dataset: diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index e19f59b3..4437fb3f 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -336,10 +336,7 @@ def _parse_duel(raw_qualifiers: List, type_id: int, outcome: int) -> Dict: ] ) - if outcome: - result = DuelResult.WON - else: - result = DuelResult.LOST + result = DuelResult.WON if outcome else DuelResult.LOST return dict( result=result, diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index e7708043..5a3b7a8b 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -462,10 +462,9 @@ def _parse_duel( "type", {} ).get("name") - if outcome_name in DUEL_WON_NAMES: - result = DuelResult.WON - else: - result = DuelResult.LOST + result = ( + DuelResult.WON if outcome_name in DUEL_WON_NAMES else DuelResult.LOST + ) return {"result": result, "qualifiers": qualifiers} @@ -909,7 +908,8 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **duel_event_kwargs, **generic_event_kwargs, ) - new_events.append(duel_event) + # add duel event as first event. + new_events.insert(0, duel_event) for event in new_events: if self.should_include_event(event): From 13ea62d51d8abff313e03712663460850dbfea4d Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Fri, 7 Jul 2023 16:24:04 +0200 Subject: [PATCH 5/7] Merge master in --- kloppy/tests/test_statsbomb.py | 3 +-- kloppy/tests/test_wyscout.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 2cd44070..93b85355 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -159,8 +159,7 @@ def test_correct_deserialization( dataset.events[4032].get_qualifier_values(DuelQualifier)[1].value == DuelType.GROUND ) - - assert dataset.events[271].event_type == EventType.CLEARANCE + assert dataset.events[272].event_type == EventType.CLEARANCE def test_correct_normalized_deserialization( self, lineup_data: Path, event_data: Path diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index cb98dbff..7ed40f57 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -31,7 +31,7 @@ def test_correct_v3_deserialization(self, event_v3_data: Path): data_version="V3", ) assert dataset.records[2].coordinates == Point(36.0, 78.0) - assert dataset.events[5].event_type == EventType.CLEARANCE + assert dataset.events[8].event_type == EventType.CLEARANCE assert ( dataset.events[5].get_qualifier_value(DuelQualifier) From 2727d24bc216ea4f2fbd266f8dddc6bf66007870 Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Fri, 14 Jul 2023 12:04:57 +0200 Subject: [PATCH 6/7] Reverted requirements.txt --- requirements.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index e6a99000..910874d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,8 +5,4 @@ networkx>=2.4 pytest pandas>=1.0.0 polars>=0.16.6 -pre-commit -kloppy~=3.11.0 -pytz~=2023.3 -python-dateutil~=2.8.2 -setuptools~=67.8.0 \ No newline at end of file +pre-commit \ No newline at end of file From c7facb37f251b33202ecc30a649f53d9739d96ad Mon Sep 17 00:00:00 2001 From: MKlaasman Date: Fri, 14 Jul 2023 12:19:08 +0200 Subject: [PATCH 7/7] Merged master in --- kloppy/infra/serializers/event/opta/deserializer.py | 2 +- kloppy/tests/test_wyscout.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 414a11fe..38e235ec 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -717,7 +717,7 @@ def deserialize(self, inputs: OptaInputs) -> EventDataset: **generic_event_kwargs, ) elif (type_id == EVENT_TYPE_FOUL_COMMITTED) and ( - outcome == 0 + outcome == 0 ): event = self.event_factory.build_foul_committed( result=None, diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index 59f82322..493f8ada 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -30,25 +30,26 @@ def test_correct_v3_deserialization(self, event_v3_data: Path): coordinates="wyscout", data_version="V3", ) + df = dataset.to_df() assert dataset.records[2].coordinates == Point(36.0, 78.0) assert ( - dataset.events[4].get_qualifier_value(SetPieceQualifier) - == SetPieceType.CORNER_KICK + dataset.events[4].get_qualifier_value(SetPieceQualifier) + == SetPieceType.CORNER_KICK ) assert dataset.events[5].event_type == EventType.FOUL_COMMITTED assert ( - dataset.events[5].get_qualifier_value(DuelQualifier) + dataset.events[6].get_qualifier_value(DuelQualifier) == DuelType.GROUND ) assert ( - dataset.events[6].get_qualifier_values(DuelQualifier)[1].value + dataset.events[7].get_qualifier_values(DuelQualifier)[1].value == DuelType.AERIAL ) assert ( - dataset.events[7].get_qualifier_values(DuelQualifier)[2].value + dataset.events[8].get_qualifier_values(DuelQualifier)[2].value == DuelType.SLIDING_TACKLE ) - assert dataset.events[8].event_type == EventType.CLEARANCE + assert dataset.events[9].event_type == EventType.CLEARANCE def test_correct_normalized_v3_deserialization(self, event_v3_data: Path): dataset = wyscout.load(event_data=event_v3_data, data_version="V3")