diff --git a/.gitignore b/.gitignore index d7354f77..41589f10 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,5 @@ examples/pattern_matching/repository/*.json *.code-workspace .* + +scratchpad \ No newline at end of file diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index e41bca24..dc1a1d08 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -18,6 +18,7 @@ removes_suffix, docstring_inherit_attributes, deprecated, + DeprecatedEnumValue, ) from .common import DataRecord, Dataset, Player, Team @@ -188,6 +189,7 @@ class EventType(Enum): MISCONTROL (EventType): BALL_OUT (EventType): FOUL_COMMITTED (EventType): + GOALKEEPER (EventType): FORMATION_CHANGE (EventType): """ @@ -207,6 +209,7 @@ class EventType(Enum): MISCONTROL = "MISCONTROL" BALL_OUT = "BALL_OUT" FOUL_COMMITTED = "FOUL_COMMITTED" + GOALKEEPER = "GOALKEEPER" FORMATION_CHANGE = "FORMATION_CHANGE" def __repr__(self): @@ -339,7 +342,7 @@ class BodyPart(Enum): Attributes: RIGHT_FOOT (BodyPart): Pass or Shot with right foot, save with right foot (for goalkeepers). - LEFT_FOOT (BodyPart): Pass or Shot with leftt foot, save with left foot (for goalkeepers). + LEFT_FOOT (BodyPart): Pass or Shot with left foot, save with left foot (for goalkeepers). HEAD (BodyPart): Pass or Shot with head, save with head (for goalkeepers). BOTH_HANDS (BodyPart): Goalkeeper only. Save with both hands. CHEST (BodyPart): Goalkeeper only. Save with chest. @@ -373,13 +376,57 @@ class BodyPartQualifier(EnumQualifier): class GoalkeeperAction(Enum): + """ + Deprecated: GoalkeeperAction has been renamed to GoalkeeperActionType. + + Attributes: + SAVE (GoalkeeperAction): Goalkeeper faces shot and saves. + CLAIM (GoalkeeperAction): Goalkeeper catches cross. + PUNCH (GoalkeeperAction): Goalkeeper punches ball clear. + PICK_UP (GoalkeeperAction): Goalkeeper picks up ball. + SMOTHER (GoalkeeperAction): Goalkeeper coming out to dispossess a player, + equivalent to a tackle for an outfield player. + REFLEX (GoalkeeperAction): Goalkeeper performs a reflex to save a ball. + SAVE_ATTEMPT (GoalkeeperAction): Goalkeeper attempting to save a shot. + """ + + SAVE = DeprecatedEnumValue("SAVE") + CLAIM = DeprecatedEnumValue("CLAIM") + PUNCH = DeprecatedEnumValue("PUNCH") + PICK_UP = DeprecatedEnumValue("PICK_UP") + SMOTHER = DeprecatedEnumValue("SMOTHER") + REFLEX = DeprecatedEnumValue("REFLEX") + SAVE_ATTEMPT = DeprecatedEnumValue("SAVE_ATTEMPT") + + +class GoalkeeperActionType(Enum): + """ + GoalkeeperActionType + + Attributes: + SAVE (GoalkeeperActionType): Goalkeeper faces shot and saves. + CLAIM (GoalkeeperActionType): Goalkeeper catches cross. + PUNCH (GoalkeeperActionType): Goalkeeper punches ball clear. + PICK_UP (GoalkeeperActionType): Goalkeeper picks up ball. + SMOTHER (GoalkeeperActionType): Goalkeeper coming out to dispossess a player, + equivalent to a tackle for an outfield player. + REFLEX (GoalkeeperActionType): Goalkeeper performs a reflex to save a ball. + SAVE_ATTEMPT (GoalkeeperActionType): Goalkeeper attempting to save a shot. + """ + + SAVE = "SAVE" + CLAIM = "CLAIM" + PUNCH = "PUNCH" + PICK_UP = "PICK_UP" + SMOTHER = "SMOTHER" + REFLEX = "REFLEX" SAVE_ATTEMPT = "SAVE_ATTEMPT" @dataclass -class GoalkeeperActionQualifier(EnumQualifier): - value: GoalkeeperAction +class GoalkeeperQualifier(EnumQualifier): + value: GoalkeeperActionType class DuelType(Enum): @@ -895,6 +942,21 @@ class FoulCommittedEvent(Event): event_name: str = "foul_committed" +@dataclass(repr=False) +@docstring_inherit_attributes(Event) +class GoalkeeperEvent(Event): + """ + GoalkeeperEvent + + Attributes: + event_type (EventType): `EventType.GOALKEEPER` (See [`EventType`][kloppy.domain.models.event.EventType]) + event_name (str): "goalkeeper" + """ + + event_type: EventType = EventType.GOALKEEPER + event_name: str = "goalkeeper" + + @dataclass(repr=False) class EventDataset(Dataset[Event]): """ @@ -1002,8 +1064,10 @@ def generic_record_converter(event: Event): "PassType", "BodyPart", "BodyPartQualifier", + "GoalkeeperEvent", + "GoalkeeperQualifier", "GoalkeeperAction", - "GoalkeeperActionQualifier", + "GoalkeeperActionType", "CounterAttackQualifier", "DuelEvent", "DuelType", diff --git a/kloppy/domain/services/event_factory.py b/kloppy/domain/services/event_factory.py index cb1af4b9..d8a7f0e9 100644 --- a/kloppy/domain/services/event_factory.py +++ b/kloppy/domain/services/event_factory.py @@ -20,6 +20,7 @@ FoulCommittedEvent, CardEvent, SubstitutionEvent, + GoalkeeperEvent, ) T = TypeVar("T") @@ -114,3 +115,6 @@ def build_foul_committed(self, **kwargs) -> FoulCommittedEvent: def build_substitution(self, **kwargs) -> SubstitutionEvent: return create_event(SubstitutionEvent, **kwargs) + + def build_goalkeeper_event(self, **kwargs) -> GoalkeeperEvent: + return create_event(GoalkeeperEvent, **kwargs) diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 6418c89c..67808c0b 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -43,6 +43,8 @@ BodyPart, PassType, PassQualifier, + GoalkeeperQualifier, + GoalkeeperActionType, CounterAttackQualifier, ) from kloppy.exceptions import DeserializationError @@ -74,6 +76,19 @@ EVENT_TYPE_FORMATION_CHANGE = 40 EVENT_TYPE_BALL_TOUCH = 61 +EVENT_TYPE_SAVE = 10 +EVENT_TYPE_CLAIM = 11 +EVENT_TYPE_PUNCH = 41 +EVENT_TYPE_KEEPER_PICK_UP = 52 +EVENT_TYPE_SMOTHER = 54 +KEEPER_EVENTS = [ + EVENT_TYPE_SAVE, + EVENT_TYPE_CLAIM, + EVENT_TYPE_PUNCH, + EVENT_TYPE_KEEPER_PICK_UP, + EVENT_TYPE_SMOTHER, +] + BALL_OUT_EVENTS = [EVENT_TYPE_BALL_OUT, EVENT_TYPE_CORNER_AWARDED] DUEL_EVENTS = [EVENT_TYPE_TACKLE, EVENT_TYPE_AERIAL, EVENT_TYPE_50_50] @@ -332,6 +347,14 @@ def _parse_shot( return dict(coordinates=coordinates, result=result, qualifiers=qualifiers) +def _parse_goalkeeper_events(raw_qualifiers: List, type_id: int) -> Dict: + qualifiers = _get_event_qualifiers(raw_qualifiers) + goalkeeper_qualifiers = _get_goalkeeper_qualifiers(type_id) + qualifiers.extend(goalkeeper_qualifiers) + + return dict(result=None, qualifiers=qualifiers) + + def _parse_duel(raw_qualifiers: List, type_id: int, outcome: int) -> Dict: qualifiers = _get_event_qualifiers(raw_qualifiers) if type_id == EVENT_TYPE_TACKLE: @@ -502,6 +525,26 @@ def _get_event_card_qualifiers(raw_qualifiers: List) -> List[Qualifier]: return qualifiers +def _get_goalkeeper_qualifiers(type_id: int) -> List[Qualifier]: + qualifiers = [] + goalkeeper_qualifier = None + if type_id == EVENT_TYPE_SAVE: + goalkeeper_qualifier = GoalkeeperActionType.SAVE + elif type_id == EVENT_TYPE_CLAIM: + goalkeeper_qualifier = GoalkeeperActionType.CLAIM + elif type_id == EVENT_TYPE_PUNCH: + goalkeeper_qualifier = GoalkeeperActionType.PUNCH + elif type_id == EVENT_TYPE_KEEPER_PICK_UP: + goalkeeper_qualifier = GoalkeeperActionType.PICK_UP + elif type_id == EVENT_TYPE_SMOTHER: + goalkeeper_qualifier = GoalkeeperActionType.SMOTHER + + if goalkeeper_qualifier: + qualifiers.append(GoalkeeperQualifier(value=goalkeeper_qualifier)) + + return qualifiers + + def _get_event_counter_attack_qualifiers( raw_qualifiers: List, ) -> List[Qualifier]: @@ -718,6 +761,13 @@ def deserialize(self, inputs: OptaInputs) -> EventDataset: **duel_event_kwargs, **generic_event_kwargs, ) + elif type_id in KEEPER_EVENTS: + goalkeeper_event_kwargs = _parse_goalkeeper_events( + raw_qualifiers, type_id + ) + event = self.event_factory.build_goalkeeper_event( + **goalkeeper_event_kwargs, **generic_event_kwargs + ) elif (type_id == EVENT_TYPE_BALL_TOUCH) & (outcome == 0): event = self.event_factory.build_miscontrol( result=None, diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index 31d9604e..4bf88632 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -22,6 +22,8 @@ Ground, Player, CardType, + GoalkeeperQualifier, + GoalkeeperActionType, SetPieceQualifier, SetPieceType, FormationType, @@ -50,6 +52,7 @@ SB_EVENT_TYPE_CLEARANCE = 9 SB_EVENT_TYPE_DRIBBLE = 14 SB_EVENT_TYPE_SHOT = 16 +SB_EVENT_TYPE_GOALKEEPER_EVENT = 23 SB_EVENT_TYPE_PASS = 30 SB_EVENT_TYPE_50_50 = 33 SB_EVENT_TYPE_MISCONTROL = 38 @@ -118,6 +121,29 @@ SB_BODYPART_OTHER = 70 SB_BODYPART_NO_TOUCH = 106 +SB_GOALKEEPER_COLLECTED = 25 +SB_GOALKEEPER_GOAL_CONCEDED = 26 +SB_GOALKEEPER_KEEPER_SWEEPER = 27 +SB_GOALKEEPER_PENALTY_CONCEDED = 28 +SB_GOALKEEPER_PENALTY_SAVED = 29 +SB_GOALKEEPER_PUNCH = 30 +SB_GOALKEEPER_SAVE = 31 +SB_GOALKEEPER_SHOT_FACED = 32 +SB_GOALKEEPER_SHOT_SAVED = 33 +SB_GOALKEEPER_SMOTHER = 34 + +SB_GOALKEEPER_PENALTY_SAVED_TO_POST = 109 +SB_GOALKEEPER_SAVED_TO_POST = ( + 110 # A save by the goalkeeper that hits the post +) +SB_GOALKEEPER_SHOT_SAVED_OFF_TARGET = 113 +SB_GOALKEEPER_SHOT_SAVED_TO_POST = ( + 114 # A shot saved by the goalkeeper that hits the post +) + +SB_GOALKEEPER_CLAIM = 47 +SB_GOALKEEPER_CLEAR = 48 + SB_TECHNIQUE_THROUGH_BALL = 108 FREEZE_FRAME_FPS = 25 @@ -274,6 +300,33 @@ def _get_set_piece_qualifiers(pass_dict: Dict) -> List[SetPieceQualifier]: return qualifiers +def _get_goalkeeper_qualifiers( + goalkeeper_dict: Dict, +) -> List[SetPieceQualifier]: + qualifiers = [] + save_event_types = [ + SB_GOALKEEPER_SHOT_SAVED, + SB_GOALKEEPER_PENALTY_SAVED_TO_POST, + SB_GOALKEEPER_SAVED_TO_POST, + SB_GOALKEEPER_SHOT_SAVED_OFF_TARGET, + SB_GOALKEEPER_SHOT_SAVED_TO_POST, + ] + if "type" in goalkeeper_dict: + type_id = goalkeeper_dict["type"]["id"] + goalkeeper_qualifier = None + if type_id in save_event_types: + goalkeeper_qualifier = GoalkeeperActionType.SAVE + elif type_id == SB_GOALKEEPER_SMOTHER: + goalkeeper_qualifier = GoalkeeperActionType.SMOTHER + elif type_id == SB_GOALKEEPER_PUNCH: + goalkeeper_qualifier = GoalkeeperActionType.PUNCH + + if goalkeeper_qualifier: + qualifiers.append(GoalkeeperQualifier(value=goalkeeper_qualifier)) + + return qualifiers + + def _parse_pass(pass_dict: Dict, team: Team, fidelity_version: int) -> Dict: if "outcome" in pass_dict: outcome_id = pass_dict["outcome"]["id"] @@ -432,6 +485,15 @@ def _parse_clearance(raw_event: Dict, events: List) -> Dict: return {"qualifiers": qualifiers} +def _parse_goalkeeper_event(goalkeeper_dict: Dict) -> Dict: + qualifiers = [] + goalkeeper_qualifiers = _get_goalkeeper_qualifiers(goalkeeper_dict) + qualifiers.extend(goalkeeper_qualifiers) + body_part_qualifiers = _get_body_part_qualifiers(goalkeeper_dict) + qualifiers.extend(body_part_qualifiers) + return {"result": None, "qualifiers": qualifiers} + + def _parse_take_on(take_on_dict: Dict) -> Dict: if "outcome" in take_on_dict: outcome_id = take_on_dict["outcome"]["id"] @@ -836,7 +898,26 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **generic_event_kwargs, ) new_events.append(duel_event) - + elif event_type == SB_EVENT_TYPE_GOALKEEPER_EVENT: + goalkeeper_event_kwargs = _parse_goalkeeper_event( + goalkeeper_dict=raw_event["goalkeeper"], + ) + if goalkeeper_event_kwargs["qualifiers"]: + goalkeeper_event = ( + self.event_factory.build_goalkeeper_event( + **goalkeeper_event_kwargs, + **generic_event_kwargs, + ) + ) + new_events.append(goalkeeper_event) + else: + generic_event = self.event_factory.build_generic( + result=None, + qualifiers=None, + event_name=raw_event["type"]["name"], + **generic_event_kwargs, + ) + new_events.append(generic_event) # lineup affecting events elif event_type == SB_EVENT_TYPE_SUBSTITUTION: substitution_event_kwargs = _parse_substitution( @@ -900,7 +981,6 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **generic_event_kwargs, ) new_events.append(player_off_event) - elif event_type == SB_EVENT_TYPE_RECOVERY: recovery_event = self.event_factory.build_recovery( result=None, @@ -908,7 +988,6 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **generic_event_kwargs, ) new_events.append(recovery_event) - elif event_type == SB_EVENT_TYPE_FORMATION_CHANGE: formation_change_event_kwargs = _parse_formation_change( raw_event["tactics"]["formation"] @@ -931,7 +1010,6 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **generic_event_kwargs, ) new_events.append(generic_event) - # Add possible aerial won - Applicable to multiple event types for type_name in ["shot", "clearance", "miscontrol", "pass"]: if ( diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py index 89908e6f..26d58d20 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py @@ -12,8 +12,8 @@ DuelQualifier, DuelType, EventDataset, - GoalkeeperAction, - GoalkeeperActionQualifier, + GoalkeeperQualifier, + GoalkeeperActionType, Ground, Metadata, Orientation, @@ -97,12 +97,10 @@ def _parse_shot(raw_event: Dict, next_event: Dict) -> Dict: if next_event["eventId"] == wyscout_events.SAVE.EVENT: if next_event["subEventId"] == wyscout_events.SAVE.REFLEXES: - qualifiers.append( - GoalkeeperActionQualifier(GoalkeeperAction.REFLEX) - ) + qualifiers.append(GoalkeeperQualifier(GoalkeeperActionType.REFLEX)) if next_event["subEventId"] == wyscout_events.SAVE.SAVE_ATTEMPT: qualifiers.append( - GoalkeeperActionQualifier(GoalkeeperAction.SAVE_ATTEMPT) + GoalkeeperQualifier(GoalkeeperActionType.SAVE_ATTEMPT) ) return { @@ -181,6 +179,25 @@ def _parse_clearance(raw_event: Dict) -> Dict: return {"result": None, "qualifiers": qualifiers} +def _parse_goalkeeper_save(raw_event) -> List[Qualifier]: + qualifiers = _generic_qualifiers(raw_event) + goalkeeper_qualifiers = [] + if not _has_tag(raw_event, wyscout_tags.GOAL): + goalkeeper_qualifiers.append( + GoalkeeperQualifier(value=GoalkeeperActionType.SAVE) + ) + else: + goalkeeper_qualifiers.append( + GoalkeeperQualifier(value=GoalkeeperActionType.SAVE_ATTEMPT) + ) + if raw_event["subEventId"] == wyscout_events.SAVE.REFLEXES: + goalkeeper_qualifiers.append( + GoalkeeperQualifier(value=GoalkeeperActionType.REFLEX) + ) + qualifiers.extend(goalkeeper_qualifiers) + return {"result": None, "qualifiers": qualifiers} + + def _parse_foul(raw_event: Dict) -> Dict: qualifiers = _generic_qualifiers(raw_event) return { @@ -390,6 +407,11 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: event = self.event_factory.build_ball_out( **ball_out_event_args, **generic_event_args ) + elif raw_event["eventId"] == wyscout_events.SAVE.EVENT: + goalkeeper_save_args = _parse_goalkeeper_save(raw_event) + event = self.event_factory.build_goalkeeper_event( + **goalkeeper_save_args, **generic_event_args + ) elif raw_event["eventId"] == wyscout_events.FREE_KICK.EVENT: set_piece_event_args = _parse_set_piece( raw_event, next_event diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 34cbe4ae..36c86966 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -16,8 +16,8 @@ EventDataset, FoulCommittedEvent, GenericEvent, - GoalkeeperAction, - GoalkeeperActionQualifier, + GoalkeeperQualifier, + GoalkeeperActionType, Ground, Metadata, Orientation, @@ -203,6 +203,24 @@ def _parse_clearance(raw_event: Dict) -> Dict: } +def _parse_goalkeeper_save(raw_event: Dict) -> Dict: + qualifiers = _generic_qualifiers(raw_event) + + goalkeeper_qualifiers = [] + if "save" in raw_event["type"]["secondary"]: + goalkeeper_qualifiers.append( + GoalkeeperQualifier(value=GoalkeeperActionType.SAVE) + ) + + if "save_with_reflex" == "save_with_reflex": + goalkeeper_qualifiers.append( + GoalkeeperQualifier(value=GoalkeeperActionType.REFLEX) + ) + qualifiers.extend(goalkeeper_qualifiers) + + return {"result": None, "qualifiers": qualifiers} + + def _parse_ball_out(raw_event: Dict) -> Dict: qualifiers = _generic_qualifiers(raw_event) return {"result": None, "qualifiers": qualifiers} @@ -421,6 +439,13 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: event = self.event_factory.build_clearance( **clearance_event_args, **generic_event_args ) + elif (primary_event_type == "shot_against") & ( + "save" in raw_event["type"]["secondary"] + ): + goalkeeper_save_args = _parse_goalkeeper_save(raw_event) + event = self.event_factory.build_goalkeeper_event( + **goalkeeper_save_args, **generic_event_args + ) elif ( (primary_event_type in ["throw_in", "goal_kick"]) or ( diff --git a/kloppy/tests/files/opta_f24.xml b/kloppy/tests/files/opta_f24.xml index 95a54799..fa411c02 100644 --- a/kloppy/tests/files/opta_f24.xml +++ b/kloppy/tests/files/opta_f24.xml @@ -219,17 +219,17 @@ - + - - + + - - + + @@ -238,7 +238,7 @@ - + @@ -264,6 +264,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kloppy/tests/files/statsbomb_event.json b/kloppy/tests/files/statsbomb_event.json index 6af07749..af949022 100644 --- a/kloppy/tests/files/statsbomb_event.json +++ b/kloppy/tests/files/statsbomb_event.json @@ -170300,5 +170300,92 @@ }, "duration" : 0.0, "related_events" : [ "e1cc4d5e-ba55-4b6b-88cc-dae13311c1d9" ] +}, { + "id" : "f93a1612-e2de-4efe-b202-6c4a178eebad", + "index" : 4003, + "period" : 2, + "timestamp" : "00:47:32.053", + "minute" : 93, + "second" : 43, + "type" : { + "id" : 23, + "name" : "Goal Keeper" + }, + "possession" : 144, + "possession_team" : { + "id" : 217, + "name" : "Barcelona" + }, + "play_pattern" : { + "id" : 1, + "name" : "Regular Play" + }, + "team" : { + "id" : 206, + "name" : "Deportivo Alavés" + }, + "player" : { + "id" : 6629, + "name" : "Fernando Pacheco Flores" + }, + "position" : { + "id" : 1, + "name" : "Goalkeeper" + }, + "location" : [ 3.0, 43.3 ], + "duration" : 0.0, + "related_events" : [ "55d71847-9511-4417-aea9-6f415e279011" ], + "goalkeeper" : { + "type" : { + "id" : 34, + "name" : "Smother" + } + } +}, { + "id" : "f93a1612-e2de-4efe-b202-6c4a178eebad", + "index" : 4004, + "period" : 2, + "timestamp" : "00:47:32.053", + "minute" : 93, + "second" : 43, + "type" : { + "id" : 23, + "name" : "Goal Keeper" + }, + "possession" : 144, + "possession_team" : { + "id" : 217, + "name" : "Barcelona" + }, + "play_pattern" : { + "id" : 1, + "name" : "Regular Play" + }, + "team" : { + "id" : 206, + "name" : "Deportivo Alavés" + }, + "player" : { + "id" : 6629, + "name" : "Fernando Pacheco Flores" + }, + "position" : { + "id" : 1, + "name" : "Goalkeeper" + }, + "location" : [ 3.0, 43.3 ], + "duration" : 0.0, + "related_events" : [ "55d71847-9511-4417-aea9-6f415e279011" ], + "goalkeeper" : { + "outcome" : { + "id" : 117, + "name" : "Punched out" + }, + "punched_out" : true, + "type" : { + "id" : 30, + "name" : "Punch" + } + } } ] \ 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 ac962c69..403c95fa 100644 --- a/kloppy/tests/files/wyscout_events_v3.json +++ b/kloppy/tests/files/wyscout_events_v3.json @@ -786,6 +786,91 @@ }, "attack": null } + }, + { + "id": 1331979498, + "matchId": 5352285, + "matchPeriod": "2H", + "minute": 1, + "second": 0, + "matchTimestamp": "00:1:00.585", + "videoTimestamp": "60.585284", + "relatedEventId": 82914010, + "type": { + "primary": "shot_against", + "secondary": [ + "save", + "save_with_reflex" + ] + }, + "location": { + "x": 1, + "y": 49 + }, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "opponentTeam": { + "formation": "3-4-3", + "id": 3185, + "name": "Torino" + }, + "player": { + "id": 99430, + "name": "Ł. Skorupski", + "position": "GK" + }, + "pass": null, + "shot": null, + "groundDuel": null, + "aerialDuel": null, + "infraction": null, + "carry": null, + "possession": null + }, + { + "id": 1331979079, + "matchId": 5352285, + "matchPeriod": "2H", + "minute": 1, + "second": 2, + "matchTimestamp": "01:00:02.254", + "videoTimestamp": "62.254", + "relatedEventId": 13319790709, + "type": { + "primary": "shot_against", + "secondary": [ + "conceded_goal" + ] + }, + "location": { + "x": 0, + "y": 50 + }, + "team": { + "formation": "4-2-3-1", + "id": 3166, + "name": "Bologna" + }, + "opponentTeam": { + "formation": "3-4-3", + "id": 3185, + "name": "Torino" + }, + "player": { + "id": 99430, + "name": "Ł. Skorupski", + "position": "GK" + }, + "pass": null, + "shot": null, + "groundDuel": null, + "aerialDuel": null, + "infraction": null, + "carry": null, + "possession": null } ], "formations": { diff --git a/kloppy/tests/test_adapter.py b/kloppy/tests/test_adapter.py index c0935f0f..772f254c 100644 --- a/kloppy/tests/test_adapter.py +++ b/kloppy/tests/test_adapter.py @@ -57,4 +57,4 @@ def read_to_stream(self, url: str, output: BinaryIO): # Asserts borrowed from `test_opta.py` assert dataset.metadata.provider == Provider.OPTA assert dataset.dataset_type == DatasetType.EVENT - assert len(dataset.events) == 24 + assert len(dataset.events) == 29 diff --git a/kloppy/tests/test_helpers.py b/kloppy/tests/test_helpers.py index c687ed2a..2e5a8822 100644 --- a/kloppy/tests/test_helpers.py +++ b/kloppy/tests/test_helpers.py @@ -376,7 +376,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 == 4039 + assert c == 4041 def test_tracking_dataset_to_polars(self): """ diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 45f43b8f..777bd5a0 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -13,6 +13,8 @@ DatasetType, CardType, FormationType, + GoalkeeperQualifier, + GoalkeeperActionType, DuelQualifier, DuelType, CounterAttackQualifier, @@ -40,7 +42,7 @@ def test_correct_deserialization(self, f7_data: str, f24_data: str): ) assert dataset.metadata.provider == Provider.OPTA assert dataset.dataset_type == DatasetType.EVENT - assert len(dataset.events) == 24 + assert len(dataset.events) == 29 assert len(dataset.metadata.periods) == 2 assert ( dataset.events[10].ball_owning_team == dataset.metadata.teams[1] @@ -113,8 +115,30 @@ def test_correct_deserialization(self, f7_data: str, f24_data: str): assert dataset.events[18].result.value == "OWN_GOAL" # 2318697001 # Check OFFSIDE pass has end_coordinates assert dataset.events[20].receiver_coordinates.x == 89.3 # 2360555167 + + # Check goalkeeper qualifiers + assert ( + dataset.events[23].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.SAVE + ) + assert ( + dataset.events[24].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.CLAIM + ) + assert ( + dataset.events[25].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.PUNCH + ) + assert ( + dataset.events[26].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.PICK_UP + ) + assert ( + dataset.events[27].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.SMOTHER + ) assert ( - dataset.events[23].event_type == EventType.MISCONTROL + dataset.events[28].event_type == EventType.MISCONTROL ) # 250913217 # Check counterattack diff --git a/kloppy/tests/test_state_builder.py b/kloppy/tests/test_state_builder.py index 592812ac..e9ef412c 100644 --- a/kloppy/tests/test_state_builder.py +++ b/kloppy/tests/test_state_builder.py @@ -32,7 +32,7 @@ def test_score_state_builder(self, base_dir): "0-0": 2909, "1-0": 717, "2-0": 405, - "3-0": 8, + "3-0": 10, } def test_sequence_state_builder(self, base_dir): @@ -93,7 +93,7 @@ def test_formation_state_builder(self, base_dir): # inspect FormationChangeEvent usage and formation state_builder assert events_per_formation_change["4-1-4-1"] == 3085 - assert events_per_formation_change["4-4-2"] == 954 + assert events_per_formation_change["4-4-2"] == 956 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 88ad5446..745d1fec 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -25,6 +25,8 @@ PassQualifier, PassType, EventType, + GoalkeeperQualifier, + GoalkeeperActionType, ) @@ -53,7 +55,7 @@ def test_correct_deserialization( assert dataset.metadata.provider == Provider.STATSBOMB assert dataset.dataset_type == DatasetType.EVENT - assert len(dataset.events) == 4039 + assert len(dataset.events) == 4041 assert len(dataset.metadata.periods) == 2 assert ( dataset.metadata.orientation == Orientation.ACTION_EXECUTING_TEAM @@ -162,6 +164,21 @@ def test_correct_deserialization( assert dataset.events[272].event_type == EventType.CLEARANCE assert dataset.events[68].event_type == EventType.MISCONTROL + assert ( + dataset.events[761].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.SAVE + ) + + assert ( + dataset.events[4039].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.SMOTHER + ) + + assert ( + dataset.events[4040].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.PUNCH + ) + 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 3fc1f4e8..232825c6 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) == 4039 + assert len(records) == 4041 assert list(records[0].keys()) == [ "event_id", "event_type", diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index 61e2069f..56c552b7 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -8,6 +8,8 @@ DuelQualifier, DuelType, EventType, + GoalkeeperQualifier, + GoalkeeperActionType, ) from kloppy import wyscout @@ -30,8 +32,11 @@ 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[10].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.SAVE + ) assert ( dataset.events[4].get_qualifier_value(SetPieceQualifier) == SetPieceType.CORNER_KICK @@ -63,7 +68,7 @@ def test_correct_v2_deserialization(self, event_v2_data: Path): ) assert dataset.records[2].coordinates == Point(29.0, 6.0) assert dataset.events[11].event_type == EventType.MISCONTROL - assert dataset.events[136].event_type == EventType.CLEARANCE + assert dataset.events[137].event_type == EventType.CLEARANCE assert ( dataset.events[39].get_qualifier_value(DuelQualifier) @@ -74,9 +79,13 @@ def test_correct_v2_deserialization(self, event_v2_data: Path): == DuelType.AERIAL ) assert ( - dataset.events[258].get_qualifier_values(DuelQualifier)[2].value + dataset.events[259].get_qualifier_values(DuelQualifier)[2].value == DuelType.SLIDING_TACKLE ) + assert ( + dataset.events[291].get_qualifier_value(GoalkeeperQualifier) + == GoalkeeperActionType.SAVE + ) def test_correct_auto_recognize_deserialization(self, event_v2_data: Path): dataset = wyscout.load(event_data=event_v2_data, coordinates="wyscout") diff --git a/kloppy/utils.py b/kloppy/utils.py index e235ff0c..b0858398 100644 --- a/kloppy/utils.py +++ b/kloppy/utils.py @@ -156,3 +156,16 @@ def new_func2(*args, **kwargs): else: raise TypeError(repr(type(reason))) + + +class DeprecatedEnumValue: + def __init__(self, value): + self.value = value + + def __get__(self, instance, owner): + warnings.warn( + f"{owner.__name__} is deprecated. Use GoalkeeperActionType instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.value