Skip to content

Commit

Permalink
Merge branch 'master' into feature/statistics
Browse files Browse the repository at this point in the history
# Conflicts:
#	kloppy/infra/serializers/event/statsbomb/helpers.py
#	kloppy/infra/serializers/event/statsbomb/specification.py
#	kloppy/infra/serializers/event/statsperform/deserializer.py
#	kloppy/infra/serializers/tracking/statsperform.py
#	kloppy/infra/serializers/tracking/tracab/tracab_json.py
  • Loading branch information
DriesDeprest committed Nov 19, 2024
2 parents 9a08aa1 + ef2c398 commit 9b6f9af
Show file tree
Hide file tree
Showing 47 changed files with 157,382 additions and 4,464 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ jobs:
pip install black==22.3.0
black --check .
- name: Test with pytest
if: '!cancelled()'
run: |
pytest --color=yes
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/ambv/black
rev: 23.3.0
rev: 22.3.0
hooks:
- id: black
language_version: python3
2 changes: 2 additions & 0 deletions kloppy/_providers/statsbomb.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def load(
event_types: Optional[List[str]] = None,
coordinates: Optional[str] = None,
event_factory: Optional[EventFactory] = None,
additional_metadata: dict = {},
) -> EventDataset:
"""
Load StatsBomb event data into a [`EventDataset`][kloppy.domain.models.event.EventDataset]
Expand Down Expand Up @@ -48,6 +49,7 @@ def load(
lineup_data=lineup_data_fp,
three_sixty_data=three_sixty_data_fp,
),
additional_metadata=additional_metadata,
)


Expand Down
56 changes: 29 additions & 27 deletions kloppy/domain/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
Iterable,
)

from .position import PositionType

from ...utils import deprecated

if sys.version_info >= (3, 8):
Expand All @@ -33,7 +35,6 @@
from .pitch import (
PitchDimensions,
Unit,
Point,
Dimension,
NormalizedPitchDimensions,
MetricPitchDimensions,
Expand All @@ -47,7 +48,6 @@
OrientationError,
InvalidFilterError,
KloppyParameterError,
KloppyError,
)


Expand Down Expand Up @@ -119,20 +119,6 @@ def __str__(self):
return self.value


@dataclass(frozen=True)
class Position:
position_id: str
name: str
coordinates: Optional[Point] = None

def __str__(self):
return self.name

@classmethod
def unknown(cls) -> "Position":
return cls(position_id="", name="Unknown")


@dataclass(frozen=True)
class Player:
"""
Expand All @@ -157,8 +143,8 @@ class Player:

# match specific
starting: bool = False
starting_position: Optional[Position] = None
positions: TimeContainer[Position] = field(
starting_position: Optional[PositionType] = None
positions: TimeContainer[PositionType] = field(
default_factory=TimeContainer, compare=False
)

Expand All @@ -174,7 +160,7 @@ def full_name(self):

@property
@deprecated("starting_position or positions should be used")
def position(self) -> Optional[Position]:
def position(self) -> Optional[PositionType]:
try:
return self.positions.last()
except KeyError:
Expand All @@ -191,7 +177,7 @@ def __eq__(self, other):
return False
return self.player_id == other.player_id

def set_position(self, time: Time, position: Optional[Position]):
def set_position(self, time: Time, position: Optional[PositionType]):
self.positions.set(time, position)


Expand All @@ -211,6 +197,9 @@ class Team:
name: str
ground: Ground
starting_formation: Optional[FormationType] = None
formations: TimeContainer[FormationType] = field(
default_factory=TimeContainer, compare=False
)
players: List[Player] = field(default_factory=list)

def __str__(self):
Expand All @@ -232,11 +221,12 @@ def get_player_by_jersey_number(self, jersey_no: int):

return None

def get_player_by_position(self, position_id: Union[int, str]):
position_id = str(position_id)
def get_player_by_position(self, position: PositionType, time: Time):
for player in self.players:
if player.position and player.position.position_id == position_id:
return player
if player.positions.items:
player_position = player.positions.value_at(time)
if player_position and player_position == position:
return player

return None

Expand All @@ -249,6 +239,9 @@ def get_player_by_id(self, player_id: Union[int, str]):

return None

def set_formation(self, time: Time, formation: Optional[FormationType]):
self.formations.set(time, formation)


class BallState(Enum):
"""
Expand Down Expand Up @@ -1072,6 +1065,10 @@ class Metadata:
orientation: See [`Orientation`][kloppy.domain.models.common.Orientation]
flags:
provider: See [`Provider`][kloppy.domain.models.common.Provider]
date: Date of the game.
game_week: Game week (or match day) of the game. It can also be the stage
(ex: "8th Finals"), if the game is happening during a cup or a play-off.
game_id: Game id of the game from the provider.
"""

teams: List[Team]
Expand All @@ -1083,6 +1080,11 @@ class Metadata:
coordinate_system: CoordinateSystem
score: Optional[Score] = None
frame_rate: Optional[float] = None
date: Optional[datetime] = None
game_week: Optional[str] = None
game_id: Optional[str] = None
home_coach: Optional[str] = None
away_coach: Optional[str] = None
attributes: Optional[Dict] = field(default_factory=dict, compare=False)

def __post_init__(self):
Expand Down Expand Up @@ -1138,7 +1140,7 @@ def __post_init__(self):
)

self._init_player_positions()
self._update_player_positions()
self._update_formations_and_positions()

def _init_player_positions(self):
start_of_match = self.metadata.periods[0].start_time
Expand All @@ -1147,10 +1149,10 @@ def _init_player_positions(self):
if player.starting:
player.set_position(
start_of_match,
player.starting_position or Position.unknown(),
player.starting_position or PositionType.unknown(),
)

def _update_player_positions(self):
def _update_formations_and_positions(self):
"""Update player positions based on the events for example."""
pass

Expand Down
49 changes: 34 additions & 15 deletions kloppy/domain/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
DatasetType,
AttackingDirection,
OrientationError,
Position,
PositionType,
)
from kloppy.utils import (
camelcase_to_snakecase,
Expand Down Expand Up @@ -881,7 +881,7 @@ class SubstitutionEvent(Event):
"""

replacement_player: Player
position: Optional[Position] = None
position: Optional[PositionType] = None

event_type: EventType = EventType.SUBSTITUTION
event_name: str = "substitution"
Expand Down Expand Up @@ -948,7 +948,7 @@ class FormationChangeEvent(Event):
"""

formation_type: FormationType
player_positions: Optional[Dict[Player, Position]] = None
player_positions: Optional[Dict[Player, PositionType]] = None

event_type: EventType = EventType.FORMATION_CHANGE
event_name: str = "formation_change"
Expand Down Expand Up @@ -1062,8 +1062,8 @@ class EventDataset(Dataset[Event]):

dataset_type: DatasetType = DatasetType.EVENT

def _update_player_positions(self):
"""Update player positions based on Substitution and TacticalShift events."""
def _update_formations_and_positions(self):
"""Update team formations and player positions based on Substitution and TacticalShift events."""
max_leeway = timedelta(seconds=60)

for event in self.events:
Expand All @@ -1078,17 +1078,36 @@ def _update_player_positions(self):
elif isinstance(event, FormationChangeEvent):
if event.player_positions:
for player, position in event.player_positions.items():
last_time, last_position = player.positions.last(
include_time=True
if len(player.positions.items):
last_time, last_position = player.positions.last(
include_time=True
)
if last_position != position:
# Only update when the position changed
if event.time - last_time < max_leeway:
# Often the formation change is detected a couple of seconds after a Substitution.
# In this case we need to use the time of the Substitution
player.positions.set(last_time, position)
else:
player.positions.set(event.time, position)

if event.team.formations.items:
last_time, last_formation = event.team.formations.last(
include_time=True
)
if last_formation != event.formation_type:
event.team.formations.set(
event.time, event.formation_type
)

elif event.team.starting_formation:
if event.team.starting_formation != event.formation_type:
event.team.formations.set(
event.time, event.formation_type
)
if last_position != position:
# Only update when the position changed
if event.time - last_time < max_leeway:
# Often the formation change is detected a couple of seconds after a Substitution.
# In this case we need to use the time of the Substitution
player.positions.set(last_time, position)
else:
player.positions.set(event.time, position)

else:
event.team.formations.set(event.time, event.formation_type)

@property
def events(self):
Expand Down
92 changes: 92 additions & 0 deletions kloppy/domain/models/position.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from enum import Enum


class PositionType(Enum):
Unknown = ("Unknown", "UNK", None)

Goalkeeper = ("Goalkeeper", "GK", None)

Defender = ("Defender", "DEF", None)
FullBack = ("Full Back", "FB", "Defender")
LeftBack = ("Left Back", "LB", "FullBack")
RightBack = ("Right Back", "RB", "FullBack")
CenterBack = ("Center Back", "CB", "Defender")
LeftCenterBack = ("Left Center Back", "LCB", "CenterBack")
RightCenterBack = ("Right Center Back", "RCB", "CenterBack")

Midfielder = ("Midfielder", "MID", None)
DefensiveMidfield = ("Defensive Midfield", "DM", "Midfielder")
LeftDefensiveMidfield = (
"Left Defensive Midfield",
"LDM",
"DefensiveMidfield",
)
CenterDefensiveMidfield = (
"Center Defensive Midfield",
"CDM",
"DefensiveMidfield",
)
RightDefensiveMidfield = (
"Right Defensive Midfield",
"RDM",
"DefensiveMidfield",
)

CentralMidfield = ("Central Midfield", "CM", "Midfielder")
LeftCentralMidfield = ("Left Central Midfield", "LCM", "CentralMidfield")
CenterMidfield = ("Center Midfield", "CM", "CentralMidfield")
RightCentralMidfield = ("Right Central Midfield", "RCM", "CentralMidfield")

AttackingMidfield = ("Attacking Midfield", "AM", "Midfielder")
LeftAttackingMidfield = (
"Left Attacking Midfield",
"LAM",
"AttackingMidfield",
)
CenterAttackingMidfield = (
"Center Attacking Midfield",
"CAM",
"AttackingMidfield",
)
RightAttackingMidfield = (
"Right Attacking Midfield",
"RAM",
"AttackingMidfield",
)

WideMidfield = ("Wide Midfield", "WM", "Midfielder")
LeftWing = ("Left Wing", "LW", "WideMidfield")
RightWing = ("Right Wing", "RW", "WideMidfield")
LeftMidfield = ("Left Midfield", "LM", "WideMidfield")
RightMidfield = ("Right Midfield", "RM", "WideMidfield")

Attacker = ("Attacker", "ATT", None)
LeftForward = ("Left Forward", "LF", "Attacker")
Striker = ("Striker", "ST", "Attacker")
RightForward = ("Right Forward", "RF", "Attacker")

def __init__(self, long_name, code, parent):
self.long_name = long_name
self.code = code
self._parent = parent

@property
def parent(self):
if self._parent:
return PositionType[self._parent]
return None

def is_subtype_of(self, other):
current = self
while current is not None:
if current == other:
return True
current = current.parent
return False

def __str__(self):
return self.long_name

@classmethod
def unknown(cls) -> "PositionType":
return cls.Unknown
4 changes: 2 additions & 2 deletions kloppy/domain/services/aggregators/minutes_played.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import timedelta
from typing import List, NamedTuple, Union

from kloppy.domain import EventDataset, Player, Position, Time
from kloppy.domain import EventDataset, Player, Time, PositionType
from kloppy.domain.services.aggregators.aggregator import (
EventDatasetAggregator,
)
Expand All @@ -16,7 +16,7 @@ class MinutesPlayed(NamedTuple):

class MinutesPlayedPerPosition(NamedTuple):
player: Player
position: Position
position: PositionType
start_time: Time
end_time: Time
duration: timedelta
Expand Down
Loading

0 comments on commit 9b6f9af

Please sign in to comment.