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