Skip to content

Commit

Permalink
Merge pull request #196 from MKlaasman/feature/add-keeper-actions
Browse files Browse the repository at this point in the history
Add GoalkeeperEvent
  • Loading branch information
koenvo authored Sep 18, 2023
2 parents 8b4b5a1 + 692de42 commit 212cb51
Show file tree
Hide file tree
Showing 18 changed files with 536 additions and 33 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,5 @@ examples/pattern_matching/repository/*.json
*.code-workspace

.*

scratchpad
72 changes: 68 additions & 4 deletions kloppy/domain/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
removes_suffix,
docstring_inherit_attributes,
deprecated,
DeprecatedEnumValue,
)

from .common import DataRecord, Dataset, Player, Team
Expand Down Expand Up @@ -188,6 +189,7 @@ class EventType(Enum):
MISCONTROL (EventType):
BALL_OUT (EventType):
FOUL_COMMITTED (EventType):
GOALKEEPER (EventType):
FORMATION_CHANGE (EventType):
"""

Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]):
"""
Expand Down Expand Up @@ -1002,8 +1064,10 @@ def generic_record_converter(event: Event):
"PassType",
"BodyPart",
"BodyPartQualifier",
"GoalkeeperEvent",
"GoalkeeperQualifier",
"GoalkeeperAction",
"GoalkeeperActionQualifier",
"GoalkeeperActionType",
"CounterAttackQualifier",
"DuelEvent",
"DuelType",
Expand Down
4 changes: 4 additions & 0 deletions kloppy/domain/services/event_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
FoulCommittedEvent,
CardEvent,
SubstitutionEvent,
GoalkeeperEvent,
)

T = TypeVar("T")
Expand Down Expand Up @@ -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)
50 changes: 50 additions & 0 deletions kloppy/infra/serializers/event/opta/deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
BodyPart,
PassType,
PassQualifier,
GoalkeeperQualifier,
GoalkeeperActionType,
CounterAttackQualifier,
)
from kloppy.exceptions import DeserializationError
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 82 additions & 4 deletions kloppy/infra/serializers/event/statsbomb/deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
Ground,
Player,
CardType,
GoalkeeperQualifier,
GoalkeeperActionType,
SetPieceQualifier,
SetPieceType,
FormationType,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -900,15 +981,13 @@ 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,
qualifiers=None,
**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"]
Expand All @@ -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 (
Expand Down
Loading

0 comments on commit 212cb51

Please sign in to comment.