From e359a991c5da675a95d827ce534330e5669f02cb Mon Sep 17 00:00:00 2001 From: Dries Deprest Date: Tue, 17 Dec 2024 18:00:39 +0100 Subject: [PATCH 1/8] fix(Stats Perform): Ignore 19/"Player on" events as they are already incorporated in SubstitutionEvent (#361) --- kloppy/infra/serializers/event/statsperform/deserializer.py | 2 ++ kloppy/tests/issues/issue_60/test_issue_60.py | 4 ++-- kloppy/tests/test_adapter.py | 2 +- kloppy/tests/test_statsperform.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/kloppy/infra/serializers/event/statsperform/deserializer.py b/kloppy/infra/serializers/event/statsperform/deserializer.py index f603717a..5ec3dac0 100644 --- a/kloppy/infra/serializers/event/statsperform/deserializer.py +++ b/kloppy/infra/serializers/event/statsperform/deserializer.py @@ -724,6 +724,8 @@ def deserialize(self, inputs: StatsPerformInputs) -> EventDataset: f"Set end of period {period.id} to {raw_event.timestamp}" ) period.end_timestamp = raw_event.timestamp + elif raw_event.type_id == EVENT_TYPE_PLAYER_ON: + continue else: if not period.start_timestamp: # not started yet diff --git a/kloppy/tests/issues/issue_60/test_issue_60.py b/kloppy/tests/issues/issue_60/test_issue_60.py index 5687973e..4d14b972 100644 --- a/kloppy/tests/issues/issue_60/test_issue_60.py +++ b/kloppy/tests/issues/issue_60/test_issue_60.py @@ -16,7 +16,7 @@ def test_deleted_event_opta(self): assert deleted_event_id not in df["event_id"].to_list() # OPTA F24 file: Pass -> Deleted Event -> Tackle - assert event_dataset.events[16].event_name == "pass" + assert event_dataset.events[15].event_name == "pass" assert ( - event_dataset.events[17].event_name == "duel" + event_dataset.events[16].event_name == "duel" ) # Deleted Event is filter out diff --git a/kloppy/tests/test_adapter.py b/kloppy/tests/test_adapter.py index e6ff29c7..10409958 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) == 40 + assert len(dataset.events) == 39 diff --git a/kloppy/tests/test_statsperform.py b/kloppy/tests/test_statsperform.py index f1c772d1..9e8e9070 100644 --- a/kloppy/tests/test_statsperform.py +++ b/kloppy/tests/test_statsperform.py @@ -177,7 +177,7 @@ def test_deserialize_all(self, event_dataset: EventDataset): pitch_length=None, pitch_width=None, ) - assert len(event_dataset.records) == 1652 + assert len(event_dataset.records) == 1643 substitution_events = event_dataset.find_all("substitution") assert len(substitution_events) == 9 From 45ab84c668f1f0947a97e776971569cb94e2661a Mon Sep 17 00:00:00 2001 From: UnravelSports <64530306+UnravelSports@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:07:57 +0100 Subject: [PATCH 2/8] feat(sportec): add referees to metadata; fix(sportec): parsing tracking data with referee --------- Co-authored-by: UnravelSports [JB] Co-authored-by: Pieter Robberechts --- kloppy/domain/models/common.py | 43 +- .../serializers/event/sportec/deserializer.py | 38 + .../tracking/sportec/deserializer.py | 10 + .../files/sportec_positional_w_referee.xml | 671 ++++++++++++++++++ kloppy/tests/test_sportec.py | 56 ++ kloppy/utils.py | 5 + 6 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 kloppy/tests/files/sportec_positional_w_referee.xml diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index c1830d1b..b4880451 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -20,7 +20,7 @@ from .position import PositionType -from ...utils import deprecated +from ...utils import deprecated, snake_case if sys.version_info >= (3, 8): from typing import Literal @@ -119,6 +119,46 @@ def __str__(self): return self.value +class OfficialType(Enum): + """Enumeration for types of officials (referees).""" + + VideoAssistantReferee = "Video Assistant Referee" + MainReferee = "Main Referee" + AssistantReferee = "Assistant Referee" + FourthOfficial = "Fourth Official" + + def __str__(self): + return self.value + + +@dataclass(frozen=True) +class Official: + """ + Represents an official (referee) with optional names and roles. + """ + + official_id: str + name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + role: Optional[OfficialType] = None + + @property + def full_name(self): + """ + Returns the full name of the official, falling back to role-based or ID-based naming. + """ + if self.name: + return self.name + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + if self.last_name: + return self.last_name + if self.role: + return f"{snake_case(str(self.role))}_{self.official_id}" + return f"official_{self.official_id}" + + @dataclass(frozen=True) class Player: """ @@ -1016,6 +1056,7 @@ class Metadata: game_id: Optional[str] = None home_coach: Optional[str] = None away_coach: Optional[str] = None + officials: Optional[List] = field(default_factory=list) attributes: Optional[Dict] = field(default_factory=dict, compare=False) def __post_init__(self): diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py index 14895206..57d105a4 100644 --- a/kloppy/infra/serializers/event/sportec/deserializer.py +++ b/kloppy/infra/serializers/event/sportec/deserializer.py @@ -29,6 +29,8 @@ CardType, AttackingDirection, PositionType, + Official, + OfficialType, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer @@ -55,6 +57,14 @@ "LA": PositionType.LeftWing, } +referee_types_mapping: Dict[str, OfficialType] = { + "referee": OfficialType.MainReferee, + "firstAssistant": OfficialType.AssistantReferee, + "videoReferee": OfficialType.VideoAssistantReferee, + "secondAssistant": OfficialType.AssistantReferee, + "fourthOfficial": OfficialType.FourthOfficial, +} + logger = logging.getLogger(__name__) @@ -102,6 +112,7 @@ class SportecMetadata(NamedTuple): fps: int home_coach: str away_coach: str + officials: List[Official] def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: @@ -213,6 +224,31 @@ def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: ] ) + if hasattr(match_root, "MatchInformation") and hasattr( + match_root.MatchInformation, "Referees" + ): + officials = [] + referee_path = objectify.ObjectPath( + "PutDataRequest.MatchInformation.Referees" + ) + referee_elms = referee_path.find(match_root).iterchildren( + tag="Referee" + ) + + for referee in referee_elms: + ref_attrib = referee.attrib + officials.append( + Official( + official_id=ref_attrib["PersonId"], + name=ref_attrib["Shortname"], + first_name=ref_attrib["FirstName"], + last_name=ref_attrib["LastName"], + role=referee_types_mapping[ref_attrib["Role"]], + ) + ) + else: + officials = [] + return SportecMetadata( score=score, teams=teams, @@ -222,6 +258,7 @@ def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: fps=SPORTEC_FPS, home_coach=home_coach, away_coach=away_coach, + officials=officials, ) @@ -673,6 +710,7 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset: game_id=game_id, home_coach=home_coach, away_coach=away_coach, + officials=sportec_metadata.officials, ) return EventDataset( diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index 3f418375..7cc08516 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -122,6 +122,7 @@ def deserialize( with performance_logging("parse metadata", logger=logger): sportec_metadata = sportec_metadata_from_xml_elm(match_root) teams = home_team, away_team = sportec_metadata.teams + periods = sportec_metadata.periods transformer = self.get_transformer( pitch_length=sportec_metadata.x_max, @@ -130,6 +131,12 @@ def deserialize( home_coach = sportec_metadata.home_coach away_coach = sportec_metadata.away_coach + official_ids = [] + if sportec_metadata.officials: + official_ids = [ + x.official_id for x in sportec_metadata.officials + ] + with performance_logging("parse raw data", logger=logger): date = parse( match_root.MatchInformation.General.attrib["KickoffTime"] @@ -156,6 +163,7 @@ def _iter(): for i, (frame_id, frame_data) in enumerate( sorted(raw_frames.items()) ): + if "ball" not in frame_data: # Frames without ball data are corrupt. continue @@ -193,6 +201,7 @@ def _iter(): ) for player_id, raw_player_data in frame_data.items() if player_id != "ball" + and player_id not in official_ids }, other_data={}, ball_coordinates=Point3D( @@ -242,6 +251,7 @@ def _iter(): game_id=game_id, home_coach=home_coach, away_coach=away_coach, + officials=sportec_metadata.officials, ) return TrackingDataset( diff --git a/kloppy/tests/files/sportec_positional_w_referee.xml b/kloppy/tests/files/sportec_positional_w_referee.xml new file mode 100644 index 00000000..d9f12d8f --- /dev/null +++ b/kloppy/tests/files/sportec_positional_w_referee.xml @@ -0,0 +1,671 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py index 1c11bb78..ac8ad2de 100644 --- a/kloppy/tests/test_sportec.py +++ b/kloppy/tests/test_sportec.py @@ -16,6 +16,8 @@ BallState, Point3D, PositionType, + OfficialType, + Official, ) from kloppy import sportec @@ -119,6 +121,10 @@ class TestSportecTrackingData: def raw_data(self, base_dir) -> str: return base_dir / "files/sportec_positional.xml" + @pytest.fixture + def raw_data_referee(self, base_dir) -> str: + return base_dir / "files/sportec_positional_w_referee.xml" + @pytest.fixture def meta_data(self, base_dir) -> str: return base_dir / "files/sportec_meta.xml" @@ -145,6 +151,7 @@ def test_load_metadata(self, raw_data: Path, meta_data: Path): assert dataset.metadata.periods[1].end_timestamp == timedelta( seconds=4000 + 2996.68 ) + assert len(dataset.metadata.officials) == 4 def test_load_frames(self, raw_data: Path, meta_data: Path): dataset = sportec.load_tracking( @@ -238,3 +245,52 @@ def test_enriched_metadata(self, raw_data: Path, meta_data: Path): if away_coach: assert isinstance(away_coach, str) assert away_coach == "M. Rose" + + def test_referees(self, raw_data_referee: Path, meta_data: Path): + dataset = sportec.load_tracking( + raw_data=raw_data_referee, + meta_data=meta_data, + coordinates="sportec", + only_alive=True, + ) + assert len(dataset.metadata.officials) == 4 + + assert ( + Official( + official_id="42", + name="Pierluigi Collina", + role=OfficialType.MainReferee, + ).role.value + == "Main Referee" + ) + + assert ( + Official( + official_id="42", + name="Pierluigi Collina", + role=OfficialType.MainReferee, + ).full_name + == "Pierluigi Collina" + ) + assert ( + Official( + official_id="42", + first_name="Pierluigi", + last_name="Collina", + role=OfficialType.MainReferee, + ).full_name + == "Pierluigi Collina" + ) + assert ( + Official( + official_id="42", + last_name="Collina", + role=OfficialType.MainReferee, + ).full_name + == "Collina" + ) + assert ( + Official(official_id="42", role=OfficialType.MainReferee).full_name + == "main_referee_42" + ) + assert Official(official_id="42").full_name == "official_42" diff --git a/kloppy/utils.py b/kloppy/utils.py index b0858398..68d36af2 100644 --- a/kloppy/utils.py +++ b/kloppy/utils.py @@ -169,3 +169,8 @@ def __get__(self, instance, owner): stacklevel=2, ) return self.value + + +def snake_case(s: str) -> str: + """Convert a string to snake_case.""" + return re.sub(r"[\s\-]+", "_", s.strip()).lower() From b0f56e126732b54bbcef98f5edcf42087b1e201d Mon Sep 17 00:00:00 2001 From: Pieter Robberechts Date: Tue, 17 Dec 2024 20:56:18 +0100 Subject: [PATCH 3/8] fix: common bug in parsing of UTC datetimes (#373) --- .../event/datafactory/deserializer.py | 14 +++++------ .../serializers/event/sportec/deserializer.py | 7 +++--- .../event/statsperform/deserializer.py | 18 +++++++++---- .../event/statsperform/parsers/base.py | 2 +- .../event/statsperform/parsers/f24_xml.py | 25 ++++++++++++------- .../event/statsperform/parsers/ma1_json.py | 15 +++++------ .../event/statsperform/parsers/ma1_xml.py | 7 +++--- .../event/statsperform/parsers/ma3_json.py | 7 +++--- .../event/statsperform/parsers/ma3_xml.py | 7 +++--- .../event/wyscout/deserializer_v3.py | 8 +++--- .../infra/serializers/tracking/skillcorner.py | 18 +++++++------ .../tracking/sportec/deserializer.py | 7 +++--- .../serializers/tracking/tracab/tracab_dat.py | 13 +++++----- kloppy/tests/test_opta.py | 6 ++--- setup.py | 1 - 15 files changed, 84 insertions(+), 71 deletions(-) diff --git a/kloppy/infra/serializers/event/datafactory/deserializer.py b/kloppy/infra/serializers/event/datafactory/deserializer.py index cf3d11eb..44f5df20 100644 --- a/kloppy/infra/serializers/event/datafactory/deserializer.py +++ b/kloppy/infra/serializers/event/datafactory/deserializer.py @@ -1,9 +1,8 @@ import json import logging -from datetime import timedelta, datetime, timezone -from dateutil.parser import parse, _parser from dataclasses import replace -from typing import Dict, List, Tuple, Union, IO, NamedTuple +from datetime import datetime, timedelta, timezone +from typing import IO, Dict, List, NamedTuple, Tuple, Union from kloppy.domain import ( AttackingDirection, @@ -41,7 +40,6 @@ from kloppy.infra.serializers.event.deserializer import EventDataDeserializer from kloppy.utils import Readable, performance_logging - logger = logging.getLogger(__name__) @@ -435,7 +433,7 @@ def deserialize(self, inputs: DatafactoryInputs) -> EventDataset: + status_update["time"] + match["stadiumGMT"], "%Y%m%d%H:%M:%S%z", - ).astimezone(timezone.utc) + ) half = status_update["t"]["half"] if status_update["type"] == DF_EVENT_TYPE_STATUS_MATCH_START: half = 1 @@ -458,8 +456,10 @@ def deserialize(self, inputs: DatafactoryInputs) -> EventDataset: date = match["date"] if date: # TODO: scheduledStart and stadiumGMT should probably be used here too - date = parse(date).astimezone(timezone.utc) - except _parser.ParserError: + date = datetime.strptime(date, "%Y%m%d").replace( + tzinfo=timezone.utc + ) + except ValueError: date = None game_week = match.get("week", None) if game_week: diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py index 57d105a4..b240db49 100644 --- a/kloppy/infra/serializers/event/sportec/deserializer.py +++ b/kloppy/infra/serializers/event/sportec/deserializer.py @@ -2,7 +2,6 @@ from typing import Dict, List, NamedTuple, IO from datetime import timedelta, datetime, timezone import logging -from dateutil.parser import parse from lxml import objectify from kloppy.domain import ( @@ -314,7 +313,7 @@ def _event_chain_from_xml_elm(event_elm): def _parse_datetime(dt_str: str) -> datetime: - return parse(dt_str).astimezone(timezone.utc) + return datetime.fromisoformat(dt_str) def _get_event_qualifiers(event_chain: Dict) -> List[Qualifier]: @@ -469,9 +468,9 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset: event_root = objectify.fromstring(inputs.event_data.read()) with performance_logging("parse data", logger=logger): - date = parse( + date = datetime.fromisoformat( match_root.MatchInformation.General.attrib["KickoffTime"] - ).astimezone(timezone.utc) + ) game_week = match_root.MatchInformation.General.attrib["MatchDay"] game_id = match_root.MatchInformation.General.attrib["MatchId"] diff --git a/kloppy/infra/serializers/event/statsperform/deserializer.py b/kloppy/infra/serializers/event/statsperform/deserializer.py index 5ec3dac0..95bf9e9c 100644 --- a/kloppy/infra/serializers/event/statsperform/deserializer.py +++ b/kloppy/infra/serializers/event/statsperform/deserializer.py @@ -1,9 +1,10 @@ -import pytz import math from typing import Dict, List, NamedTuple, IO, Optional import logging from datetime import datetime, timedelta +import pytz + from kloppy.domain import ( EventDataset, Team, @@ -795,11 +796,18 @@ def deserialize(self, inputs: StatsPerformInputs) -> EventDataset: ): if raw_event.type_id == EVENT_TYPE_SHOT_GOAL: if 374 in raw_event.qualifiers: + # Qualifier 374 specifies the actual time of the shot for all goal events + # It uses London timezone for both MA3 and F24 feeds + naive_datetime = datetime.strptime( + raw_event.qualifiers[374], + "%Y-%m-%d %H:%M:%S.%f", + ) + timezone = pytz.timezone("Europe/London") + aware_datetime = timezone.localize( + naive_datetime + ) generic_event_kwargs["timestamp"] = ( - datetime.strptime( - raw_event.qualifiers[374], - "%Y-%m-%d %H:%M:%S.%f", - ).replace(tzinfo=pytz.utc) + aware_datetime.astimezone(pytz.utc) - period.start_timestamp ) shot_event_kwargs = _parse_shot(raw_event) diff --git a/kloppy/infra/serializers/event/statsperform/parsers/base.py b/kloppy/infra/serializers/event/statsperform/parsers/base.py index 3fee98b9..2a7ca7cc 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/base.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/base.py @@ -61,7 +61,7 @@ def extract_score(self) -> Optional[Score]: """Return the score of the game.""" return None - def extract_date(self) -> Optional[str]: + def extract_date(self) -> Optional[datetime]: """Return the date of the game.""" return None diff --git a/kloppy/infra/serializers/event/statsperform/parsers/f24_xml.py b/kloppy/infra/serializers/event/statsperform/parsers/f24_xml.py index f32dbd95..e8cb1ffb 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/f24_xml.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/f24_xml.py @@ -1,10 +1,11 @@ """XML parser for Opta F24 feeds.""" -import pytz -from datetime import datetime, timezone + +from datetime import datetime from typing import List, Optional -from dateutil.parser import parse -from .base import OptaXMLParser, OptaEvent +import pytz + +from .base import OptaEvent, OptaXMLParser def _parse_f24_datetime(dt_str: str) -> datetime: @@ -15,9 +16,10 @@ def zero_pad_milliseconds(timestamp): return ".".join(parts[:-1] + ["{:03d}".format(int(parts[-1]))]) dt_str = zero_pad_milliseconds(dt_str) - return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%f").replace( - tzinfo=pytz.utc - ) + naive_datetime = datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%f") + timezone = pytz.timezone("Europe/London") + aware_datetime = timezone.localize(naive_datetime) + return aware_datetime.astimezone(pytz.utc) class F24XMLParser(OptaXMLParser): @@ -54,11 +56,16 @@ def extract_events(self) -> List[OptaEvent]: for event in game_elm.iterchildren("Event") ] - def extract_date(self) -> Optional[str]: + def extract_date(self) -> Optional[datetime]: """Return the date of the game.""" game_elm = self.root.find("Game") if game_elm and "game_date" in game_elm.attrib: - return parse(game_elm.attrib["game_date"]).astimezone(timezone.utc) + naive_datetime = datetime.strptime( + game_elm.attrib["game_date"], "%Y-%m-%dT%H:%M:%S" + ) + timezone = pytz.timezone("Europe/London") + aware_datetime = timezone.localize(naive_datetime) + return aware_datetime.astimezone(pytz.utc) else: return None diff --git a/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py b/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py index c9aa3974..8c1bf6e2 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/ma1_json.py @@ -1,10 +1,11 @@ """JSON parser for Stats Perform MA1 feeds.""" -import pytz + from datetime import datetime, timezone -from typing import Any, Optional, List, Tuple, Dict +from typing import Any, Dict, List, Optional, Tuple -from kloppy.domain import Period, Score, Team, Ground, Player +from kloppy.domain import Ground, Period, Player, Score, Team from kloppy.exceptions import DeserializationError + from .base import OptaJSONParser @@ -30,12 +31,12 @@ def extract_periods(self) -> List[Period]: id=period["id"], start_timestamp=datetime.strptime( period_start_raw, "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=pytz.utc) + ).replace(tzinfo=timezone.utc) if period_start_raw else None, end_timestamp=datetime.strptime( period_end_raw, "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=pytz.utc) + ).replace(tzinfo=timezone.utc) if period_end_raw else None, ) @@ -95,12 +96,12 @@ def extract_lineups(self) -> Tuple[Team, Team]: raise DeserializationError("Lineup incomplete") return home_team, away_team - def extract_date(self) -> Optional[str]: + def extract_date(self) -> Optional[datetime]: """Return the date of the game.""" if "matchInfo" in self.root and "date" in self.root["matchInfo"]: return datetime.strptime( self.root["matchInfo"]["date"], "%Y-%m-%dZ" - ).astimezone(timezone.utc) + ).replace(tzinfo=timezone.utc) else: return None diff --git a/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py b/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py index 5b7bda49..92058877 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/ma1_xml.py @@ -1,6 +1,5 @@ """XML parser for Stats Perform MA1 feeds.""" -import pytz -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Optional, List, Dict, Tuple from kloppy.domain import Period, Score, Team, Ground, Player @@ -22,10 +21,10 @@ def extract_periods(self) -> List[Period]: id=int(period.get("id")), start_timestamp=datetime.strptime( period.get("start"), "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=pytz.utc), + ).replace(tzinfo=timezone.utc), end_timestamp=datetime.strptime( period.get("end"), "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=pytz.utc), + ).replace(tzinfo=timezone.utc), ) ) return parsed_periods diff --git a/kloppy/infra/serializers/event/statsperform/parsers/ma3_json.py b/kloppy/infra/serializers/event/statsperform/parsers/ma3_json.py index 59494bfa..a91cc148 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/ma3_json.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/ma3_json.py @@ -1,6 +1,5 @@ """JSON parser for Stats Perform MA3 feeds.""" -import pytz -from datetime import datetime +from datetime import datetime, timezone from typing import List from .base import OptaJSONParser, OptaEvent @@ -9,12 +8,12 @@ def _parse_ma3_datetime(dt_str: str) -> datetime: try: return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace( - tzinfo=pytz.utc + tzinfo=timezone.utc ) except ValueError: return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=pytz.utc + tzinfo=timezone.utc ) diff --git a/kloppy/infra/serializers/event/statsperform/parsers/ma3_xml.py b/kloppy/infra/serializers/event/statsperform/parsers/ma3_xml.py index 148b4d79..823f8313 100644 --- a/kloppy/infra/serializers/event/statsperform/parsers/ma3_xml.py +++ b/kloppy/infra/serializers/event/statsperform/parsers/ma3_xml.py @@ -1,6 +1,5 @@ """XML parser for Stats Perform MA3 feeds.""" -import pytz -from datetime import datetime +from datetime import datetime, timezone from typing import List from .base import OptaXMLParser, OptaEvent @@ -9,11 +8,11 @@ def _parse_ma3_datetime(dt_str: str) -> datetime: try: return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace( - tzinfo=pytz.utc + tzinfo=timezone.utc ) except ValueError: return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=pytz.utc + tzinfo=timezone.utc ) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 8e2143aa..1ef620e4 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -1,12 +1,10 @@ import json import logging from dataclasses import replace -from datetime import timedelta, timezone +from datetime import datetime, timedelta, timezone from enum import Enum from typing import Dict, List, Optional -from dateutil.parser import parse - from kloppy.domain import ( BodyPart, BodyPartQualifier, @@ -709,7 +707,9 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: ) date = raw_events["match"].get("dateutc") if date: - date = parse(date).astimezone(timezone.utc) + date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S").replace( + tzinfo=timezone.utc + ) game_week = raw_events["match"].get("gameweek") if game_week: game_week = str(game_week) diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index b5cc0306..f819a5af 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -1,15 +1,14 @@ +import json import logging -from datetime import timedelta, timezone -from dateutil.parser import parse import warnings -from typing import NamedTuple, IO, Optional, Union, Dict from collections import Counter -import numpy as np -import json +from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import IO, Dict, NamedTuple, Optional, Union + +import numpy as np from kloppy.domain import ( - attacking_direction_from_frame, AttackingDirection, DatasetFlag, Frame, @@ -18,6 +17,7 @@ Orientation, Period, Player, + PlayerData, Point, Point3D, PositionType, @@ -25,7 +25,7 @@ Score, Team, TrackingDataset, - PlayerData, + attacking_direction_from_frame, ) from kloppy.infra.serializers.tracking.deserializer import ( TrackingDataDeserializer, @@ -367,7 +367,9 @@ def deserialize(self, inputs: SkillCornerInputs) -> TrackingDataset: date = metadata.get("date_time") if date: - date = parse(date).astimezone(timezone.utc) + date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) game_id = metadata.get("id") if game_id: diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index 7cc08516..1ed04e1a 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -2,8 +2,7 @@ import warnings from collections import defaultdict from typing import NamedTuple, Optional, Union, IO -from datetime import timedelta, timezone -from dateutil.parser import parse +from datetime import datetime, timedelta from lxml import objectify @@ -138,9 +137,9 @@ def deserialize( ] with performance_logging("parse raw data", logger=logger): - date = parse( + date = datetime.fromisoformat( match_root.MatchInformation.General.attrib["KickoffTime"] - ).astimezone(timezone.utc) + ) game_week = match_root.MatchInformation.General.attrib["MatchDay"] game_id = match_root.MatchInformation.General.attrib["MatchId"] diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py index 831370cb..001efdfa 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py @@ -1,9 +1,8 @@ import logging -from datetime import timedelta, timezone +from datetime import datetime, timedelta, timezone import warnings from typing import Dict, Optional, Union import html -from dateutil.parser import parse from lxml import objectify @@ -184,9 +183,9 @@ def deserialize(self, inputs: TRACABInputs) -> TrackingDataset: pitch_size_height = float( match.attrib["fPitchYSizeMeters"].replace(",", ".") ) - date = parse(meta_data.match.attrib["dtDate"]).astimezone( - timezone.utc - ) + date = datetime.strptime( + meta_data.match.attrib["dtDate"], "%Y-%m-%d %H:%M:%S" + ).replace(tzinfo=timezone.utc) game_id = meta_data.match.attrib["iId"] for period in match.iterchildren(tag="period"): @@ -205,7 +204,9 @@ def deserialize(self, inputs: TRACABInputs) -> TrackingDataset: ) ) elif hasattr(meta_data, "Phase1StartFrame"): - date = parse(str(meta_data["Kickoff"])) + date = datetime.strptime( + str(meta_data["Kickoff"]), "%Y-%m-%d %H:%M:%S" + ).replace(tzinfo=timezone.utc) game_id = str(meta_data["GameID"]) id_suffix = "ID" player_item = "item" diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index b38db5fa..f0ad8ba3 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -61,11 +61,11 @@ def test_parse_f24_datetime(): """Test if the F24 datetime is correctly parsed""" # timestamps have millisecond precision assert _parse_f24_datetime("2018-09-23T15:02:13.608") == datetime( - 2018, 9, 23, 15, 2, 13, 608000, tzinfo=timezone.utc + 2018, 9, 23, 14, 2, 13, 608000, tzinfo=timezone.utc ) # milliseconds are not left-padded assert _parse_f24_datetime("2018-09-23T15:02:14.39") == datetime( - 2018, 9, 23, 15, 2, 14, 39000, tzinfo=timezone.utc + 2018, 9, 23, 14, 2, 14, 39000, tzinfo=timezone.utc ) @@ -325,7 +325,7 @@ def test_correct_deserialization(self, dataset: EventDataset): ) def test_timestamp_goal(self, dataset: EventDataset): - """Check timestamp from qualifier in case of goal""" + """Check timestamp from qualifier 374 in case of goal""" goal = dataset.get_event_by_id("2318695229") assert goal.timestamp == ( _parse_f24_datetime("2018-09-23T16:07:48.525") # event timestamp diff --git a/setup.py b/setup.py index 6d78f9c5..a2ed9746 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,6 @@ def setup_package(): "requests>=2.0.0,<3", "pytz>=2020.1", 'typing_extensions;python_version<"3.11"', - "python-dateutil>=2.8.1,<3", "sortedcontainers>=2", ], extras_require={ From edee570a0ac66b9e2f9ac5bbde64ab1b3b4bc54c Mon Sep 17 00:00:00 2001 From: Pieter Robberechts Date: Tue, 17 Dec 2024 21:01:59 +0100 Subject: [PATCH 4/8] refactor: remove numpy dependency from SkillCorner (#375) --- .../infra/serializers/tracking/skillcorner.py | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index f819a5af..32e2d670 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -1,13 +1,11 @@ import json import logging import warnings -from collections import Counter +from collections import Counter, defaultdict from datetime import datetime, timedelta, timezone from pathlib import Path from typing import IO, Dict, NamedTuple, Optional, Union -import numpy as np - from kloppy.domain import ( AttackingDirection, DatasetFlag, @@ -207,22 +205,21 @@ def _get_skillcorner_attacking_directions(cls, frames, periods): x-coords might not reflect the attacking direction. """ attacking_directions = {} - frame_period_ids = np.array([_frame.period.id for _frame in frames]) - frame_attacking_directions = np.array( - [ - attacking_direction_from_frame(frame) - if len(frame.players_data) > 0 - else AttackingDirection.NOT_SET - for frame in frames - ] - ) + # Group attacking directions by period ID + period_direction_map = defaultdict(list) + for frame in frames: + if len(frame.players_data) > 0: + direction = attacking_direction_from_frame(frame) + else: + direction = AttackingDirection.NOT_SET + period_direction_map[frame.period.id].append(direction) + + # Determine the most common attacking direction for each period for period_id in periods.keys(): - if period_id in frame_period_ids: - count = Counter( - frame_attacking_directions[frame_period_ids == period_id] - ) - attacking_directions[period_id] = count.most_common()[0][0] + if period_id in period_direction_map: + count = Counter(period_direction_map[period_id]) + attacking_directions[period_id] = count.most_common(1)[0][0] else: attacking_directions[period_id] = AttackingDirection.NOT_SET @@ -252,28 +249,33 @@ def __get_periods(cls, tracking): """gets the Periods contained in the tracking data""" periods = {} - _periods = np.array([f["period"] for f in tracking]) - unique_periods = set(_periods) - unique_periods = [ - period for period in unique_periods if period is not None - ] + # Extract unique periods while filtering out None values + unique_periods = { + frame["period"] + for frame in tracking + if frame["period"] is not None + } for period in unique_periods: + # Filter frames that belong to the current period and have valid "time" _frames = [ frame for frame in tracking if frame["period"] == period and frame["time"] is not None ] - periods[period] = Period( - id=period, - start_timestamp=timedelta( - seconds=_frames[0]["frame"] / frame_rate - ), - end_timestamp=timedelta( - seconds=_frames[-1]["frame"] / frame_rate - ), - ) + # Ensure _frames is not empty before accessing the first and last elements + if _frames: + periods[period] = Period( + id=period, + start_timestamp=timedelta( + seconds=_frames[0]["frame"] / frame_rate + ), + end_timestamp=timedelta( + seconds=_frames[-1]["frame"] / frame_rate + ), + ) + return periods @classmethod From 77a8823cc35927d48615a44c6a23633c5b05ff7c Mon Sep 17 00:00:00 2001 From: Pieter Robberechts Date: Tue, 17 Dec 2024 21:10:19 +0100 Subject: [PATCH 5/8] fix(SkillCorner): load data error for game ids 2269 and 3442 (#376) Fixes #192 --- kloppy/infra/serializers/tracking/skillcorner.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index 32e2d670..3a8c3a4a 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -131,15 +131,18 @@ def _get_frame_data( track_id = frame_record.get("track_id", None) group_name = frame_record.get("group_name", None) - if trackable_object == ball_id: - group_name = "ball" + if trackable_object == ball_id or group_name == "balls": + group_name = "balls" z = frame_record.get("z") if z is not None: z = float(z) ball_coordinates = Point3D(x=float(x), y=float(y), z=z) continue - elif trackable_object in referee_dict.keys(): + elif ( + trackable_object in referee_dict.keys() + or group_name == "referee" + ): group_name = "referee" continue # Skip Referee Coords From 452efe74737cb5c6af828e2aee621ee86b886e5c Mon Sep 17 00:00:00 2001 From: Pieter Robberechts Date: Tue, 17 Dec 2024 21:17:35 +0100 Subject: [PATCH 6/8] =?UTF-8?q?Bump=20version:=203.15.0=20=E2=86=92=203.16?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kloppy/__init__.py | 2 +- mkdocs.yml | 51 +++++++++++++++++++++------------------------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/kloppy/__init__.py b/kloppy/__init__.py index 8389a052..111b63cc 100644 --- a/kloppy/__init__.py +++ b/kloppy/__init__.py @@ -13,4 +13,4 @@ # ) # from .domain.services.state_builder import add_state -__version__ = "3.15.0" +__version__ = "3.16.0" diff --git a/mkdocs.yml b/mkdocs.yml index 7c8b8ea2..1ae00900 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,17 +1,14 @@ -site_name: kloppy 3.15.0 +site_name: kloppy 3.16.0 site_url: https://kloppy.pysport.org repo_url: https://github.com/PySport/kloppy repo_name: 'GitHub' edit_uri: blob/master/docs/ extra_css: [style.css] - # TODO: set-up Google Analytics project to track -google_analytics: null - +google_analytics: null theme: name: material custom_dir: docs/overrides - nav: - Home: index.md - Open-data: open-data.md @@ -28,30 +25,29 @@ nav: - TRACAB: getting-started/tracab.ipynb - Wyscout: getting-started/wyscout.ipynb - Examples: - - Event Data: examples/event_data.ipynb - - Tracking Data: examples/tracking_data.ipynb - - Broadcast Tracking Data: examples/broadcast_tracking_data.ipynb - - Code data: examples/code_data.ipynb - - State: examples/state.ipynb - - Navigating: examples/navigating.ipynb - - Plotting: examples/plotting.ipynb - - Config: examples/config.ipynb - - Adapters: examples/adapter.ipynb -# - API Reference: -# - Domain: -# - Common: api/domain/common.md -# - Pitch: api/domain/pitch.md -# - Tracking: api/domain/tracking.md -# - Event: api/domain/event.md + - Event Data: examples/event_data.ipynb + - Tracking Data: examples/tracking_data.ipynb + - Broadcast Tracking Data: examples/broadcast_tracking_data.ipynb + - Code data: examples/code_data.ipynb + - State: examples/state.ipynb + - Navigating: examples/navigating.ipynb + - Plotting: examples/plotting.ipynb + - Config: examples/config.ipynb + - Adapters: examples/adapter.ipynb + # - API Reference: + # - Domain: + # - Common: api/domain/common.md + # - Pitch: api/domain/pitch.md + # - Tracking: api/domain/tracking.md + # - Event: api/domain/event.md - Providers: 'providers.md' - Other: - - Issues: 'issues.md' - - Contributing: 'contributing.md' - - Sponsors: 'sponsors.md' - - About: 'about.md' - - Changelog: 'changelog.md' - - License: 'license.md' - + - Issues: 'issues.md' + - Contributing: 'contributing.md' + - Sponsors: 'sponsors.md' + - About: 'about.md' + - Changelog: 'changelog.md' + - License: 'license.md' plugins: - mkdocs-jupyter: include_source: True @@ -69,7 +65,6 @@ plugins: - exclude: glob: - presentations/* - markdown_extensions: - pymdownx.highlight: use_pygments: true From 3b40d02735f706fbc26ed9635623954ae8962a86 Mon Sep 17 00:00:00 2001 From: Dries Deprest Date: Wed, 18 Dec 2024 14:22:12 +0100 Subject: [PATCH 7/8] fix(Wyscout v3): Handle unrecognized players gracefully to prevent crashes (#358) --- kloppy/exceptions.py | 4 + .../event/wyscout/deserializer_v3.py | 22 +- kloppy/tests/prs/pr_358/test_pr_358.py | 19 + .../tests/prs/pr_358/wyscout_events_v3.json | 3086 +++++++++++++++++ 4 files changed, 3125 insertions(+), 6 deletions(-) create mode 100644 kloppy/tests/prs/pr_358/test_pr_358.py create mode 100644 kloppy/tests/prs/pr_358/wyscout_events_v3.json diff --git a/kloppy/exceptions.py b/kloppy/exceptions.py index c6970585..e15a3338 100644 --- a/kloppy/exceptions.py +++ b/kloppy/exceptions.py @@ -40,3 +40,7 @@ class UnknownEncoderError(KloppyError): class KloppyParameterError(KloppyError): pass + + +class DeserializationWarning(Warning): + pass diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 1ef620e4..bd9fb8f9 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -1,5 +1,6 @@ import json import logging +import warnings from dataclasses import replace from datetime import datetime, timedelta, timezone from enum import Enum @@ -37,7 +38,7 @@ TakeOnResult, Team, ) -from kloppy.exceptions import DeserializationError +from kloppy.exceptions import DeserializationError, DeserializationWarning from kloppy.utils import performance_logging from ..deserializer import EventDataDeserializer @@ -780,6 +781,19 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: str(raw_event["possession"]["team"]["id"]) ] + if player_id == INVALID_PLAYER: + player = None + elif player_id not in players[team_id]: + player = None + warnings.warn( + f"Event {raw_event['id']} was performed by player {player_id} and team {team_id}, " + f"but the player does not appear to be part of that team's lineup. " + f"Handled by setting the event's player to None.", + DeserializationWarning, + ) + else: + player = players[team_id][player_id] + generic_event_args = { "event_id": raw_event["id"], "raw_event": raw_event, @@ -792,11 +806,7 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset: else None ), "team": team, - "player": ( - players[team_id][player_id] - if player_id != INVALID_PLAYER - else None - ), + "player": player, "ball_owning_team": ball_owning_team, "ball_state": None, "period": periods[-1], diff --git a/kloppy/tests/prs/pr_358/test_pr_358.py b/kloppy/tests/prs/pr_358/test_pr_358.py new file mode 100644 index 00000000..e2c1ab82 --- /dev/null +++ b/kloppy/tests/prs/pr_358/test_pr_358.py @@ -0,0 +1,19 @@ +import pytest + +from kloppy import wyscout +from kloppy.exceptions import DeserializationWarning + + +def test_ignore_unknown_player(base_dir): + with pytest.warns( + DeserializationWarning, + match="the player does not appear to be part of that team's lineup", + ): + dataset = wyscout.load( + event_data=base_dir / "prs" / "pr_358" / "wyscout_events_v3.json", + coordinates="wyscout", + ) + + assert len(dataset.events) == 2 + + assert dataset.events[1].player is None diff --git a/kloppy/tests/prs/pr_358/wyscout_events_v3.json b/kloppy/tests/prs/pr_358/wyscout_events_v3.json new file mode 100644 index 00000000..ce350053 --- /dev/null +++ b/kloppy/tests/prs/pr_358/wyscout_events_v3.json @@ -0,0 +1,3086 @@ +{ + "meta": [], + "events": [ + { + "id": 1927028854, + "matchId": 5154199, + "matchPeriod": "1H", + "minute": 0, + "second": 3, + "matchTimestamp": "00:00:03.211", + "videoTimestamp": "4.211804", + "relatedEventId": 1927028859, + "type": { + "primary": "pass", + "secondary": [ + "back_pass", + "short_or_medium_pass" + ] + }, + "location": { + "x": 51, + "y": 51 + }, + "team": { + "id": 3164, + "name": "Sampdoria", + "formation": "4-5-1" + }, + "opponentTeam": { + "id": 3159, + "name": "Juventus", + "formation": "3-4-2-1" + }, + "player": { + "id": 239298, + "name": "F. Bonazzoli", + "position": "CF" + }, + "pass": { + "accurate": true, + "angle": 180, + "height": null, + "length": 18, + "recipient": { + "id": 21006, + "name": "L. Tonelli", + "position": "RCB" + }, + "endLocation": { + "x": 34, + "y": 52 + } + }, + "shot": null, + "groundDuel": null, + "aerialDuel": null, + "infraction": null, + "carry": null, + "possession": { + "id": 1927028854, + "duration": "8.693585", + "types": [ + "attack" + ], + "eventsNumber": 7, + "eventIndex": 0, + "startLocation": { + "x": 51, + "y": 51 + }, + "endLocation": { + "x": 86, + "y": 98 + }, + "team": { + "id": 3164, + "name": "Sampdoria", + "formation": "4-5-1" + }, + "attack": { + "withShot": false, + "withShotOnGoal": false, + "withGoal": false, + "flank": "right", + "xg": 0 + } + } + }, + { + "id": 1927028859, + "matchId": 5154199, + "matchPeriod": "1H", + "minute": 0, + "second": 4, + "matchTimestamp": "00:00:04.227", + "videoTimestamp": "5.227381", + "relatedEventId": 1927028860, + "type": { + "primary": "pass", + "secondary": [ + "short_or_medium_pass" + ] + }, + "location": { + "x": 34, + "y": 52 + }, + "team": { + "id": 3164, + "name": "Sampdoria", + "formation": "4-5-1" + }, + "opponentTeam": { + "id": 3159, + "name": "Juventus", + "formation": "3-4-2-1" + }, + "player": { + "id": 1, + "name": "H. Vanaken", + "position": "CAM" + }, + "pass": { + "accurate": true, + "angle": 124, + "height": null, + "length": 3, + "recipient": { + "id": 215503, + "name": "O. Colley", + "position": "LCB" + }, + "endLocation": { + "x": 32, + "y": 56 + } + }, + "shot": null, + "groundDuel": null, + "aerialDuel": null, + "infraction": null, + "carry": null, + "possession": { + "id": 1927028854, + "duration": "8.693585", + "types": [ + "attack" + ], + "eventsNumber": 7, + "eventIndex": 1, + "startLocation": { + "x": 51, + "y": 51 + }, + "endLocation": { + "x": 86, + "y": 98 + }, + "team": { + "id": 3164, + "name": "Sampdoria", + "formation": "4-5-1" + }, + "attack": { + "withShot": false, + "withShotOnGoal": false, + "withGoal": false, + "flank": "right", + "xg": 0 + } + } + } + ], + "match": { + "wyId": 5154199, + "gsmId": 5154199, + "label": "Juventus - Sampdoria, 3 - 0", + "date": "September 20, 2020 at 8:45:00 PM GMT+2", + "dateutc": "2020-09-20 18:45:00", + "status": "Played", + "duration": "Regular", + "winner": 3159, + "competitionId": 524, + "seasonId": 186353, + "roundId": 4422711, + "gameweek": 1, + "teamsData": { + "3159": { + "teamId": 3159, + "side": "home", + "score": 3, + "scoreHT": 1, + "scoreET": 0, + "scoreP": 0, + "coachId": 679990, + "hasFormation": 1, + "formation": { + "lineup": [ + { + "playerId": 489124, + "goals": "0", + "ownGoals": "0", + "yellowCards": "54", + "redCards": "0", + "shirtNumber": 38, + "assists": "0" + }, + { + "playerId": 20751, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 16, + "assists": "0" + }, + { + "playerId": 472363, + "goals": "1", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 44, + "assists": "0" + }, + { + "playerId": 20461, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 3, + "assists": "0" + }, + { + "playerId": 209117, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 25, + "assists": "0" + }, + { + "playerId": 7870, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 8, + "assists": "1" + }, + { + "playerId": 3322, + "goals": "1", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 7, + "assists": "0" + }, + { + "playerId": 70083, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 13, + "assists": "0" + }, + { + "playerId": 20459, + "goals": "1", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 19, + "assists": "0" + }, + { + "playerId": 471348, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 14, + "assists": "0" + }, + { + "playerId": 7849, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 1, + "assists": "0" + } + ], + "bench": [ + { + "playerId": 255256, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 5, + "assists": "0" + }, + { + "playerId": 21128, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 31, + "assists": "0" + }, + { + "playerId": 134427, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 24, + "assists": "0" + }, + { + "playerId": 20455, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 77, + "assists": "0" + }, + { + "playerId": 485096, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 40, + "assists": "0" + }, + { + "playerId": 472395, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 41, + "assists": "0" + }, + { + "playerId": 496673, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 39, + "assists": "0" + }, + { + "playerId": 105334, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 11, + "assists": "0" + }, + { + "playerId": 20395, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 2, + "assists": "0" + }, + { + "playerId": 345695, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 28, + "assists": "0" + }, + { + "playerId": 361807, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 30, + "assists": "0" + } + ], + "substitutions": [ + { + "minute": 67, + "playerIn": 20395, + "playerOut": 489124, + "assists": "0" + }, + { + "minute": 78, + "playerIn": 361807, + "playerOut": 20751, + "assists": "0" + }, + { + "minute": 82, + "playerIn": 105334, + "playerOut": 472363, + "assists": "0" + }, + { + "minute": 83, + "playerIn": 345695, + "playerOut": 20461, + "assists": "0" + } + ] + } + }, + "3164": { + "teamId": 3164, + "side": "away", + "score": 0, + "scoreHT": 0, + "scoreET": 0, + "scoreP": 0, + "coachId": 20508, + "hasFormation": 1, + "formation": { + "lineup": [ + { + "playerId": 415809, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 12, + "assists": "0" + }, + { + "playerId": 21006, + "goals": "0", + "ownGoals": "0", + "yellowCards": "4", + "redCards": "0", + "shirtNumber": 21, + "assists": "0" + }, + { + "playerId": 449978, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 26, + "assists": "0" + }, + { + "playerId": 239298, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 9, + "assists": "0" + }, + { + "playerId": 237057, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 18, + "assists": "0" + }, + { + "playerId": 20876, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 6, + "assists": "0" + }, + { + "playerId": 99833, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 24, + "assists": "0" + }, + { + "playerId": 257027, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 1, + "assists": "0" + }, + { + "playerId": 354547, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 14, + "assists": "0" + }, + { + "playerId": 215503, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 15, + "assists": "0" + }, + { + "playerId": 222964, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 3, + "assists": "0" + } + ], + "bench": [ + { + "playerId": 263433, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 25, + "assists": "0" + }, + { + "playerId": 372408, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 20, + "assists": "0" + }, + { + "playerId": 391740, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 5, + "assists": "0" + }, + { + "playerId": 134429, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 28, + "assists": "0" + }, + { + "playerId": 21331, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 30, + "assists": "0" + }, + { + "playerId": 434154, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 4, + "assists": "0" + }, + { + "playerId": 21004, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 19, + "assists": "0" + }, + { + "playerId": 20479, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 27, + "assists": "0" + }, + { + "playerId": 20689, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 11, + "assists": "0" + }, + { + "playerId": 449472, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 38, + "assists": "0" + }, + { + "playerId": 703, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 22, + "assists": "0" + }, + { + "playerId": 20446, + "goals": "0", + "ownGoals": "0", + "yellowCards": "0", + "redCards": "0", + "shirtNumber": 8, + "assists": "0" + } + ], + "substitutions": [ + { + "minute": 46, + "playerIn": 20479, + "playerOut": 415809, + "assists": "0" + }, + { + "minute": 46, + "playerIn": 20689, + "playerOut": 21006, + "assists": "0" + }, + { + "minute": 46, + "playerIn": 703, + "playerOut": 449978, + "assists": "0" + }, + { + "minute": 70, + "playerIn": 449472, + "playerOut": 239298, + "assists": "0" + }, + { + "minute": 70, + "playerIn": 20446, + "playerOut": 237057, + "assists": "0" + } + ] + } + } + }, + "venue": null, + "referees": [ + { + "refereeId": 396780, + "role": "referee" + }, + { + "refereeId": 378959, + "role": "firstAssistant" + }, + { + "refereeId": 420139, + "role": "secondAssistant" + }, + { + "refereeId": 393581, + "role": "fourthOfficial" + }, + { + "refereeId": 0, + "role": "firstAdditionalAssistant" + }, + { + "refereeId": 0, + "role": "secondAdditionalAssistant" + } + ], + "hasDataAvailable": true + }, + "formations": { + "3159": { + "1H": { + "0": { + "3-4-2-1": { + "id": 2589485, + "scheme": "3-4-2-1", + "matchPeriod": "1H", + "startSec": 0, + "endSec": 3987, + "players": [ + { + "209117": { + "playerId": 209117, + "position": "rcmf" + } + }, + { + "489124": { + "playerId": 489124, + "position": "lwb" + } + }, + { + "20461": { + "playerId": 20461, + "position": "lcb3" + } + }, + { + "20459": { + "playerId": 20459, + "position": "cb" + } + }, + { + "471348": { + "playerId": 471348, + "position": "lcmf" + } + }, + { + "70083": { + "playerId": 70083, + "position": "rcb3" + } + }, + { + "7849": { + "playerId": 7849, + "position": "gk" + } + }, + { + "20751": { + "playerId": 20751, + "position": "rwb" + } + }, + { + "3322": { + "playerId": 3322, + "position": "cf" + } + }, + { + "7870": { + "playerId": 7870, + "position": "amf" + } + }, + { + "472363": { + "playerId": 472363, + "position": "amf" + } + } + ] + } + } + }, + "2H": { + "1278": { + "3-4-2-1": { + "id": 2589662, + "scheme": "3-4-2-1", + "matchPeriod": "2H", + "startSec": 1278, + "endSec": 1951, + "players": [ + { + "7870": { + "playerId": 7870, + "position": "amf" + } + }, + { + "472363": { + "playerId": 472363, + "position": "amf" + } + }, + { + "20395": { + "playerId": 20395, + "position": "rwb" + } + }, + { + "209117": { + "playerId": 209117, + "position": "rcmf" + } + }, + { + "20461": { + "playerId": 20461, + "position": "lcb3" + } + }, + { + "20459": { + "playerId": 20459, + "position": "cb" + } + }, + { + "471348": { + "playerId": 471348, + "position": "lcmf" + } + }, + { + "70083": { + "playerId": 70083, + "position": "rcb3" + } + }, + { + "7849": { + "playerId": 7849, + "position": "gk" + } + }, + { + "20751": { + "playerId": 20751, + "position": "lwb" + } + }, + { + "3322": { + "playerId": 3322, + "position": "cf" + } + } + ] + } + }, + "1951": { + "3-4-2-1": { + "id": 2589672, + "scheme": "3-4-2-1", + "matchPeriod": "2H", + "startSec": 1951, + "endSec": 2192, + "players": [ + { + "70083": { + "playerId": 70083, + "position": "rcb3" + } + }, + { + "7849": { + "playerId": 7849, + "position": "gk" + } + }, + { + "3322": { + "playerId": 3322, + "position": "cf" + } + }, + { + "7870": { + "playerId": 7870, + "position": "amf" + } + }, + { + "361807": { + "playerId": 361807, + "position": "rcmf" + } + }, + { + "472363": { + "playerId": 472363, + "position": "amf" + } + }, + { + "20395": { + "playerId": 20395, + "position": "lwb" + } + }, + { + "209117": { + "playerId": 209117, + "position": "lcmf" + } + }, + { + "20461": { + "playerId": 20461, + "position": "lcb3" + } + }, + { + "20459": { + "playerId": 20459, + "position": "cb" + } + }, + { + "471348": { + "playerId": 471348, + "position": "rwb" + } + } + ] + } + }, + "2192": { + "3-4-2-1": { + "id": 2589677, + "scheme": "3-4-2-1", + "matchPeriod": "2H", + "startSec": 2192, + "endSec": 2824, + "players": [ + { + "20395": { + "playerId": 20395, + "position": "lwb" + } + }, + { + "209117": { + "playerId": 209117, + "position": "rcmf" + } + }, + { + "105334": { + "playerId": 105334, + "position": "amf" + } + }, + { + "20459": { + "playerId": 20459, + "position": "cb" + } + }, + { + "471348": { + "playerId": 471348, + "position": "rwb" + } + }, + { + "70083": { + "playerId": 70083, + "position": "rcb3" + } + }, + { + "7849": { + "playerId": 7849, + "position": "gk" + } + }, + { + "3322": { + "playerId": 3322, + "position": "cf" + } + }, + { + "7870": { + "playerId": 7870, + "position": "amf" + } + }, + { + "345695": { + "playerId": 345695, + "position": "lcb3" + } + }, + { + "361807": { + "playerId": 361807, + "position": "lcmf" + } + } + ] + } + } + } + }, + "3164": { + "1H": { + "0": { + "4-5-1": { + "id": 2589548, + "scheme": "4-5-1", + "matchPeriod": "1H", + "startSec": 0, + "endSec": 2713, + "players": [ + { + "237057": { + "playerId": 237057, + "position": "rcmf3" + } + }, + { + "20876": { + "playerId": 20876, + "position": "dmf" + } + }, + { + "354547": { + "playerId": 354547, + "position": "lcmf3" + } + }, + { + "415809": { + "playerId": 415809, + "position": "rw" + } + }, + { + "215503": { + "playerId": 215503, + "position": "lcb" + } + }, + { + "257027": { + "playerId": 257027, + "position": "gk" + } + }, + { + "99833": { + "playerId": 99833, + "position": "rb" + } + }, + { + "449978": { + "playerId": 449978, + "position": "lw" + } + }, + { + "222964": { + "playerId": 222964, + "position": "lb" + } + }, + { + "239298": { + "playerId": 239298, + "position": "cf" + } + }, + { + "21006": { + "playerId": 21006, + "position": "rcb" + } + } + ] + } + } + }, + "2H": { + "4": { + "4-3-1-2": { + "id": 2589654, + "scheme": "4-3-1-2", + "matchPeriod": "2H", + "startSec": 4, + "endSec": 1461, + "players": [ + { + "215503": { + "playerId": 215503, + "position": "lcb" + } + }, + { + "257027": { + "playerId": 257027, + "position": "gk" + } + }, + { + "99833": { + "playerId": 99833, + "position": "rb" + } + }, + { + "222964": { + "playerId": 222964, + "position": "lb" + } + }, + { + "239298": { + "playerId": 239298, + "position": "cf" + } + }, + { + "237057": { + "playerId": 237057, + "position": "rcmf3" + } + }, + { + "703": { + "playerId": 703, + "position": "rcb" + } + }, + { + "20876": { + "playerId": 20876, + "position": "dmf" + } + }, + { + "354547": { + "playerId": 354547, + "position": "lcmf3" + } + }, + { + "20479": { + "playerId": 20479, + "position": "ss" + } + }, + { + "20689": { + "playerId": 20689, + "position": "amf" + } + } + ] + } + }, + "1461": { + "4-4-2": { + "id": 2589686, + "scheme": "4-4-2", + "matchPeriod": "2H", + "startSec": 1461, + "endSec": 2824, + "players": [ + { + "703": { + "playerId": 703, + "position": "rcb" + } + }, + { + "20876": { + "playerId": 20876, + "position": "lcmf" + } + }, + { + "449472": { + "playerId": 449472, + "position": "rw" + } + }, + { + "354547": { + "playerId": 354547, + "position": "lw" + } + }, + { + "20479": { + "playerId": 20479, + "position": "cf" + } + }, + { + "20689": { + "playerId": 20689, + "position": "ss" + } + }, + { + "215503": { + "playerId": 215503, + "position": "lcb" + } + }, + { + "257027": { + "playerId": 257027, + "position": "gk" + } + }, + { + "99833": { + "playerId": 99833, + "position": "rb" + } + }, + { + "20446": { + "playerId": 20446, + "position": "rcmf" + } + }, + { + "222964": { + "playerId": 222964, + "position": "lb" + } + } + ] + } + } + } + } + }, + "substitutions": { + "3159": { + "2H": { + "1278": { + "in": [ + { + "playerId": 20395 + } + ], + "out": [ + { + "playerId": 489124 + } + ] + }, + "1951": { + "in": [ + { + "playerId": 361807 + } + ], + "out": [ + { + "playerId": 20751 + } + ] + }, + "2192": { + "in": [ + { + "playerId": 105334 + }, + { + "playerId": 345695 + } + ], + "out": [ + { + "playerId": 472363 + }, + { + "playerId": 20461 + } + ] + } + } + }, + "3164": { + "2H": { + "4": { + "in": [ + { + "playerId": 703 + }, + { + "playerId": 20479 + }, + { + "playerId": 20689 + } + ], + "out": [ + { + "playerId": 415809 + }, + { + "playerId": 449978 + }, + { + "playerId": 21006 + } + ] + }, + "1461": { + "in": [ + { + "playerId": 449472 + }, + { + "playerId": 20446 + } + ], + "out": [ + { + "playerId": 239298 + }, + { + "playerId": 237057 + } + ] + } + } + } + }, + "teams": { + "3159": { + "team": { + "wyId": 3159, + "gsmId": 1242, + "name": "Juventus", + "officialName": "Juventus FC", + "city": "Torino", + "area": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "type": "club", + "category": "default", + "gender": "male", + "children": [ + { + "name": "Juventus U20", + "wyId": 76395 + }, + { + "name": "Juventus U14", + "wyId": 65171 + }, + { + "name": "Juventus Next Gen", + "wyId": 63044 + }, + { + "name": "Juventus U16", + "wyId": 32081 + }, + { + "name": "Juventus U15", + "wyId": 20450 + }, + { + "name": "Juventus U19", + "wyId": 26540 + }, + { + "name": "Juventus U17", + "wyId": 32859 + } + ], + "imageDataURL": "https://cdn5.wyscout.com/photos/team/public/80_120x120.png" + } + }, + "3164": { + "team": { + "wyId": 3164, + "gsmId": 1247, + "name": "Sampdoria", + "officialName": "UC Sampdoria", + "city": "Genova", + "area": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "type": "club", + "category": "default", + "gender": "male", + "children": [ + { + "name": "Sampdoria U20", + "wyId": 76399 + }, + { + "name": "Sampdoria U14", + "wyId": 65449 + }, + { + "name": "Sampdoria U18", + "wyId": 65358 + }, + { + "name": "Sampdoria U16", + "wyId": 36488 + }, + { + "name": "Sampdoria U17", + "wyId": 22302 + }, + { + "name": "Sampdoria U15", + "wyId": 21663 + }, + { + "name": "Sampdoria U19", + "wyId": 3710 + } + ], + "imageDataURL": "https://cdn5.wyscout.com/photos/team/public/88_120x120.png" + } + } + }, + "players": { + "3159": [ + { + "player": { + "wyId": 489124, + "gsmId": 472023, + "shortName": "G. Frabotta", + "firstName": "Gianluca", + "middleName": "", + "lastName": "Frabotta", + "height": 187, + "weight": 70, + "birthDate": "1999-06-24", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "left", + "currentTeamId": 1627, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g472023_100x130.png" + } + }, + { + "player": { + "wyId": 20751, + "gsmId": 61414, + "shortName": "J. Cuadrado", + "firstName": "Juan Guillermo", + "middleName": "", + "lastName": "Cuadrado Bello", + "height": 176, + "weight": 72, + "birthDate": "1988-05-26", + "birthArea": { + "id": 170, + "alpha2code": "CO", + "alpha3code": "COL", + "name": "Colombia" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/24886_100x130.png" + } + }, + { + "player": { + "wyId": 472363, + "gsmId": 460405, + "shortName": "D. Kulusevski", + "firstName": "Dejan", + "middleName": "", + "lastName": "Kulusevski", + "height": 186, + "weight": 75, + "birthDate": "2000-04-25", + "birthArea": { + "id": 752, + "alpha2code": "SE", + "alpha3code": "SWE", + "name": "Sweden" + }, + "passportArea": { + "id": 807, + "alpha2code": "MK", + "alpha3code": "MKD", + "name": "Macedonia FYR" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "left", + "currentTeamId": 1624, + "currentNationalTeamId": 7047, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g460405_100x130.png" + } + }, + { + "player": { + "wyId": 20461, + "gsmId": 17684, + "shortName": "G. Chiellini", + "firstName": "Giorgio", + "middleName": "", + "lastName": "Chiellini", + "height": 187, + "weight": 86, + "birthDate": "1984-08-14", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "left", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "retired", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/445_100x130.png" + } + }, + { + "player": { + "wyId": 209117, + "gsmId": 242768, + "shortName": "A. Rabiot", + "firstName": "Adrien", + "middleName": "", + "lastName": "Rabiot", + "height": 188, + "weight": 80, + "birthDate": "1995-04-03", + "birthArea": { + "id": 250, + "alpha2code": "FR", + "alpha3code": "FRA", + "name": "France" + }, + "passportArea": { + "id": 250, + "alpha2code": "FR", + "alpha3code": "FRA", + "name": "France" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "left", + "currentTeamId": null, + "currentNationalTeamId": 4418, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/80854_100x130.png" + } + }, + { + "player": { + "wyId": 7870, + "gsmId": 66532, + "shortName": "A. Ramsey", + "firstName": "Aaron", + "middleName": "", + "lastName": "Ramsey", + "height": 183, + "weight": 76, + "birthDate": "1990-12-26", + "birthArea": { + "id": 1013, + "alpha2code": "WA", + "alpha3code": "XWA", + "name": "Wales" + }, + "passportArea": { + "id": 1013, + "alpha2code": "WA", + "alpha3code": "XWA", + "name": "Wales" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 10529, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/12398_100x130.png" + } + }, + { + "player": { + "wyId": 3322, + "gsmId": 382, + "shortName": "Cristiano Ronaldo", + "firstName": "Cristiano Ronaldo", + "middleName": "", + "lastName": "dos Santos Aveiro", + "height": 187, + "weight": 83, + "birthDate": "1985-02-05", + "birthArea": { + "id": 620, + "alpha2code": "PT", + "alpha3code": "PRT", + "name": "Portugal" + }, + "passportArea": { + "id": 620, + "alpha2code": "PT", + "alpha3code": "PRT", + "name": "Portugal" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "right", + "currentTeamId": 16470, + "currentNationalTeamId": 9905, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/5045_100x130.png" + } + }, + { + "player": { + "wyId": 70083, + "gsmId": 75848, + "shortName": "Danilo", + "firstName": "Danilo Luiz", + "middleName": "", + "lastName": "da Silva", + "height": 184, + "weight": 78, + "birthDate": "1991-07-15", + "birthArea": { + "id": 76, + "alpha2code": "BR", + "alpha3code": "BRA", + "name": "Brazil" + }, + "passportArea": { + "id": 76, + "alpha2code": "BR", + "alpha3code": "BRA", + "name": "Brazil" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 3159, + "currentNationalTeamId": 6380, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/37089_100x130.png" + } + }, + { + "player": { + "wyId": 20459, + "gsmId": 3979, + "shortName": "L. Bonucci", + "firstName": "Leonardo", + "middleName": "", + "lastName": "Bonucci", + "height": 189, + "weight": 85, + "birthDate": "1987-05-01", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "retired", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/13748_100x130.png" + } + }, + { + "player": { + "wyId": 471348, + "gsmId": 459693, + "shortName": "W. McKennie", + "firstName": "Weston ", + "middleName": "", + "lastName": "McKennie", + "height": 185, + "weight": 84, + "birthDate": "1998-08-28", + "birthArea": { + "id": 840, + "alpha2code": "US", + "alpha3code": "USA", + "name": "United States" + }, + "passportArea": { + "id": 840, + "alpha2code": "US", + "alpha3code": "USA", + "name": "United States" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3159, + "currentNationalTeamId": 8134, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g459693_100x130.png" + } + }, + { + "player": { + "wyId": 7849, + "gsmId": 16085, + "shortName": "W. Szczęsny", + "firstName": "Wojciech ", + "middleName": "", + "lastName": "Szczęsny", + "height": 195, + "weight": 90, + "birthDate": "1990-04-18", + "birthArea": { + "id": 616, + "alpha2code": "PL", + "alpha3code": "POL", + "name": "Poland" + }, + "passportArea": { + "id": 616, + "alpha2code": "PL", + "alpha3code": "POL", + "name": "Poland" + }, + "role": { + "name": "Goalkeeper", + "code2": "GK", + "code3": "GKP" + }, + "foot": "right", + "currentTeamId": null, + "currentNationalTeamId": 13869, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/19133_100x130.png" + } + }, + { + "player": { + "wyId": 255256, + "gsmId": 288085, + "shortName": "Arthur", + "firstName": "Arthur Henrique", + "middleName": "", + "lastName": "Ramos de Oliveira Melo", + "height": 171, + "weight": 73, + "birthDate": "1996-08-12", + "birthArea": { + "id": 76, + "alpha2code": "BR", + "alpha3code": "BRA", + "name": "Brazil" + }, + "passportArea": { + "id": 76, + "alpha2code": "BR", + "alpha3code": "BRA", + "name": "Brazil" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3159, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g288085_100x130.png" + } + }, + { + "player": { + "wyId": 21128, + "gsmId": 95779, + "shortName": "C. Pinsoglio", + "firstName": "Carlo", + "middleName": "", + "lastName": "Pinsoglio", + "height": 194, + "weight": 85, + "birthDate": "1990-03-16", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Goalkeeper", + "code2": "GK", + "code3": "GKP" + }, + "foot": "left", + "currentTeamId": 3159, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/37632_100x130.png" + } + }, + { + "player": { + "wyId": 134427, + "gsmId": 162676, + "shortName": "D. Rugani", + "firstName": "Daniele", + "middleName": "", + "lastName": "Rugani", + "height": 190, + "weight": 84, + "birthDate": "1994-07-29", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 3159, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/79321_100x130.png" + } + }, + { + "player": { + "wyId": 20455, + "gsmId": 53, + "shortName": "G. Buffon", + "firstName": "Gianluigi", + "middleName": "", + "lastName": "Buffon", + "height": 192, + "weight": 92, + "birthDate": "1978-01-28", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Goalkeeper", + "code2": "GK", + "code3": "GKP" + }, + "foot": "right", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "retired", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/442_100x130.png" + } + }, + { + "player": { + "wyId": 485096, + "gsmId": 469983, + "shortName": "G. Vrioni", + "firstName": "Giacomo", + "middleName": "", + "lastName": "Vrioni", + "height": 188, + "weight": 79, + "birthDate": "1998-10-15", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 8, + "alpha2code": "AL", + "alpha3code": "ALB", + "name": "Albania" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "left", + "currentTeamId": 7854, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g469983_100x130.png" + } + }, + { + "player": { + "wyId": 472395, + "gsmId": 460439, + "shortName": "H. Nicolussi Caviglia", + "firstName": "Hans", + "middleName": "", + "lastName": "Nicolussi Caviglia", + "height": 184, + "weight": 79, + "birthDate": "2000-06-18", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3159, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g460439_100x130.png" + } + }, + { + "player": { + "wyId": 496673, + "gsmId": 477620, + "shortName": "M. Portanova", + "firstName": "Manolo", + "middleName": "", + "lastName": "Portanova", + "height": 183, + "weight": 77, + "birthDate": "2000-06-02", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3211, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g477620_100x130.png" + } + }, + { + "player": { + "wyId": 105334, + "gsmId": 65287, + "shortName": "Douglas Costa", + "firstName": "Douglas ", + "middleName": "", + "lastName": "Costa de Souza", + "height": 172, + "weight": 70, + "birthDate": "1990-09-14", + "birthArea": { + "id": 76, + "alpha2code": "BR", + "alpha3code": "BRA", + "name": "Brazil" + }, + "passportArea": { + "id": 76, + "alpha2code": "BR", + "alpha3code": "BRA", + "name": "Brazil" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "left", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/28399_100x130.png" + } + }, + { + "player": { + "wyId": 20395, + "gsmId": 158411, + "shortName": "M. De Sciglio", + "firstName": "Mattia", + "middleName": "", + "lastName": "De Sciglio", + "height": 182, + "weight": 78, + "birthDate": "1992-10-20", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 3159, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/41462_100x130.png" + } + }, + { + "player": { + "wyId": 345695, + "gsmId": 374637, + "shortName": "M. Demiral", + "firstName": "Merih", + "middleName": "", + "lastName": "Demiral", + "height": 192, + "weight": 86, + "birthDate": "1998-03-05", + "birthArea": { + "id": 792, + "alpha2code": "TR", + "alpha3code": "TUR", + "name": "Turkey" + }, + "passportArea": { + "id": 792, + "alpha2code": "TR", + "alpha3code": "TUR", + "name": "Turkey" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 16465, + "currentNationalTeamId": 4687, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g374637_100x130.png" + } + }, + { + "player": { + "wyId": 361807, + "gsmId": 396178, + "shortName": "R. Bentancur", + "firstName": "Rodrigo", + "middleName": "", + "lastName": "Bentancur Colmán", + "height": 187, + "weight": 73, + "birthDate": "1997-06-25", + "birthArea": { + "id": 858, + "alpha2code": "UY", + "alpha3code": "URY", + "name": "Uruguay" + }, + "passportArea": { + "id": 858, + "alpha2code": "UY", + "alpha3code": "URY", + "name": "Uruguay" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 1624, + "currentNationalTeamId": 15670, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g396178_100x130.png" + } + } + ], + "3164": [ + { + "player": { + "wyId": 415809, + "gsmId": 433613, + "shortName": "F. Depaoli", + "firstName": "Fabio", + "middleName": "", + "lastName": "Depaoli", + "height": 182, + "weight": 71, + "birthDate": "1997-04-24", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 3164, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g433613_100x130.png" + } + }, + { + "player": { + "wyId": 21006, + "gsmId": 145358, + "shortName": "L. Tonelli", + "firstName": "Lorenzo", + "middleName": "", + "lastName": "Tonelli", + "height": 183, + "weight": 75, + "birthDate": "1990-01-17", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/37978_100x130.png" + } + }, + { + "player": { + "wyId": 449978, + "gsmId": 454740, + "shortName": "M. Léris", + "firstName": "Mehdi", + "middleName": "", + "lastName": "Léris", + "height": 186, + "weight": 78, + "birthDate": "1998-05-23", + "birthArea": { + "id": 250, + "alpha2code": "FR", + "alpha3code": "FRA", + "name": "France" + }, + "passportArea": { + "id": 12, + "alpha2code": "DZ", + "alpha3code": "DZA", + "name": "Algeria" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3207, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g454740_100x130.png" + } + }, + { + "player": { + "wyId": 239298, + "gsmId": 273833, + "shortName": "F. Bonazzoli", + "firstName": "Federico", + "middleName": "", + "lastName": "Bonazzoli", + "height": 182, + "weight": 74, + "birthDate": "1997-05-21", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "left", + "currentTeamId": 3235, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g273833_100x130.png" + } + }, + { + "player": { + "wyId": 237057, + "gsmId": 270648, + "shortName": "M. Thorsby", + "firstName": "Morten", + "middleName": "", + "lastName": "Thorsby", + "height": 188, + "weight": 83, + "birthDate": "1996-05-05", + "birthArea": { + "id": 578, + "alpha2code": "NO", + "alpha3code": "NOR", + "name": "Norway" + }, + "passportArea": { + "id": 578, + "alpha2code": "NO", + "alpha3code": "NOR", + "name": "Norway" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3193, + "currentNationalTeamId": 7336, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g270648_100x130.png" + } + }, + { + "player": { + "wyId": 20876, + "gsmId": 21090, + "shortName": "A. Ekdal", + "firstName": "Albin", + "middleName": "", + "lastName": "Ekdal", + "height": 188, + "weight": 82, + "birthDate": "1989-07-28", + "birthArea": { + "id": 752, + "alpha2code": "SE", + "alpha3code": "SWE", + "name": "Sweden" + }, + "passportArea": { + "id": 752, + "alpha2code": "SE", + "alpha3code": "SWE", + "name": "Sweden" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 6713, + "currentNationalTeamId": 7047, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/9700_100x130.png" + } + }, + { + "player": { + "wyId": 99833, + "gsmId": 123106, + "shortName": "B. Bereszyński", + "firstName": "Bartosz", + "middleName": "", + "lastName": "Bereszyński", + "height": 183, + "weight": 77, + "birthDate": "1992-07-12", + "birthArea": { + "id": 616, + "alpha2code": "PL", + "alpha3code": "POL", + "name": "Poland" + }, + "passportArea": { + "id": 616, + "alpha2code": "PL", + "alpha3code": "POL", + "name": "Poland" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 3164, + "currentNationalTeamId": 13869, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/51565_100x130.png" + } + }, + { + "player": { + "wyId": 257027, + "gsmId": 289808, + "shortName": "E. Audero", + "firstName": "Emil", + "middleName": "", + "lastName": "Audero Mulyadi", + "height": 192, + "weight": 83, + "birthDate": "1997-01-18", + "birthArea": { + "id": 360, + "alpha2code": "ID", + "alpha3code": "IDN", + "name": "Indonesia" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Goalkeeper", + "code2": "GK", + "code3": "GKP" + }, + "foot": "right", + "currentTeamId": 3200, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g289808_100x130.png" + } + }, + { + "player": { + "wyId": 354547, + "gsmId": 391087, + "shortName": "J. Jankto", + "firstName": "Jakub", + "middleName": "", + "lastName": "Jankto", + "height": 184, + "weight": 74, + "birthDate": "1996-01-19", + "birthArea": { + "id": 203, + "alpha2code": "CZ", + "alpha3code": "CZE", + "name": "Czech Republic" + }, + "passportArea": { + "id": 203, + "alpha2code": "CZ", + "alpha3code": "CZE", + "name": "Czech Republic" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "left", + "currentTeamId": 3173, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g391087_100x130.png" + } + }, + { + "player": { + "wyId": 215503, + "gsmId": 246644, + "shortName": "O. Colley", + "firstName": "Omar", + "middleName": "", + "lastName": "Colley", + "height": 191, + "weight": 90, + "birthDate": "1992-10-24", + "birthArea": { + "id": 270, + "alpha2code": "GM", + "alpha3code": "GMB", + "name": "Gambia" + }, + "passportArea": { + "id": 270, + "alpha2code": "GM", + "alpha3code": "GMB", + "name": "Gambia" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "left", + "currentTeamId": 4489, + "currentNationalTeamId": 18728, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/68504_100x130.png" + } + }, + { + "player": { + "wyId": 222964, + "gsmId": 256802, + "shortName": "T. Augello", + "firstName": "Tommaso", + "middleName": "", + "lastName": "Augello", + "height": 180, + "weight": 70, + "birthDate": "1994-08-30", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "left", + "currentTeamId": 3173, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g256802_100x130.png" + } + }, + { + "player": { + "wyId": 263433, + "gsmId": 296232, + "shortName": "A. Ferrari", + "firstName": "Alex", + "middleName": "", + "lastName": "Ferrari", + "height": 191, + "weight": 80, + "birthDate": "1994-07-01", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 3164, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/77028_100x130.png" + } + }, + { + "player": { + "wyId": 372408, + "gsmId": 402008, + "shortName": "A. La Gumina", + "firstName": "Antonino", + "middleName": "", + "lastName": "La Gumina", + "height": 182, + "weight": 72, + "birthDate": "1996-03-06", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "right", + "currentTeamId": 3164, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g402008_100x130.png" + } + }, + { + "player": { + "wyId": 391740, + "gsmId": 414054, + "shortName": "J. Chabot", + "firstName": "Jeffrey Julian", + "middleName": "", + "lastName": "Chabot", + "height": 195, + "weight": 85, + "birthDate": "1998-02-12", + "birthArea": { + "id": 276, + "alpha2code": "DE", + "alpha3code": "DEU", + "name": "Germany" + }, + "passportArea": { + "id": 250, + "alpha2code": "FR", + "alpha3code": "FRA", + "name": "France" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "left", + "currentTeamId": 2445, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g414054_100x130.png" + } + }, + { + "player": { + "wyId": 134429, + "gsmId": 180575, + "shortName": "L. Capezzi", + "firstName": "Leonardo", + "middleName": "", + "lastName": "Capezzi", + "height": 178, + "weight": 70, + "birthDate": "1995-03-28", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3242, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g180575_100x130.png" + } + }, + { + "player": { + "wyId": 21331, + "gsmId": 91443, + "shortName": "N. Ravaglia", + "firstName": "Nicola", + "middleName": "", + "lastName": "Ravaglia", + "height": 184, + "weight": 78, + "birthDate": "1988-12-12", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Goalkeeper", + "code2": "GK", + "code3": "GKP" + }, + "foot": "right", + "currentTeamId": 3164, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/19312_100x130.png" + } + }, + { + "player": { + "wyId": 434154, + "gsmId": 444205, + "shortName": "R. Vieira", + "firstName": "Ronaldo", + "middleName": "", + "lastName": "Vieira Nan", + "height": 178, + "weight": 70, + "birthDate": "1998-07-19", + "birthArea": { + "id": 624, + "alpha2code": "GW", + "alpha3code": "GNB", + "name": "Guinea-Bissau" + }, + "passportArea": { + "id": 826, + "alpha2code": "EN", + "alpha3code": "XEN", + "name": "England" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 3164, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g444205_100x130.png" + } + }, + { + "player": { + "wyId": 21004, + "gsmId": 71499, + "shortName": "V. Regini", + "firstName": "Vasco", + "middleName": "", + "lastName": "Regini", + "height": 185, + "weight": 79, + "birthDate": "1990-09-09", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 674, + "alpha2code": "SM", + "alpha3code": "SMR", + "name": "San Marino" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "left", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/13288_100x130.png" + } + }, + { + "player": { + "wyId": 20479, + "gsmId": 4164, + "shortName": "F. Quagliarella", + "firstName": "Fabio", + "middleName": "", + "lastName": "Quagliarella", + "height": 180, + "weight": 0, + "birthDate": "1983-01-31", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Forward", + "code2": "FW", + "code3": "FWD" + }, + "foot": "right", + "currentTeamId": null, + "currentNationalTeamId": null, + "gender": "male", + "status": "retired", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/28_100x130.png" + } + }, + { + "player": { + "wyId": 20689, + "gsmId": 91722, + "shortName": "G. Ramírez", + "firstName": "Gastón Exequiel", + "middleName": "", + "lastName": "Ramírez Pereyra", + "height": 178, + "weight": 83, + "birthDate": "1990-12-02", + "birthArea": { + "id": 858, + "alpha2code": "UY", + "alpha3code": "URY", + "name": "Uruguay" + }, + "passportArea": { + "id": 858, + "alpha2code": "UY", + "alpha3code": "URY", + "name": "Uruguay" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "left", + "currentTeamId": 15633, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/36136_100x130.png" + } + }, + { + "player": { + "wyId": 449472, + "gsmId": -45021, + "shortName": "M. Damsgaard", + "firstName": "Mikkel", + "middleName": "", + "lastName": "Damsgaard", + "height": 180, + "weight": 71, + "birthDate": "2000-07-03", + "birthArea": { + "id": 208, + "alpha2code": "DK", + "alpha3code": "DNK", + "name": "Denmark" + }, + "passportArea": { + "id": 208, + "alpha2code": "DK", + "alpha3code": "DNK", + "name": "Denmark" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "right", + "currentTeamId": 1669, + "currentNationalTeamId": 7712, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/g-45021_100x130.png" + } + }, + { + "player": { + "wyId": 703, + "gsmId": 5803, + "shortName": "M. Yoshida", + "firstName": "Maya", + "middleName": "", + "lastName": "Yoshida", + "height": 189, + "weight": 87, + "birthDate": "1988-08-24", + "birthArea": { + "id": 392, + "alpha2code": "JP", + "alpha3code": "JPN", + "name": "Japan" + }, + "passportArea": { + "id": 392, + "alpha2code": "JP", + "alpha3code": "JPN", + "name": "Japan" + }, + "role": { + "name": "Defender", + "code2": "DF", + "code3": "DEF" + }, + "foot": "right", + "currentTeamId": 7847, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/37158_100x130.png" + } + }, + { + "player": { + "wyId": 20446, + "gsmId": 162670, + "shortName": "V. Verre", + "firstName": "Valerio", + "middleName": "", + "lastName": "Verre", + "height": 181, + "weight": 70, + "birthDate": "1994-01-11", + "birthArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "passportArea": { + "id": 380, + "alpha2code": "IT", + "alpha3code": "ITA", + "name": "Italy" + }, + "role": { + "name": "Midfielder", + "code2": "MD", + "code3": "MID" + }, + "foot": "both", + "currentTeamId": 3171, + "currentNationalTeamId": null, + "gender": "male", + "status": "active", + "imageDataURL": "https://cdn5.wyscout.com/photos/players/public/56950_100x130.png" + } + } + ] + } +} \ No newline at end of file From 97ad27e41c1efa7af91c0375ff152a271ac42985 Mon Sep 17 00:00:00 2001 From: Dries Deprest Date: Wed, 18 Dec 2024 14:48:10 +0100 Subject: [PATCH 8/8] Add statistics attribute to DataRecord (#302) * add Statistic class * introduce frame factory to handle passing of statistics attribute * add xg, psxg and obv for statstbomb * add xg and psxg for opta * add xg and psxg for wyscout --- kloppy/domain/models/common.py | 80 +++++++++++++++++++ kloppy/domain/services/event_factory.py | 7 +- kloppy/domain/services/frame_factory.py | 36 +++++++++ .../domain/services/transformers/dataset.py | 3 + kloppy/infra/serializers/code/sportscode.py | 1 + .../serializers/event/statsbomb/helpers.py | 26 +++++- .../event/statsbomb/specification.py | 15 ++++ .../event/statsperform/deserializer.py | 21 ++++- .../event/wyscout/deserializer_v3.py | 14 ++++ .../infra/serializers/tracking/metrica_csv.py | 3 +- .../tracking/metrica_epts/deserializer.py | 3 +- .../serializers/tracking/secondspectrum.py | 5 +- .../infra/serializers/tracking/skillcorner.py | 5 +- .../tracking/sportec/deserializer.py | 3 +- .../serializers/tracking/statsperform.py | 5 +- .../serializers/tracking/tracab/tracab_dat.py | 5 +- .../tracking/tracab/tracab_json.py | 5 +- kloppy/tests/files/opta_f24.xml | 2 + kloppy/tests/test_helpers.py | 5 +- kloppy/tests/test_opta.py | 21 +++++ kloppy/tests/test_statsbomb.py | 9 +++ 21 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 kloppy/domain/services/frame_factory.py diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index b4880451..57e01ebc 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -928,6 +928,85 @@ class DatasetFlag(Flag): BALL_STATE = 2 +@dataclass +class Statistic(ABC): + name: str = field(init=False) + + +@dataclass +class ScalarStatistic(Statistic): + value: float + + +@dataclass +class ExpectedGoals(ScalarStatistic): + """Expected goals""" + + def __post_init__(self): + self.name = "xG" + + +@dataclass +class PostShotExpectedGoals(ScalarStatistic): + """Post-shot expected goals""" + + def __post_init__(self): + self.name = "PSxG" + + +@dataclass +class ActionValue(Statistic): + """Action value""" + + name: str + action_value_scoring_before: Optional[float] = field(default=None) + action_value_scoring_after: Optional[float] = field(default=None) + action_value_conceding_before: Optional[float] = field(default=None) + action_value_conceding_after: Optional[float] = field(default=None) + + @property + def offensive_value(self) -> Optional[float]: + return ( + None + if None + in ( + self.action_value_scoring_before, + self.action_value_scoring_after, + ) + else self.action_value_scoring_after + - self.action_value_scoring_before + ) + + @property + def defensive_value(self) -> Optional[float]: + return ( + None + if None + in ( + self.action_value_conceding_before, + self.action_value_conceding_after, + ) + else self.action_value_conceding_after + - self.action_value_conceding_before + ) + + @property + def value(self) -> Optional[float]: + if None in ( + self.action_value_scoring_before, + self.action_value_scoring_after, + self.action_value_conceding_before, + self.action_value_conceding_after, + ): + return None + return ( + self.action_value_scoring_after - self.action_value_scoring_before + ) - ( + self.action_value_conceding_after + - self.action_value_conceding_before + ) + + @dataclass class DataRecord(ABC): """ @@ -945,6 +1024,7 @@ class DataRecord(ABC): next_record: Optional["DataRecord"] = field(init=False) period: Period timestamp: timedelta + statistics: List[Statistic] ball_owning_team: Optional[Team] ball_state: Optional[BallState] diff --git a/kloppy/domain/services/event_factory.py b/kloppy/domain/services/event_factory.py index 7afe01c3..89bc46c7 100644 --- a/kloppy/domain/services/event_factory.py +++ b/kloppy/domain/services/event_factory.py @@ -48,6 +48,9 @@ def create_event(event_cls: Type[T], **kwargs) -> T: if "freeze_frame" not in kwargs: kwargs["freeze_frame"] = None + if "statistics" not in kwargs: + kwargs["statistics"] = [] + all_kwargs = dict(**kwargs, **extra_kwargs) relevant_kwargs = { @@ -66,7 +69,9 @@ def create_event(event_cls: Type[T], **kwargs) -> T: f"The following arguments were skipped: {skipped_kwargs}" ) - return event_cls(**relevant_kwargs) + event = event_cls(**relevant_kwargs) + + return event class EventFactory: diff --git a/kloppy/domain/services/frame_factory.py b/kloppy/domain/services/frame_factory.py new file mode 100644 index 00000000..d53fe41d --- /dev/null +++ b/kloppy/domain/services/frame_factory.py @@ -0,0 +1,36 @@ +import dataclasses +import warnings +from dataclasses import fields + +from kloppy.domain import Frame + + +def create_frame(**kwargs) -> Frame: + """ + Do the actual construction of a frame. + + This method does a couple of things: + 1. Fill in some arguments when not passed + 2. Pass only arguments that are accepted by the Frame class. + """ + if "statistics" not in kwargs: + kwargs["statistics"] = [] + + relevant_kwargs = { + field.name: kwargs.get(field.name, field.default) + for field in fields(Frame) + if field.init + and not ( + field.default == dataclasses.MISSING and field.name not in kwargs + ) + } + + if len(relevant_kwargs) < len(kwargs): + skipped_kwargs = set(kwargs.keys()) - set(relevant_kwargs.keys()) + warnings.warn( + f"The following arguments were skipped: {skipped_kwargs}" + ) + + frame = Frame(**relevant_kwargs) + + return frame diff --git a/kloppy/domain/services/transformers/dataset.py b/kloppy/domain/services/transformers/dataset.py index e208e0b5..349d036f 100644 --- a/kloppy/domain/services/transformers/dataset.py +++ b/kloppy/domain/services/transformers/dataset.py @@ -220,6 +220,7 @@ def __change_frame_coordinate_system(self, frame: Frame): for key, player_data in frame.players_data.items() }, other_data=frame.other_data, + statistics=frame.statistics, ) def __change_frame_dimensions(self, frame: Frame): @@ -246,6 +247,7 @@ def __change_frame_dimensions(self, frame: Frame): for key, player_data in frame.players_data.items() }, other_data=frame.other_data, + statistics=frame.statistics, ) def __change_point_coordinate_system( @@ -303,6 +305,7 @@ def __flip_frame(self, frame: Frame): ball_coordinates=self.flip_point(frame.ball_coordinates), players_data=players_data, other_data=frame.other_data, + statistics=frame.statistics, ) def transform_event(self, event: Event) -> Event: diff --git a/kloppy/infra/serializers/code/sportscode.py b/kloppy/infra/serializers/code/sportscode.py index 612bf8ca..c4ccb8c1 100644 --- a/kloppy/infra/serializers/code/sportscode.py +++ b/kloppy/infra/serializers/code/sportscode.py @@ -68,6 +68,7 @@ def deserialize(self, inputs: SportsCodeInputs) -> CodeDataset: labels=parse_labels(instance), ball_state=None, ball_owning_team=None, + statistics=[], ) period.end_timestamp = end_timestamp codes.append(code) diff --git a/kloppy/infra/serializers/event/statsbomb/helpers.py b/kloppy/infra/serializers/event/statsbomb/helpers.py index fc3a9ec3..5f16016f 100644 --- a/kloppy/infra/serializers/event/statsbomb/helpers.py +++ b/kloppy/infra/serializers/event/statsbomb/helpers.py @@ -11,7 +11,10 @@ Player, PlayerData, PositionType, + ActionValue, + Provider, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.exceptions import DeserializationError @@ -21,6 +24,25 @@ def parse_str_ts(timestamp: str) -> float: return timedelta(seconds=int(h) * 3600 + int(m) * 60 + float(s)) +def parse_obv_values(raw_event: dict) -> Optional[ActionValue]: + game_state_values_data = {} + obv_mapping = { + "obv_for_before": "action_value_scoring_before", + "obv_against_before": "action_value_conceding_before", + "obv_for_after": "action_value_scoring_after", + "obv_against_after": "action_value_conceding_after", + } + for sb_name, kloppy_name in obv_mapping.items(): + obv_value = raw_event.get(sb_name) + if obv_value is not None: + game_state_values_data[kloppy_name] = obv_value + + if game_state_values_data: + game_state_value = ActionValue(name="OBV", **game_state_values_data) + + return game_state_value + + def get_team_by_id(team_id: int, teams: List[Team]) -> Team: """Get a team by its id.""" if str(team_id) == teams[0].team_id: @@ -129,7 +151,7 @@ def get_player_from_freeze_frame(player_data, team, i): + event.timestamp.total_seconds() * FREEZE_FRAME_FPS ) - return Frame( + frame = create_frame( frame_id=frame_id, ball_coordinates=Point3D( x=event.coordinates.x, y=event.coordinates.y, z=0 @@ -141,3 +163,5 @@ def get_player_from_freeze_frame(player_data, team, i): ball_owning_team=event.ball_owning_team, other_data={"visible_area": visible_area}, ) + + return frame diff --git a/kloppy/infra/serializers/event/statsbomb/specification.py b/kloppy/infra/serializers/event/statsbomb/specification.py index 30bf2df2..f6fa2995 100644 --- a/kloppy/infra/serializers/event/statsbomb/specification.py +++ b/kloppy/infra/serializers/event/statsbomb/specification.py @@ -27,6 +27,8 @@ FormationType, PositionType, CounterAttackQualifier, + ExpectedGoals, + PostShotExpectedGoals, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.statsbomb.helpers import ( @@ -34,6 +36,7 @@ get_team_by_id, get_period_by_id, parse_coordinates, + parse_obv_values, ) @@ -293,6 +296,7 @@ def deserialize(self, event_factory: EventFactory) -> List[Event]: return events def _parse_generic_kwargs(self) -> Dict: + game_state_value = parse_obv_values(self.raw_event) return { "period": self.period, "timestamp": parse_str_ts(self.raw_event["timestamp"]), @@ -311,6 +315,7 @@ def _parse_generic_kwargs(self) -> Dict: ), "related_event_ids": self.raw_event.get("related_events", []), "raw_event": self.raw_event, + "statistics": [game_state_value] if game_state_value else [], } def _create_aerial_won_event( @@ -548,6 +553,16 @@ def _create_events( EVENT_TYPE.SHOT, shot_dict ) + _get_body_part_qualifiers(shot_dict) + for statistic_cls, prop_name in { + ExpectedGoals: "statsbomb_xg", + PostShotExpectedGoals: "shot_execution_xg", + }.items(): + value = shot_dict.get(prop_name, None) + if value is not None: + generic_event_kwargs["statistics"].append( + statistic_cls(value=value) + ) + shot_event = event_factory.build_shot( result=result, qualifiers=qualifiers, diff --git a/kloppy/infra/serializers/event/statsperform/deserializer.py b/kloppy/infra/serializers/event/statsperform/deserializer.py index 95bf9e9c..6271e0d2 100644 --- a/kloppy/infra/serializers/event/statsperform/deserializer.py +++ b/kloppy/infra/serializers/event/statsperform/deserializer.py @@ -35,6 +35,8 @@ GoalkeeperActionType, CounterAttackQualifier, PositionType, + ExpectedGoals, + PostShotExpectedGoals, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer @@ -152,6 +154,9 @@ EVENT_QUALIFIER_FORMATION_PLAYER_IDS = 30 EVENT_QUALIFIER_FORMATION_PLAYER_POSITIONS = 131 +EVENT_QUALIFIER_XG = 321 +EVENT_QUALIFIER_POST_SHOT_XG = 322 + event_type_names = { 1: "pass", 2: "offside pass", @@ -403,13 +408,27 @@ def _parse_shot(raw_event: OptaEvent) -> Dict: y=100 - result_coordinates.y, ) - return dict( + event_info = dict( coordinates=coordinates, result=result, result_coordinates=result_coordinates, qualifiers=qualifiers, ) + statistics = [] + for event_qualifier, statistic in zip( + [EVENT_QUALIFIER_XG, EVENT_QUALIFIER_POST_SHOT_XG], + [ExpectedGoals, PostShotExpectedGoals], + ): + xg_value = raw_event.qualifiers.get(event_qualifier) + if xg_value: + statistics.append(statistic(value=float(xg_value))) + + if statistics: + event_info["statistics"] = statistics + + return event_info + def _parse_goalkeeper_events(raw_event: OptaEvent) -> Dict: qualifiers = _get_event_qualifiers(raw_event.qualifiers) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index bd9fb8f9..637c94c9 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -37,6 +37,10 @@ ShotResult, TakeOnResult, Team, + FormationType, + CarryResult, + ExpectedGoals, + PostShotExpectedGoals, ) from kloppy.exceptions import DeserializationError, DeserializationWarning from kloppy.utils import performance_logging @@ -269,10 +273,20 @@ def _parse_shot(raw_event: Dict) -> Dict: elif raw_event["shot"]["bodyPart"] == "right_foot": qualifiers.append(BodyPartQualifier(value=BodyPart.RIGHT_FOOT)) + statistics = [] + for statistic_cls, prop_name in { + ExpectedGoals: "xg", + PostShotExpectedGoals: "postShotXg", + }.items(): + value = raw_event["shot"].get(prop_name, None) + if value is not None: + statistics.append(statistic_cls(value=value)) + return { "result": result, "result_coordinates": _create_shot_result_coordinates(raw_event), "qualifiers": qualifiers, + "statistics": statistics, } diff --git a/kloppy/infra/serializers/tracking/metrica_csv.py b/kloppy/infra/serializers/tracking/metrica_csv.py index e20c1172..629f4962 100644 --- a/kloppy/infra/serializers/tracking/metrica_csv.py +++ b/kloppy/infra/serializers/tracking/metrica_csv.py @@ -20,6 +20,7 @@ Player, PlayerData, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.infra.serializers.tracking.deserializer import ( TrackingDataDeserializer, ) @@ -188,7 +189,7 @@ def deserialize( **away_partial_frame.players_data, } - frame = Frame( + frame = create_frame( frame_id=frame_id, timestamp=timedelta(seconds=frame_id / frame_rate) - period.start_timestamp, diff --git a/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py b/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py index b81a6d45..d83fd186 100644 --- a/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py +++ b/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py @@ -11,6 +11,7 @@ PlayerData, DatasetTransformer, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.utils import performance_logging from .metadata import load_metadata, EPTSMetadata @@ -73,7 +74,7 @@ def _frame_from_row( other_data=other_data, ) - frame = Frame( + frame = create_frame( frame_id=row["frame_id"], timestamp=timestamp, ball_owning_team=None, diff --git a/kloppy/infra/serializers/tracking/secondspectrum.py b/kloppy/infra/serializers/tracking/secondspectrum.py index ca4441e8..94b0f4dc 100644 --- a/kloppy/infra/serializers/tracking/secondspectrum.py +++ b/kloppy/infra/serializers/tracking/secondspectrum.py @@ -25,6 +25,7 @@ PlayerData, Score, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.utils import Readable, performance_logging @@ -96,7 +97,7 @@ def _frame_from_framedata(cls, teams, period, frame_data): coordinates=Point(float(x), float(y)), speed=speed ) - return Frame( + frame = create_frame( frame_id=frame_id, timestamp=frame_timestamp, ball_coordinates=ball_coordinates, @@ -108,6 +109,8 @@ def _frame_from_framedata(cls, teams, period, frame_data): other_data={}, ) + return frame + @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): if "xml_metadata" not in inputs: diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index 3a8c3a4a..acce0966 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -25,6 +25,7 @@ TrackingDataset, attacking_direction_from_frame, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.infra.serializers.tracking.deserializer import ( TrackingDataDeserializer, ) @@ -174,7 +175,7 @@ def _get_frame_data( players_data[player] = PlayerData(coordinates=Point(x, y)) - return Frame( + frame = create_frame( frame_id=frame_id, timestamp=frame_time, ball_coordinates=ball_coordinates, @@ -185,6 +186,8 @@ def _get_frame_data( other_data={}, ) + return frame + @classmethod def _timestamp_from_timestring(cls, timestring): parts = timestring.split(":") diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index 1ed04e1a..b4e50906 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -21,6 +21,7 @@ Provider, PlayerData, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.utils import performance_logging @@ -172,7 +173,7 @@ def _iter(): continue if i % sample == 0: - yield Frame( + yield create_frame( frame_id=frame_id, timestamp=timedelta( seconds=( diff --git a/kloppy/infra/serializers/tracking/statsperform.py b/kloppy/infra/serializers/tracking/statsperform.py index 1cf413d5..21a5a4c3 100644 --- a/kloppy/infra/serializers/tracking/statsperform.py +++ b/kloppy/infra/serializers/tracking/statsperform.py @@ -19,6 +19,7 @@ TrackingDataset, attacking_direction_from_frame, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.exceptions import DeserializationError from kloppy.utils import performance_logging from kloppy.infra.serializers.event.statsperform.parsers import get_parser @@ -129,7 +130,7 @@ def _frame_from_framedata(cls, teams_list, period, frame_data): players_data[player] = PlayerData(coordinates=Point(x, y)) - return Frame( + frame = create_frame( frame_id=frame_id, timestamp=frame_timestamp, ball_coordinates=ball_coordinates, @@ -140,6 +141,8 @@ def _frame_from_framedata(cls, teams_list, period, frame_data): other_data={}, ) + return frame + def deserialize(self, inputs: StatsPerformInputs) -> TrackingDataset: with performance_logging("Loading meta data", logger=logger): meta_data_parser = get_parser(inputs.meta_data, "MA1") diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py index 001efdfa..72ddbdf6 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py @@ -25,6 +25,7 @@ PlayerData, PositionType, ) +from kloppy.domain.services.frame_factory import create_frame from kloppy.exceptions import DeserializationError from kloppy.utils import Readable, performance_logging @@ -113,7 +114,7 @@ def _frame_from_line(cls, teams, period, line, frame_rate): else: raise DeserializationError(f"Unknown ball state: {ball_state}") - return Frame( + frame = create_frame( frame_id=frame_id, timestamp=timedelta(seconds=frame_id / frame_rate) - period.start_timestamp, @@ -127,6 +128,8 @@ def _frame_from_line(cls, teams, period, line, frame_rate): other_data={}, ) + return frame + @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): if "metadata" not in inputs: diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_json.py b/kloppy/infra/serializers/tracking/tracab/tracab_json.py index a5f52958..921e4df5 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_json.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_json.py @@ -24,6 +24,7 @@ attacking_direction_from_frame, ) from kloppy.domain.models import PositionType +from kloppy.domain.services.frame_factory import create_frame from kloppy.exceptions import DeserializationError from kloppy.utils import Readable, performance_logging @@ -105,7 +106,7 @@ def _create_frame(cls, teams, period, raw_frame, frame_rate): f"Unknown ball state: {raw_ball_position['BallStatus']}" ) - return Frame( + frame = create_frame( frame_id=frame_id, timestamp=timedelta(seconds=frame_id / frame_rate) - period.start_timestamp, @@ -118,6 +119,8 @@ def _create_frame(cls, teams, period, raw_frame, frame_rate): other_data={}, ) + return frame + @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): if "metadata" not in inputs: diff --git a/kloppy/tests/files/opta_f24.xml b/kloppy/tests/files/opta_f24.xml index 02622eb8..47c46c0b 100644 --- a/kloppy/tests/files/opta_f24.xml +++ b/kloppy/tests/files/opta_f24.xml @@ -389,6 +389,8 @@ + + diff --git a/kloppy/tests/test_helpers.py b/kloppy/tests/test_helpers.py index 01f9c0e7..3aeff45e 100644 --- a/kloppy/tests/test_helpers.py +++ b/kloppy/tests/test_helpers.py @@ -25,6 +25,7 @@ Team, TrackingDataset, ) +from kloppy.domain.services.frame_factory import create_frame class TestHelpers: @@ -68,7 +69,7 @@ def _get_tracking_dataset(self): tracking_data = TrackingDataset( metadata=metadata, records=[ - Frame( + create_frame( frame_id=1, timestamp=0.1, ball_owning_team=teams[0], @@ -78,7 +79,7 @@ def _get_tracking_dataset(self): other_data=None, ball_coordinates=Point3D(x=100, y=-50, z=0), ), - Frame( + create_frame( frame_id=2, timestamp=0.2, ball_owning_team=teams[1], diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index f0ad8ba3..7cb974f7 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -414,6 +414,27 @@ def test_own_goal(self, dataset: EventDataset): assert own_goal.result_coordinates == Point3D(0.0, 100 - 45.6, 1.9) assert own_goal.ball_state == BallState.DEAD + def test_goal(self, dataset: EventDataset): + """Test if goals are correctly deserialized""" + goal = dataset.get_event_by_id("2614247749") + assert goal.result == ShotResult.GOAL + assert ( + next( + statistic + for statistic in goal.statistics + if statistic.name == "xG" + ).value + == 0.9780699610710144 + ) + assert ( + next( + statistic + for statistic in goal.statistics + if statistic.name == "PSxG" + ).value + == 0.98 + ) + class TestOptaDuelEvent: """Tests related to deserializing duel events""" diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 9995bd4b..d3f3d1f5 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -717,6 +717,15 @@ def test_open_play(self, dataset: EventDataset): ) # An open play shot should not have a set piece qualifier assert shot.get_qualifier_value(SetPieceQualifier) is None + # A shot event should have a xG value + assert ( + next( + statistic + for statistic in shot.statistics + if statistic.name == "xG" + ).value + == 0.12441643 + ) def test_free_kick(self, dataset: EventDataset): """It should add set piece qualifiers to free kick shots"""