diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index f1f48a5d..e41bca24 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -185,6 +185,7 @@ class EventType(Enum): PLAYER_ON (EventType): PLAYER_OFF (EventType): RECOVERY (EventType): + MISCONTROL (EventType): BALL_OUT (EventType): FOUL_COMMITTED (EventType): FORMATION_CHANGE (EventType): @@ -203,6 +204,7 @@ class EventType(Enum): PLAYER_ON = "PLAYER_ON" PLAYER_OFF = "PLAYER_OFF" RECOVERY = "RECOVERY" + MISCONTROL = "MISCONTROL" BALL_OUT = "BALL_OUT" FOUL_COMMITTED = "FOUL_COMMITTED" FORMATION_CHANGE = "FORMATION_CHANGE" @@ -864,6 +866,20 @@ class BallOutEvent(Event): event_name: str = "ball_out" +@dataclass(repr=False) +@docstring_inherit_attributes(Event) +class MiscontrolEvent(Event): + """ + MiscontrolEvent + Attributes: + event_type (EventType): `EventType.MISCONTROL` (See [`EventType`][kloppy.domain.models.event.EventType]) + event_name (str): "miscontrol" + """ + + event_type: EventType = EventType.MISCONTROL + event_name: str = "miscontrol" + + @dataclass(repr=False) @docstring_inherit_attributes(Event) class FoulCommittedEvent(Event): @@ -976,6 +992,7 @@ def generic_record_converter(event: Event): "FormationChangeEvent", "EventDataset", "RecoveryEvent", + "MiscontrolEvent", "FoulCommittedEvent", "BallOutEvent", "SetPieceType", diff --git a/kloppy/domain/services/event_factory.py b/kloppy/domain/services/event_factory.py index 0f550ba5..cb1af4b9 100644 --- a/kloppy/domain/services/event_factory.py +++ b/kloppy/domain/services/event_factory.py @@ -9,6 +9,7 @@ GenericEvent, TakeOnEvent, RecoveryEvent, + MiscontrolEvent, CarryEvent, DuelEvent, ClearanceEvent, @@ -78,6 +79,9 @@ def build_generic(self, **kwargs) -> GenericEvent: def build_recovery(self, **kwargs) -> RecoveryEvent: return create_event(RecoveryEvent, **kwargs) + def build_miscontrol(self, **kwargs) -> MiscontrolEvent: + return create_event(MiscontrolEvent, **kwargs) + def build_take_on(self, **kwargs) -> TakeOnEvent: return create_event(TakeOnEvent, **kwargs) diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 38e235ec..6418c89c 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -72,6 +72,7 @@ EVENT_TYPE_CARD = 17 EVENT_TYPE_RECOVERY = 49 EVENT_TYPE_FORMATION_CHANGE = 40 +EVENT_TYPE_BALL_TOUCH = 61 BALL_OUT_EVENTS = [EVENT_TYPE_BALL_OUT, EVENT_TYPE_CORNER_AWARDED] DUEL_EVENTS = [EVENT_TYPE_TACKLE, EVENT_TYPE_AERIAL, EVENT_TYPE_50_50] @@ -85,6 +86,7 @@ EVENT_TYPE_SHOT_SAVED, EVENT_TYPE_SHOT_GOAL, EVENT_TYPE_RECOVERY, + EVENT_TYPE_BALL_TOUCH, ) EVENT_QUALIFIER_GOAL_KICK = 124 @@ -716,6 +718,12 @@ def deserialize(self, inputs: OptaInputs) -> EventDataset: **duel_event_kwargs, **generic_event_kwargs, ) + elif (type_id == EVENT_TYPE_BALL_TOUCH) & (outcome == 0): + event = self.event_factory.build_miscontrol( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) elif (type_id == EVENT_TYPE_FOUL_COMMITTED) and ( outcome == 0 ): diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index 017b583c..31d9604e 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -52,6 +52,7 @@ SB_EVENT_TYPE_SHOT = 16 SB_EVENT_TYPE_PASS = 30 SB_EVENT_TYPE_50_50 = 33 +SB_EVENT_TYPE_MISCONTROL = 38 SB_EVENT_TYPE_CARRY = 43 SB_EVENT_TYPE_HALF_START = 18 @@ -794,6 +795,13 @@ def deserialize(self, inputs: StatsBombInputs) -> EventDataset: **generic_event_kwargs, ) new_events.append(clearance_event) + elif event_type == SB_EVENT_TYPE_MISCONTROL: + miscontrol_event = self.event_factory.build_miscontrol( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + new_events.append(miscontrol_event) # For dribble and carry the definitions # are flipped between StatsBomb and kloppy elif event_type == SB_EVENT_TYPE_DRIBBLE: diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py index 9b3cdcb8..89908e6f 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py @@ -421,6 +421,18 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: **clearance_event_args, **generic_event_args, ) + elif ( + raw_event["subEventId"] + == wyscout_events.OTHERS_ON_BALL.TOUCH + ) & (_has_tag(raw_event, wyscout_tags.MISSED_BALL)): + miscontrol_event_args = { + "result": None, + "qualifiers": _generic_qualifiers(raw_event), + } + event = self.event_factory.build_miscontrol( + **miscontrol_event_args, + **generic_event_args, + ) else: recovery_event_args = _parse_recovery(raw_event) event = self.event_factory.build_recovery( diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index e5cea060..34cbe4ae 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -71,13 +71,6 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: return team -def _has_tag(raw_event, tag_id) -> bool: - for tag in raw_event["tags"]: - if tag["id"] == tag_id: - return True - return False - - def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]: qualifiers: List[Qualifier] = [] diff --git a/kloppy/tests/files/opta_f24.xml b/kloppy/tests/files/opta_f24.xml index 4abc3e83..95a54799 100644 --- a/kloppy/tests/files/opta_f24.xml +++ b/kloppy/tests/files/opta_f24.xml @@ -264,5 +264,9 @@ + + + + diff --git a/kloppy/tests/test_adapter.py b/kloppy/tests/test_adapter.py index 7eaa3e7a..c0935f0f 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) == 23 + assert len(dataset.events) == 24 diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 51d4a36f..45f43b8f 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -40,7 +40,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) == 23 + assert len(dataset.events) == 24 assert len(dataset.metadata.periods) == 2 assert ( dataset.events[10].ball_owning_team == dataset.metadata.teams[1] @@ -113,6 +113,9 @@ 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 + assert ( + dataset.events[23].event_type == EventType.MISCONTROL + ) # 250913217 # Check counterattack assert ( diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 93b85355..88ad5446 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -160,6 +160,7 @@ def test_correct_deserialization( == DuelType.GROUND ) assert dataset.events[272].event_type == EventType.CLEARANCE + assert dataset.events[68].event_type == EventType.MISCONTROL 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 493f8ada..61e2069f 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -62,6 +62,7 @@ def test_correct_v2_deserialization(self, event_v2_data: Path): data_version="V2", ) 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 (