Skip to content

Commit

Permalink
Merge pull request #224 from probberechts/feat/statsbomb-goalkeeper-c…
Browse files Browse the repository at this point in the history
…laim

Implement GoalkeeperActionType.PICK_UP and GoalkeeperActionType.CLAIM for StatsBomb
  • Loading branch information
koenvo authored Dec 29, 2023
2 parents 0de2285 + 3bce8f8 commit 7ad771b
Show file tree
Hide file tree
Showing 2 changed files with 92 additions and 34 deletions.
114 changes: 89 additions & 25 deletions kloppy/infra/serializers/event/statsbomb/specification.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,19 +859,104 @@ class KEEPER_SWEEPER:
class OUTCOME(Enum, metaclass=TypesEnumMeta):
CLAIM = 47
CLEAR = 48
WON = 4
SUCCESS = 15

def _create_events(
self, event_factory: EventFactory, **generic_event_kwargs
) -> List[Event]:
goalkeeper_dict = self.raw_event["goalkeeper"]
generic_event_kwargs = self._parse_generic_kwargs()
qualifiers = _get_goalkeeper_qualifiers(
self.raw_event["goalkeeper"]
) + _get_body_part_qualifiers(self.raw_event["goalkeeper"])

# parse body part
body_part_qualifiers = _get_body_part_qualifiers(goalkeeper_dict)
hands_used = any(
q.value
in [
BodyPart.LEFT_HAND,
BodyPart.RIGHT_HAND,
BodyPart.BOTH_HANDS,
]
for q in body_part_qualifiers
)
head_or_foot_used = any(
q.value
in [
BodyPart.LEFT_FOOT,
BodyPart.RIGHT_FOOT,
BodyPart.HEAD,
]
for q in body_part_qualifiers
)
bodypart_missing = len(body_part_qualifiers) == 0

# parse action type qualifiers
save_event_types = [
GOALKEEPER.TYPE.SHOT_SAVED,
GOALKEEPER.TYPE.PENALTY_SAVED_TO_POST,
GOALKEEPER.TYPE.SAVED_TO_POST,
GOALKEEPER.TYPE.SHOT_SAVED_OFF_TARGET,
GOALKEEPER.TYPE.SHOT_SAVED_TO_POST,
]
type_id = GOALKEEPER.TYPE(goalkeeper_dict.get("type", {}).get("id"))
outcome_id = goalkeeper_dict.get("outcome", {}).get("id")
qualifiers = []
if type_id in save_event_types:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.SAVE)
)
elif type_id == GOALKEEPER.TYPE.SMOTHER:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.SMOTHER)
)
elif type_id == GOALKEEPER.TYPE.PUNCH:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.PUNCH)
)
elif type_id == GOALKEEPER.TYPE.COLLECTED:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.CLAIM)
)
elif type_id == GOALKEEPER.TYPE.KEEPER_SWEEPER:
outcome_id = GOALKEEPER.KEEPER_SWEEPER.OUTCOME(
goalkeeper_dict.get("outcome", {}).get("id")
)
if outcome_id == GOALKEEPER.KEEPER_SWEEPER.OUTCOME.CLAIM:
# a goalkeeper can only pick up the ball with his hands
if hands_used or bodypart_missing:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.PICK_UP)
)
# otherwise it's a recovery
else:
recovery = event_factory.build_recovery(
result=None,
qualifiers=body_part_qualifiers,
**generic_event_kwargs,
)
return [recovery]
elif outcome_id in [
GOALKEEPER.KEEPER_SWEEPER.OUTCOME.CLEAR,
GOALKEEPER.KEEPER_SWEEPER.OUTCOME.SUCCESS,
]:
# if the goalkeeper uses his foot or head, it's a clearance
if head_or_foot_used:
clearance = event_factory.build_clearance(
result=None,
qualifiers=body_part_qualifiers,
**generic_event_kwargs,
)
return [clearance]
# otherwise, it's a save
else:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.SAVE)
)

if qualifiers:
goalkeeper_event = event_factory.build_goalkeeper_event(
result=None,
qualifiers=qualifiers,
qualifiers=qualifiers + body_part_qualifiers,
**generic_event_kwargs,
)
return [goalkeeper_event]
Expand Down Expand Up @@ -1174,27 +1259,6 @@ def _get_set_piece_qualifiers(
return []


def _get_goalkeeper_qualifiers(
goalkeeper_dict: Dict,
) -> List[GoalkeeperQualifier]:
sb_to_kloppy_goalkeeper_mapping = {
GOALKEEPER.TYPE.SHOT_SAVED: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.PENALTY_SAVED_TO_POST: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SAVED_TO_POST: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SHOT_SAVED_OFF_TARGET: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SHOT_SAVED_TO_POST: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SMOTHER: GoalkeeperActionType.SMOTHER,
GOALKEEPER.TYPE.PUNCH: GoalkeeperActionType.PUNCH,
}
if "type" in goalkeeper_dict:
type_id = GOALKEEPER.TYPE(goalkeeper_dict["type"])
if type_id in sb_to_kloppy_goalkeeper_mapping:
kloppy_event_type = sb_to_kloppy_goalkeeper_mapping[type_id]
return [GoalkeeperQualifier(value=kloppy_event_type)]

return []


def event_decoder(raw_event: Dict) -> Union[EVENT, Dict]:
type_to_event = {
EVENT_TYPE.PASS: PASS,
Expand Down
12 changes: 3 additions & 9 deletions kloppy/tests/test_statsbomb.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ class TestStatsBombClearanceEvent:
def test_deserialize_all(self, dataset: EventDataset):
"""It should deserialize all clearance events"""
events = dataset.find_all("clearance")
assert len(events) == 35
assert len(events) == 35 + 1 # clearances + keeper sweeper

def test_attributes(self, dataset: EventDataset):
"""Verify specific attributes of clearances"""
Expand Down Expand Up @@ -867,10 +867,8 @@ def test_loose_ground_duel_qualfiers(self, dataset: EventDataset):
class TestStatsBombGoalkeeperEvent:
"""Tests related to deserializing 30/Goalkeeper events"""

@pytest.mark.xfail
def test_deserialize_all(self, dataset: EventDataset):
"""It should deserialize all goalkeeper events"""
# TODO: not yet fully implemented, see #225
events = dataset.find_all("goalkeeper")
assert (
len(events) == 32 - 24 - 1 - 1
Expand Down Expand Up @@ -901,29 +899,25 @@ def test_smother(self, dataset: EventDataset):
"""It should deserialize goalkeeper smothers"""
assert True # no example in the dataset

@pytest.mark.xfail
def test_collected(self, dataset: EventDataset):
"""It should deserialize goalkeeper collections"""
# TODO: not yet implemented, see #225
collected = dataset.get_event_by_id(
"5156545b-7add-4b6a-a8e4-c68672267464"
)
assert collected.get_qualifier_value(GoalkeeperQualifier) == (
GoalkeeperActionType.CLAIM
)

@pytest.mark.xfail
def test_keeper_sweeper(self, dataset: EventDataset):
"""It should deserialize keeper sweeper actions"""
# TODO: not yet implemented, see #225
# keeper sweeper with outcome 'clear' should be deserialized as
# as a clearance event
# as a clearance event if the keeper uses his feet or head
sweeper_clear = dataset.get_event_by_id(
"6c84a193-d45b-4d6e-97bc-3f07af9001db"
)
assert sweeper_clear.event_type == EventType.CLEARANCE
# keeper sweeper with outcome 'claim' should be deserialized as
# a goalkeeper pick-up event
# a goalkeeper pick-up event if the keeper uses his hands
sweeper_claim = dataset.get_event_by_id(
"460f558e-c951-4262-b467-e078ea1faefc"
)
Expand Down

0 comments on commit 7ad771b

Please sign in to comment.