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