diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39e3ca17..32a8bf99 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 \ No newline at end of file diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index 821e9e97..c1830d1b 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -18,6 +18,8 @@ Iterable, ) +from .position import PositionType + from ...utils import deprecated if sys.version_info >= (3, 8): @@ -33,7 +35,6 @@ from .pitch import ( PitchDimensions, Unit, - Point, Dimension, NormalizedPitchDimensions, MetricPitchDimensions, @@ -47,7 +48,6 @@ OrientationError, InvalidFilterError, KloppyParameterError, - KloppyError, ) @@ -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: """ @@ -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 ) @@ -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: @@ -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) @@ -235,15 +221,11 @@ def get_player_by_jersey_number(self, jersey_no: int): return None - def get_player_by_position(self, position_id: Union[int, str], time: Time): - position_id = str(position_id) + def get_player_by_position(self, position: PositionType, time: Time): for player in self.players: if player.positions.items: player_position = player.positions.value_at(time) - if ( - player_position - and player_position.position_id == position_id - ): + if player_position and player_position == position: return player return None @@ -1098,7 +1080,7 @@ 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_formations_and_positions(self): diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index 98158bee..6de5bd38 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -17,7 +17,7 @@ DatasetType, AttackingDirection, OrientationError, - Position, + PositionType, ) from kloppy.utils import ( camelcase_to_snakecase, @@ -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" @@ -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" diff --git a/kloppy/domain/models/position.py b/kloppy/domain/models/position.py new file mode 100644 index 00000000..c0daebfd --- /dev/null +++ b/kloppy/domain/models/position.py @@ -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 diff --git a/kloppy/domain/services/aggregators/minutes_played.py b/kloppy/domain/services/aggregators/minutes_played.py index e8495d43..a44a823f 100644 --- a/kloppy/domain/services/aggregators/minutes_played.py +++ b/kloppy/domain/services/aggregators/minutes_played.py @@ -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, ) @@ -16,7 +16,7 @@ class MinutesPlayed(NamedTuple): class MinutesPlayedPerPosition(NamedTuple): player: Player - position: Position + position: PositionType start_time: Time end_time: Time duration: timedelta diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py index 73565f51..14895206 100644 --- a/kloppy/infra/serializers/event/sportec/deserializer.py +++ b/kloppy/infra/serializers/event/sportec/deserializer.py @@ -21,7 +21,6 @@ Provider, Metadata, Player, - Position, SetPieceQualifier, SetPieceType, BodyPartQualifier, @@ -29,11 +28,33 @@ Qualifier, CardType, AttackingDirection, + PositionType, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer from kloppy.utils import performance_logging + +position_types_mapping: Dict[str, PositionType] = { + "TW": PositionType.Goalkeeper, + "IVR": PositionType.RightCenterBack, + "IVL": PositionType.LeftCenterBack, + "STR": PositionType.Striker, + "STL": PositionType.LeftForward, + "STZ": PositionType.Striker, + "ZO": PositionType.CenterAttackingMidfield, + "LV": PositionType.LeftBack, + "RV": PositionType.RightBack, + "DMR": PositionType.RightDefensiveMidfield, + "DRM": PositionType.RightDefensiveMidfield, + "DML": PositionType.LeftDefensiveMidfield, + "DLM": PositionType.LeftDefensiveMidfield, + "ORM": PositionType.RightMidfield, + "OLM": PositionType.LeftMidfield, + "RA": PositionType.RightWing, + "LA": PositionType.LeftWing, +} + logger = logging.getLogger(__name__) @@ -41,9 +62,9 @@ def _team_from_xml_elm(team_elm) -> Team: team = Team( team_id=team_elm.attrib["TeamId"], name=team_elm.attrib["TeamName"], - ground=( - Ground.HOME if team_elm.attrib["Role"] == "home" else Ground.AWAY - ), + ground=Ground.HOME + if team_elm.attrib["Role"] == "home" + else Ground.AWAY, ) team.players = [ Player( @@ -53,14 +74,8 @@ def _team_from_xml_elm(team_elm) -> Team: name=player_elm.attrib["Shortname"], first_name=player_elm.attrib["FirstName"], last_name=player_elm.attrib["LastName"], - starting_position=( - Position( - position_id=None, - name=player_elm.attrib["PlayingPosition"], - coordinates=None, - ) - if "PlayingPosition" in player_elm.attrib - else None + starting_position=position_types_mapping.get( + player_elm.attrib.get("PlayingPosition"), PositionType.Unknown ), starting=player_elm.attrib["Starting"] == "true", ) diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index 18b95d15..ea6281a7 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -12,7 +12,6 @@ Orientation, Period, Player, - Position, Provider, Team, ) @@ -21,6 +20,7 @@ from kloppy.utils import performance_logging from . import specification as SB from .helpers import parse_freeze_frame, parse_str_ts +from .specification import position_types_mapping logger = logging.getLogger(__name__) @@ -179,10 +179,9 @@ def create_teams_and_players(self, raw_events, lineups): # Create players and teams player_positions = { - str(player["player"]["id"]): Position( - position_id=str(player["position"]["id"]), - name=player["position"]["name"], - ) + str(player["player"]["id"]): position_types_mapping[ + player["position"]["id"] + ] for raw_event in starting_xi_events for player in raw_event["tactics"]["lineup"] } diff --git a/kloppy/infra/serializers/event/statsbomb/helpers.py b/kloppy/infra/serializers/event/statsbomb/helpers.py index 0eeb9409..fc3a9ec3 100644 --- a/kloppy/infra/serializers/event/statsbomb/helpers.py +++ b/kloppy/infra/serializers/event/statsbomb/helpers.py @@ -10,6 +10,7 @@ Period, Player, PlayerData, + PositionType, ) from kloppy.exceptions import DeserializationError @@ -93,7 +94,9 @@ def get_player_from_freeze_frame(player_data, team, i): elif player_data.get("actor"): return event.player elif player_data.get("keeper"): - return team.get_player_by_position(position_id=1, time=event.time) + return team.get_player_by_position( + position=PositionType.Goalkeeper, time=event.time + ) else: return Player( player_id=f"T{team.team_id}-E{event.event_id}-{i}", diff --git a/kloppy/infra/serializers/event/statsbomb/specification.py b/kloppy/infra/serializers/event/statsbomb/specification.py index 3410f1b9..30bf2df2 100644 --- a/kloppy/infra/serializers/event/statsbomb/specification.py +++ b/kloppy/infra/serializers/event/statsbomb/specification.py @@ -25,7 +25,7 @@ ShotResult, TakeOnResult, FormationType, - Position, + PositionType, CounterAttackQualifier, ) from kloppy.exceptions import DeserializationError @@ -100,6 +100,34 @@ class Version(NamedTuple): 541: FormationType.FIVE_FOUR_ONE, } +position_types_mapping: Dict[int, PositionType] = { + 1: PositionType.Goalkeeper, # Provider: Goalkeeper + 2: PositionType.RightBack, # Provider: Right Back + 3: PositionType.RightCenterBack, # Provider: Right Center Back + 4: PositionType.CenterBack, # Provider: Center Back + 5: PositionType.LeftCenterBack, # Provider: Left Center Back + 6: PositionType.LeftBack, # Provider: Left Back + 7: PositionType.RightBack, # Provider: Right Wing Back (mapped to Right Back) + 8: PositionType.LeftBack, # Provider: Left Wing Back (mapped to Left Back) + 9: PositionType.RightDefensiveMidfield, # Provider: Right Defensive Midfield + 10: PositionType.CenterDefensiveMidfield, # Provider: Center Defensive Midfield + 11: PositionType.LeftDefensiveMidfield, # Provider: Left Defensive Midfield + 12: PositionType.RightMidfield, # Provider: Right Midfield + 13: PositionType.RightCentralMidfield, # Provider: Right Center Midfield + 14: PositionType.CenterMidfield, # Provider: Center Midfield + 15: PositionType.LeftCentralMidfield, # Provider: Left Center Midfield + 16: PositionType.LeftMidfield, # Provider: Left Midfield + 17: PositionType.RightWing, # Provider: Right Wing + 18: PositionType.RightAttackingMidfield, # Provider: Right Attacking Midfield + 19: PositionType.CenterAttackingMidfield, # Provider: Center Attacking Midfield + 20: PositionType.LeftAttackingMidfield, # Provider: Left Attacking Midfield + 21: PositionType.LeftWing, # Provider: Left Wing + 22: PositionType.RightForward, # Provider: Right Center Forward (mapped to Right Forward) + 23: PositionType.Striker, # Provider: Striker + 24: PositionType.LeftForward, # Provider: Left Center Forward (mapped to Left Forward) + 25: PositionType.Attacker, # Provider: Secondary Striker (mapped to Attacker) +} + class TypesEnumMeta(EnumMeta): def __call__(cls, value, *args, **kw): @@ -1200,10 +1228,7 @@ def _create_events( for player in self.raw_event["tactics"]["lineup"]: player_positions[ team.get_player_by_id(player["player"]["id"]) - ] = Position( - position_id=str(player["position"]["id"]), - name=player["position"]["name"], - ) + ] = position_types_mapping[player["position"]["id"]] formation_change_event = event_factory.build_formation_change( result=None, diff --git a/kloppy/infra/serializers/event/statsperform/deserializer.py b/kloppy/infra/serializers/event/statsperform/deserializer.py index 11e20eed..c6561c9d 100644 --- a/kloppy/infra/serializers/event/statsperform/deserializer.py +++ b/kloppy/infra/serializers/event/statsperform/deserializer.py @@ -33,7 +33,7 @@ GoalkeeperQualifier, GoalkeeperActionType, CounterAttackQualifier, - Position, + PositionType, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer @@ -218,6 +218,13 @@ 77: "player off pitch", } +position_line_mapping = { + "Goalkeeper": PositionType.Goalkeeper, + "Defender": PositionType.Defender, + "Midfielder": PositionType.Midfielder, + "Forward": PositionType.Striker, +} + def _parse_pass(raw_event: OptaEvent) -> Dict: if raw_event.outcome: @@ -308,9 +315,7 @@ def _parse_formation_change(raw_event: OptaEvent, team: Team) -> Dict: player_positions = {} for player_id, position_id in zip(player_ids, position_ids): player = team.get_player_by_id(player_id) - position = Position( - position_id=position_id, name=positions_mapping[int(position_id)] - ) + position = positions_mapping[int(position_id)] player_positions[player] = position return dict(formation_type=formation, player_positions=player_positions) @@ -324,9 +329,7 @@ def _parse_substitution(next_event: OptaEvent, team: Team) -> Dict: raw_position_line = next_event.qualifiers.get(44) if raw_position_line: - position = Position( - position_id=raw_position_line, name=raw_position_line - ) + position = position_line_mapping[raw_position_line] return dict(replacement_player=replacement_player, position=position) diff --git a/kloppy/infra/serializers/event/statsperform/formation_mapping.py b/kloppy/infra/serializers/event/statsperform/formation_mapping.py index de25b214..cda96b35 100644 --- a/kloppy/infra/serializers/event/statsperform/formation_mapping.py +++ b/kloppy/infra/serializers/event/statsperform/formation_mapping.py @@ -1,4 +1,4 @@ -from kloppy.domain import FormationType +from kloppy.domain import FormationType, PositionType formation_id_mapping = { 2: FormationType.FOUR_FOUR_TWO, @@ -55,338 +55,338 @@ formation_position_mapping = { FormationType.FOUR_FOUR_TWO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "RCM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "LCM", - 9: "LF", - 10: "RF", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.RightCentralMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.LeftForward, + 10: PositionType.RightForward, + 11: PositionType.LeftMidfield, }, FormationType.FOUR_ONE_TWO_ONE_TWO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "CDM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "CAM", - 9: "LF", - 10: "RF", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.CenterDefensiveMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.CenterAttackingMidfield, + 9: PositionType.LeftForward, + 10: PositionType.RightForward, + 11: PositionType.LeftMidfield, }, FormationType.FOUR_THREE_THREE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "CM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "LM", - 9: "ST", - 10: "RW", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.CenterMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.LeftMidfield, + 9: PositionType.Striker, + 10: PositionType.RightWing, + 11: PositionType.LeftWing, }, FormationType.FOUR_FIVE_ONE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "RCM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "LCM", - 9: "ST", - 10: "CM", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.RightCentralMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.CenterMidfield, + 11: PositionType.LeftMidfield, }, FormationType.FOUR_FOUR_ONE_ONE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "RCM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "LCM", - 9: "ST", - 10: "CAM", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.RightCentralMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.CenterAttackingMidfield, + 11: PositionType.LeftMidfield, }, FormationType.FOUR_ONE_FOUR_ONE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "CDM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "RCM", - 9: "ST", - 10: "LCM", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.CenterDefensiveMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.RightCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.LeftCentralMidfield, + 11: PositionType.LeftMidfield, }, FormationType.FOUR_TWO_THREE_ONE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "RCM", - 5: "RCB", - 6: "LCB", - 7: "RAM", - 8: "LCM", - 9: "ST", - 10: "CAM", - 11: "LAM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.RightCentralMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightAttackingMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.CenterAttackingMidfield, + 11: PositionType.LeftAttackingMidfield, }, FormationType.FOUR_THREE_TWO_ONE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "CM", - 5: "RCB", - 6: "LCB", - 7: "LM", - 8: "RM", - 9: "ST", - 10: "RAM", - 11: "LAM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.CenterMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.LeftMidfield, + 8: PositionType.RightMidfield, + 9: PositionType.Striker, + 10: PositionType.RightAttackingMidfield, + 11: PositionType.LeftAttackingMidfield, }, FormationType.FIVE_THREE_TWO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RCM", - 8: "CM", - 9: "LF", - 10: "RF", - 11: "LCM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.CenterMidfield, + 9: PositionType.LeftForward, + 10: PositionType.RightForward, + 11: PositionType.LeftCentralMidfield, }, FormationType.FIVE_FOUR_ONE: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RM", - 8: "RCM", - 9: "ST", - 10: "LCM", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.RightCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.LeftCentralMidfield, + 11: PositionType.LeftMidfield, }, FormationType.THREE_FIVE_TWO: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RCM", - 8: "LCM", - 9: "LF", - 10: "RF", - 11: "CM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.LeftForward, + 10: PositionType.RightForward, + 11: PositionType.CenterMidfield, }, FormationType.THREE_FOUR_THREE: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RCM", - 8: "LCM", - 9: "ST", - 10: "RW", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.RightWing, + 11: PositionType.LeftWing, }, FormationType.THREE_ONE_THREE_ONE_TWO: { 0: None, - 1: "GK", - 2: "Unknown", - 3: "Unknown", - 4: "Unknown", - 5: "Unknown", - 6: "Unknown", - 7: "Unknown", - 8: "Unknown", - 9: "Unknown", - 10: "Unknown", - 11: "Unknown", + 1: PositionType.Goalkeeper, + 2: PositionType.Unknown, + 3: PositionType.Unknown, + 4: PositionType.Unknown, + 5: PositionType.Unknown, + 6: PositionType.Unknown, + 7: PositionType.Unknown, + 8: PositionType.Unknown, + 9: PositionType.Unknown, + 10: PositionType.Unknown, + 11: PositionType.Unknown, }, FormationType.FOUR_TWO_TWO_TWO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "RDM", - 5: "RCB", - 6: "LCB", - 7: "RCM", - 8: "LDM", - 9: "LF", - 10: "RF", - 11: "LCM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.RightDefensiveMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.LeftDefensiveMidfield, + 9: PositionType.LeftForward, + 10: PositionType.RightForward, + 11: PositionType.LeftCentralMidfield, }, FormationType.THREE_FIVE_ONE_ONE: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RDM", - 8: "LDM", - 9: "ST", - 10: "CAM", - 11: "CDM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightDefensiveMidfield, + 8: PositionType.LeftDefensiveMidfield, + 9: PositionType.Striker, + 10: PositionType.CenterAttackingMidfield, + 11: PositionType.CenterDefensiveMidfield, }, FormationType.THREE_FOUR_TWO_ONE: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RCM", - 8: "LCM", - 9: "ST", - 10: "RF", - 11: "LF", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.RightForward, + 11: PositionType.LeftForward, }, FormationType.THREE_FOUR_ONE_TWO: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RCM", - 8: "LCM", - 9: "CAM", - 10: "RF", - 11: "LF", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.CenterAttackingMidfield, + 10: PositionType.RightForward, + 11: PositionType.LeftForward, }, FormationType.THREE_ONE_FOUR_TWO: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "CB", - 5: "RCB", - 6: "LCB", - 7: "RCM", - 8: "CDM", - 9: "RF", - 10: "LW", - 11: "LCM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.CenterBack, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.CenterDefensiveMidfield, + 9: PositionType.RightForward, + 10: PositionType.LeftWing, + 11: PositionType.LeftCentralMidfield, }, FormationType.THREE_ONE_TWO_ONE_THREE: { 0: None, - 1: "GK", - 2: "RM", - 3: "LM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "CAM", - 8: "CDM", - 9: "ST", - 10: "RW", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightMidfield, + 3: PositionType.LeftMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.CenterAttackingMidfield, + 8: PositionType.CenterDefensiveMidfield, + 9: PositionType.Striker, + 10: PositionType.RightWing, + 11: PositionType.LeftWing, }, FormationType.FOUR_ONE_THREE_TWO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "CDM", - 5: "RCB", - 6: "LCB", - 7: "RW", - 8: "CAM", - 9: "RF", - 10: "LF", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.CenterDefensiveMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightWing, + 8: PositionType.CenterAttackingMidfield, + 9: PositionType.RightForward, + 10: PositionType.LeftForward, + 11: PositionType.LeftWing, }, FormationType.FOUR_TWO_FOUR_ZERO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "RCM", - 5: "RCB", - 6: "LCB", - 7: "RW", - 8: "LCM", - 9: "RF", - 10: "LF", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.RightCentralMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightWing, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.RightForward, + 10: PositionType.LeftForward, + 11: PositionType.LeftWing, }, FormationType.FOUR_THREE_ONE_TWO: { 0: None, - 1: "GK", - 2: "RB", - 3: "LB", - 4: "CM", - 5: "RCB", - 6: "LCB", - 7: "RM", - 8: "CAM", - 9: "RF", - 10: "LF", - 11: "LM", + 1: PositionType.Goalkeeper, + 2: PositionType.RightBack, + 3: PositionType.LeftBack, + 4: PositionType.CenterMidfield, + 5: PositionType.RightCenterBack, + 6: PositionType.LeftCenterBack, + 7: PositionType.RightMidfield, + 8: PositionType.CenterAttackingMidfield, + 9: PositionType.RightForward, + 10: PositionType.LeftForward, + 11: PositionType.LeftMidfield, }, FormationType.THREE_TWO_FOUR_ONE: { 0: None, - 1: "GK", - 2: "RDM", - 3: "LDM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "RCM", - 8: "LCM", - 9: "ST", - 10: "RW", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightDefensiveMidfield, + 3: PositionType.LeftDefensiveMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.RightCentralMidfield, + 8: PositionType.LeftCentralMidfield, + 9: PositionType.Striker, + 10: PositionType.RightWing, + 11: PositionType.LeftWing, }, FormationType.THREE_THREE_THREE_ONE: { 0: None, - 1: "GK", - 2: "RDM", - 3: "LDM", - 4: "LCB", - 5: "CB", - 6: "RCB", - 7: "CAM", - 8: "CDM", - 9: "ST", - 10: "RW", - 11: "LW", + 1: PositionType.Goalkeeper, + 2: PositionType.RightDefensiveMidfield, + 3: PositionType.LeftDefensiveMidfield, + 4: PositionType.LeftCenterBack, + 5: PositionType.CenterBack, + 6: PositionType.RightCenterBack, + 7: PositionType.CenterAttackingMidfield, + 8: PositionType.CenterDefensiveMidfield, + 9: PositionType.Striker, + 10: PositionType.RightWing, + 11: PositionType.LeftWing, }, } diff --git a/kloppy/infra/serializers/event/statsperform/parsers/base.py b/kloppy/infra/serializers/event/statsperform/parsers/base.py index 90a97ffe..3fee98b9 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/base.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/base.py @@ -8,12 +8,21 @@ from lxml import objectify -from kloppy.domain import Team, Score, Period +from kloppy.domain import Team, Score, Period, PositionType from datetime import datetime from dataclasses import dataclass, field +position_types_mapping: Dict[str, PositionType] = { + "Goalkeeper": PositionType.Goalkeeper, + "Defender": PositionType.Defender, + "Midfielder": PositionType.Midfielder, + "Striker": PositionType.Attacker, + "Substitute": PositionType.Unknown, +} + + @dataclass class OptaEvent: """A raw Opta event.""" diff --git a/kloppy/infra/serializers/event/statsperform/parsers/f7_xml.py b/kloppy/infra/serializers/event/statsperform/parsers/f7_xml.py index 0f455847..20d66399 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/f7_xml.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/f7_xml.py @@ -10,13 +10,13 @@ Ground, Period, Player, - Position, Score, Team, ) +from kloppy.domain.models import PositionType from kloppy.exceptions import DeserializationError -from .base import OptaXMLParser +from .base import OptaXMLParser, position_types_mapping from ..formation_mapping import ( formation_position_mapping, formation_name_mapping, @@ -157,13 +157,9 @@ def _team_from_xml_elm(self, team_elm: Any) -> Team: "last_name" ], starting=(player_elm.attrib["Status"] == "Start"), - starting_position=Position( - position_id=player_elm.attrib["Formation_Place"], - name=formation_position_mapping[ - formation_name_mapping[team_elm.attrib["Formation"]] - ][int(player_elm.attrib["Formation_Place"])], - coordinates=None, - ), + starting_position=formation_position_mapping[ + formation_name_mapping[team_elm.attrib["Formation"]] + ][int(player_elm.attrib["Formation_Place"])], ) for player_elm in team_elm.find("PlayerLineUp").iterchildren( "MatchPlayer" diff --git a/kloppy/infra/serializers/tracking/metrica_epts/metadata.py b/kloppy/infra/serializers/tracking/metrica_epts/metadata.py index 5afe26d7..c28414a9 100644 --- a/kloppy/infra/serializers/tracking/metrica_epts/metadata.py +++ b/kloppy/infra/serializers/tracking/metrica_epts/metadata.py @@ -14,14 +14,19 @@ DatasetFlag, AttackingDirection, Orientation, - Position, Point, + PositionType, Provider, build_coordinate_system, ) from .models import * +position_types_mapping: Dict[int, PositionType] = { + -1: PositionType.Unknown, + 0: PositionType.Goalkeeper, +} + def noop(x): return x @@ -126,22 +131,20 @@ def _load_players(players_elm, team: Team) -> List[Player]: ] -def _load_position_data(parent_elm) -> Position: +def _load_position_data(parent_elm) -> PositionType: # TODO: _load_provider_parameters is called twice to set position data # and then again to set the attributes. Also, data in position should not # be duplicated in attributes either. player_provider_parameters = _load_provider_parameters(parent_elm) + if "position_index" not in player_provider_parameters: - return None + position_type = PositionType.Unknown + else: + position_type = position_types_mapping.get( + player_provider_parameters["position_index"], PositionType.Unknown + ) - return Position( - position_id=player_provider_parameters["position_index"], - name=player_provider_parameters["position_type"], - coordinates=Point( - player_provider_parameters["position_x"], - player_provider_parameters["position_y"], - ), - ) + return position_type def _load_data_format_specifications( diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index 878f04fd..b5cc0306 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -2,7 +2,7 @@ from datetime import timedelta, timezone from dateutil.parser import parse import warnings -from typing import NamedTuple, IO, Optional, Union +from typing import NamedTuple, IO, Optional, Union, Dict from collections import Counter import numpy as np import json @@ -20,7 +20,7 @@ Player, Point, Point3D, - Position, + PositionType, Provider, Score, Team, @@ -34,9 +34,28 @@ logger = logging.getLogger(__name__) - frame_rate = 10 +position_types_mapping: Dict[int, PositionType] = { + 1: PositionType.Unknown, + 2: PositionType.CenterBack, # Provider: CB + 3: PositionType.LeftCenterBack, # Provider: LCB + 4: PositionType.RightCenterBack, # Provider: RCB + 5: PositionType.LeftBack, # Provider: LWB (mapped to Left Back) + 6: PositionType.RightBack, # Provider: RWB (mapped to Right Back) + 7: PositionType.DefensiveMidfield, # Provider: DM + 8: PositionType.CenterMidfield, # Provider: CM + 9: PositionType.LeftMidfield, # Provider: LM + 10: PositionType.RightMidfield, # Provider: RM + 11: PositionType.AttackingMidfield, # Provider: AM + 12: PositionType.LeftWing, # Provider: LW + 13: PositionType.RightWing, # Provider: RW + 14: PositionType.LeftForward, # Provider: LF + 15: PositionType.Striker, # Provider: CF (mapped to Striker) + 16: PositionType.RightForward, # Provider: RF + 17: PositionType.Unknown, # Provider: SUB (mapped to Unknown) +} + class SkillCornerInputs(NamedTuple): meta_data: IO[bytes] @@ -383,10 +402,8 @@ def deserialize(self, inputs: SkillCornerInputs) -> TrackingDataset: first_name=player["first_name"], last_name=player["last_name"], starting=player["start_time"] == "00:00:00", - starting_position=Position( - position_id=player["player_role"].get("id"), - name=player["player_role"].get("name"), - coordinates=None, + starting_position=position_types_mapping.get( + player["player_role"]["id"], PositionType.Unknown ), attributes={}, ) diff --git a/kloppy/infra/serializers/tracking/tracab/common.py b/kloppy/infra/serializers/tracking/tracab/common.py index 0e2e2797..1e6a04c8 100644 --- a/kloppy/infra/serializers/tracking/tracab/common.py +++ b/kloppy/infra/serializers/tracking/tracab/common.py @@ -1,4 +1,14 @@ -from typing import NamedTuple, IO +from typing import NamedTuple, IO, Dict + +from kloppy.domain import PositionType + + +position_types_mapping: Dict[str, PositionType] = { + "G": PositionType.Goalkeeper, + "D": PositionType.Defender, + "M": PositionType.Midfielder, + "A": PositionType.Attacker, +} class TRACABInputs(NamedTuple): diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py index 79f6c1b0..831370cb 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py @@ -24,12 +24,13 @@ Player, Provider, PlayerData, + PositionType, ) from kloppy.exceptions import DeserializationError from kloppy.utils import Readable, performance_logging -from .common import TRACABInputs +from .common import TRACABInputs, position_types_mapping from ..deserializer import TrackingDataDeserializer logger = logging.getLogger(__name__) @@ -155,6 +156,9 @@ def create_team( ), jersey_no=int(player["JerseyNo"]), starting=player["StartFrameCount"] == start_frame_id, + starting_position=position_types_mapping.get( + player.get("StartingPosition"), PositionType.Unknown + ), ) for player in team_data["Players"][player_item] ] diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_json.py b/kloppy/infra/serializers/tracking/tracab/tracab_json.py index dcd1547e..a5f52958 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_json.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_json.py @@ -21,14 +21,14 @@ Player, Provider, PlayerData, - Position, attacking_direction_from_frame, ) +from kloppy.domain.models import PositionType from kloppy.exceptions import DeserializationError from kloppy.utils import Readable, performance_logging -from .common import TRACABInputs +from .common import TRACABInputs, position_types_mapping from ..deserializer import TrackingDataDeserializer logger = logging.getLogger(__name__) @@ -132,20 +132,6 @@ def create_team(self, team_data, ground): ground=ground, ) - def parse_player_position( - starting_position: str, current_position: str - ): - if starting_position != "S": - return Position( - position_id=starting_position, name=starting_position - ) - elif current_position != "S" and current_position != "O": - return Position( - position_id=current_position, name=current_position - ) - else: - return None - team.players = [ Player( player_id=str(player["PlayerID"]), @@ -157,8 +143,8 @@ def parse_player_position( ), jersey_no=int(player["JerseyNo"]), starting=True if player["StartingPosition"] != "S" else False, - starting_position=parse_player_position( - player["StartingPosition"], player["CurrentPosition"] + starting_position=position_types_mapping.get( + player["StartingPosition"], PositionType.Unknown ), ) for player in team_data["Players"] diff --git a/kloppy/tests/test_datafactory.py b/kloppy/tests/test_datafactory.py index 26920982..7354356f 100644 --- a/kloppy/tests/test_datafactory.py +++ b/kloppy/tests/test_datafactory.py @@ -11,7 +11,7 @@ Provider, SetPieceType, DatasetType, - Position, + PositionType, ) from kloppy import datafactory @@ -43,7 +43,7 @@ def test_correct_deserialization(self, event_data: str): assert player.player_id == "38804" assert player.jersey_no == 1 assert str(player) == "Daniel Bold" - assert player.starting_position is None + assert player.starting_position == None assert player.starting assert dataset.metadata.periods[0].id == 1 diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 3f57be06..b1264c6e 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -27,7 +27,7 @@ Point, Point, Point3D, - Position, + PositionType, Provider, Score, SetPieceQualifier, @@ -106,9 +106,7 @@ def test_player_position(self, dataset): """It should set the correct player position from the events""" # Starting players have a position player = dataset.metadata.teams[0].get_player_by_id("111319") - assert player.positions.last() == Position( - position_id="1", name="GK", coordinates=None - ) + assert player.positions.last() == PositionType.Goalkeeper assert player.starting # Substituted players don't have a position @@ -118,9 +116,7 @@ def test_player_position(self, dataset): # LB position is correctly based on Formation_Place player = dataset.metadata.teams[0].get_player_by_id("80398") - assert player.positions.last() == Position( - position_id="3", name="LB", coordinates=None - ) + assert player.positions.last() == PositionType.LeftBack assert player.starting def test_periods(self, dataset): diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py index 3b664f47..1c11bb78 100644 --- a/kloppy/tests/test_sportec.py +++ b/kloppy/tests/test_sportec.py @@ -15,6 +15,7 @@ DatasetType, BallState, Point3D, + PositionType, ) from kloppy import sportec @@ -75,8 +76,7 @@ def test_correct_event_data_deserialization( assert player.player_id == "DFL-OBJ-00001D" assert player.jersey_no == 1 assert str(player) == "A. Schwolow" - assert player.starting_position.position_id is None - assert player.starting_position.name == "TW" + assert player.starting_position == PositionType.Goalkeeper # Check the qualifiers assert dataset.events[25].qualifiers[0].value == SetPieceType.KICK_OFF diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 60068121..9995bd4b 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -32,11 +32,11 @@ FormationType, SetPieceQualifier, SetPieceType, - Position, ShotResult, EventDataset, Time, ) +from kloppy.domain.models import PositionType from kloppy.exceptions import DeserializationError from kloppy import statsbomb @@ -148,9 +148,7 @@ def test_player_position(self, dataset): # Starting players get their position from the STARTING_XI event player = dataset.metadata.teams[0].get_player_by_id("3089") - assert player.starting_position == Position( - position_id="18", name="Right Attacking Midfield", coordinates=None - ) + assert player.starting_position == PositionType.RightAttackingMidfield assert player.starting # Substituted players have a position @@ -165,17 +163,19 @@ def test_player_position(self, dataset): period_2 = periods[1] home_starting_gk = dataset.metadata.teams[0].get_player_by_position( - "1", time=Time(period=period_1, timestamp=timedelta(seconds=0)) + PositionType.Goalkeeper, + time=Time(period=period_1, timestamp=timedelta(seconds=0)), ) assert home_starting_gk.player_id == "3509" # Thibaut Courtois home_starting_lam = dataset.metadata.teams[0].get_player_by_position( - "20", time=Time(period=period_1, timestamp=timedelta(seconds=0)) + PositionType.LeftAttackingMidfield, + time=Time(period=period_1, timestamp=timedelta(seconds=0)), ) assert home_starting_lam.player_id == "3621" # Eden Hazard home_ending_lam = dataset.metadata.teams[0].get_player_by_position( - "20", + PositionType.LeftAttackingMidfield, time=Time(period=period_2, timestamp=timedelta(seconds=45 * 60)), ) assert home_ending_lam.player_id == "5633" # Yannick Ferreira Carrasco @@ -1149,14 +1149,12 @@ def test_player_position(self, base_dir): ( period1.start_time, period2.start_time, - Position( - position_id="12", name="Right Midfield", coordinates=None - ), + PositionType.RightMidfield, ), ( period2.start_time, period2.end_time, - Position(position_id="2", name="Right Back", coordinates=None), + PositionType.RightBack, ), ] @@ -1166,8 +1164,6 @@ def test_player_position(self, base_dir): ( period2.start_time + timedelta(seconds=1362.254), period2.end_time, - Position( - position_id="16", name="Left Midfield", coordinates=None - ), + PositionType.LeftMidfield, ) ]