diff --git a/kloppy/_providers/cdf.py b/kloppy/_providers/cdf.py new file mode 100644 index 000000000..e69de29bb diff --git a/kloppy/_providers/sportscode.py b/kloppy/_providers/sportscode.py index 3ce84e3cf..0f7b762cf 100644 --- a/kloppy/_providers/sportscode.py +++ b/kloppy/_providers/sportscode.py @@ -3,6 +3,7 @@ SportsCodeDeserializer, SportsCodeInputs, SportsCodeSerializer, + SportsCodeOutputs, ) from kloppy.io import FileLike, open_as_file @@ -31,6 +32,6 @@ def save(dataset: CodeDataset, output_filename: str) -> None: dataset: The SportsCode dataset to save. output_filename: The output filename. """ - with open(output_filename, "wb") as fp: + with open_as_file(output_filename, "wb") as data_fp: serializer = SportsCodeSerializer() - fp.write(serializer.serialize(dataset)) + serializer.serialize(dataset, outputs=SportsCodeOutputs(data=data_fp)) diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index 78ebef84c..9e33b23a9 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -111,6 +111,7 @@ class Provider(Enum): HAWEKEYE (Provider): SPORTVU (Provider): IMPECT (Provider): + CDF (Provider): OTHER (Provider): """ @@ -128,8 +129,9 @@ class Provider(Enum): STATSPERFORM = "statsperform" HAWKEYE = "hawkeye" SPORTVU = "sportvu" - SIGNALITY = "signality" IMPECT = "impect" + CDF = "common_data_format" + SIGNALITY = "signality" OTHER = "other" def __str__(self): @@ -679,12 +681,16 @@ def to_mplsoccer(self): dim = BaseDims( left=self.pitch_dimensions.x_dim.min, right=self.pitch_dimensions.x_dim.max, - bottom=self.pitch_dimensions.y_dim.min - if not invert_y - else self.pitch_dimensions.y_dim.max, - top=self.pitch_dimensions.y_dim.max - if not invert_y - else self.pitch_dimensions.y_dim.min, + bottom=( + self.pitch_dimensions.y_dim.min + if not invert_y + else self.pitch_dimensions.y_dim.max + ), + top=( + self.pitch_dimensions.y_dim.max + if not invert_y + else self.pitch_dimensions.y_dim.min + ), width=self.pitch_dimensions.x_dim.max - self.pitch_dimensions.x_dim.min, length=self.pitch_dimensions.y_dim.max @@ -733,14 +739,16 @@ def to_mplsoccer(self): - self.pitch_dimensions.x_dim.min ), pad_multiplier=1, - aspect_equal=False - if self.pitch_dimensions.unit == Unit.NORMED - else True, + aspect_equal=( + False if self.pitch_dimensions.unit == Unit.NORMED else True + ), pitch_width=pitch_width, pitch_length=pitch_length, - aspect=pitch_width / pitch_length - if self.pitch_dimensions.unit == Unit.NORMED - else 1.0, + aspect=( + pitch_width / pitch_length + if self.pitch_dimensions.unit == Unit.NORMED + else 1.0 + ), ) return dim @@ -1184,6 +1192,45 @@ def pitch_dimensions(self) -> PitchDimensions: ) +class CDFCoordinateSystem(ProviderCoordinateSystem): + """ + CDFCoordinateSystem coordinate system. + + Uses a pitch with the origin at the center and the y-axis oriented + from bottom to top. The coordinates are in meters. + """ + + @property + def provider(self) -> Provider: + return Provider.CDF + + @property + def origin(self) -> Origin: + return Origin.CENTER + + @property + def vertical_orientation(self) -> VerticalOrientation: + return VerticalOrientation.BOTTOM_TO_TOP + + @property + def pitch_dimensions(self) -> PitchDimensions: + return NormalizedPitchDimensions( + x_dim=Dimension( + -1 * self._pitch_length / 2, self._pitch_length / 2 + ), + y_dim=Dimension(-1 * self._pitch_width / 2, self._pitch_width / 2), + pitch_length=self._pitch_length, + pitch_width=self._pitch_width, + standardized=False, + ) + + def __init__(self, base_coordinate_system: ProviderCoordinateSystem): + self._pitch_length = ( + base_coordinate_system.pitch_dimensions.pitch_length + ) + self._pitch_width = base_coordinate_system.pitch_dimensions.pitch_width + + class SignalityCoordinateSystem(ProviderCoordinateSystem): @property def provider(self) -> Provider: @@ -1413,7 +1460,6 @@ def build_coordinate_system( Provider.HAWKEYE: HawkEyeCoordinateSystem, Provider.SPORTVU: SportVUCoordinateSystem, Provider.SIGNALITY: SignalityCoordinateSystem, - Provider.IMPECT: ImpectCoordinateSystem, } if provider in coordinate_systems: @@ -1820,7 +1866,7 @@ def to_records( *columns: Unpack[tuple[Column]], as_list: Literal[True] = True, **named_columns: NamedColumns, - ) -> list[dict[str, Any]]: ... + ) -> List[Dict[str, Any]]: ... @overload def to_records( @@ -1828,7 +1874,7 @@ def to_records( *columns: Unpack[tuple[Column]], as_list: Literal[False] = False, **named_columns: NamedColumns, - ) -> Iterable[dict[str, Any]]: ... + ) -> Iterable[Dict[str, Any]]: ... def to_records( self, @@ -1943,6 +1989,10 @@ def to_df( else: raise KloppyParameterError(f"Engine {engine} is not valid") + def to_cdf(self): + if self.dataset_type != DatasetType.TRACKING: + raise ValueError(f"to_cdf() is only supported for TrackingDataset") + def __repr__(self): return f"<{self.__class__.__name__} record_count={len(self.records)}>" diff --git a/kloppy/domain/models/tracking.py b/kloppy/domain/models/tracking.py index 3b5026b8d..75c7ed9e1 100644 --- a/kloppy/domain/models/tracking.py +++ b/kloppy/domain/models/tracking.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable, Optional +from typing import Any, Callable, Dict, Optional, Union, TYPE_CHECKING from kloppy.domain.models.common import DatasetType from kloppy.utils import ( @@ -7,6 +7,9 @@ docstring_inherit_attributes, ) +if TYPE_CHECKING: + from kloppy.io import FileLike, open_as_file + from .common import DataRecord, Dataset, Player from .pitch import Point, Point3D @@ -122,5 +125,80 @@ def generic_record_converter(frame: Frame): map(generic_record_converter, self.records) ) + # Update the to_cdf method in Dataset class + def to_cdf( + self, + metadata_output_file: "FileLike", + tracking_output_file: "FileLike", + additional_metadata: Optional[Union[dict, "CdfMetaDataSchema"]] = None, + ) -> None: + """ + Export dataset to Common Data Format (CDF). + + Args: + metadata_output_file: File path or file-like object for metadata JSON output. + Must have .json extension if a string path. + tracking_output_file: File path or file-like object for tracking JSONL output. + Must have .jsonl extension if a string path. + additional_metadata: Additional metadata to include in the CDF output. + Can be a complete CdfMetaDataSchema TypedDict or a partial dict. + Supported top-level keys: 'competition', 'season', 'stadium', 'meta', 'match'. + Supports nested updates like {'stadium': {'id': '123'}}. + + Raises: + KloppyError: If the dataset is not a TrackingDataset. + ValueError: If file extensions are invalid. + + Examples: + >>> # Export to local files + >>> dataset.to_cdf( + ... metadata_output_file='metadata.json', + ... tracking_output_file='tracking.jsonl' + ... ) + + >>> # Export to S3 + >>> dataset.to_cdf( + ... metadata_output_file='s3://bucket/metadata.json', + ... tracking_output_file='s3://bucket/tracking.jsonl' + ... ) + + >>> # Export with partial metadata updates + >>> dataset.to_cdf( + ... metadata_output_file='metadata.json', + ... tracking_output_file='tracking.jsonl', + ... additional_metadata={ + ... 'competition': {'id': '123'}, + ... 'season': {'id': '2024'}, + ... 'stadium': {'id': '456', 'name': 'Stadium Name'} + ... } + ... ) + """ + from kloppy.domain import DatasetType + from kloppy.exceptions import KloppyError + from kloppy.infra.serializers.tracking.cdf import ( + CDFTrackingDataSerializer, + CDFOutputs, + ) + from kloppy.io import FileLike, open_as_file + + serializer = CDFTrackingDataSerializer() + + # TODO: write files but also support non-local files, similar to how open_as_file supports non-local files + + # Use open_as_file with mode="wb" for writing + with open_as_file( + metadata_output_file, mode="wb" + ) as metadata_fp, open_as_file( + tracking_output_file, mode="wb" + ) as tracking_fp: + + serializer.serialize( + dataset=self, + outputs=CDFOutputs( + meta_data=metadata_fp, tracking_data=tracking_fp + ), + additional_metadata=additional_metadata, + ) + __all__ = ["Frame", "TrackingDataset", "PlayerData"] diff --git a/kloppy/infra/io/adapters/adapter.py b/kloppy/infra/io/adapters/adapter.py index 83839956e..c36f591a5 100644 --- a/kloppy/infra/io/adapters/adapter.py +++ b/kloppy/infra/io/adapters/adapter.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod from typing import BinaryIO +from kloppy.infra.io.buffered_stream import BufferedStream + class Adapter(ABC): @abstractmethod @@ -16,9 +18,26 @@ def is_file(self, url: str) -> bool: pass @abstractmethod - def read_to_stream(self, url: str, output: BinaryIO): + def read_to_stream(self, url: str, output: BufferedStream): pass + def write_from_stream(self, url: str, input: BufferedStream, mode: str): + """ + Write content from BufferedStream to the given URL. + + Args: + url: The destination URL + input: BufferedStream to read from + mode: Write mode ('wb' for write/overwrite or 'ab' for append) + + Raises: + NotImplementedError: If write operations are not supported by this adapter + """ + raise NotImplementedError( + f"Write operations not supported for {url}. " + f"Adapter {self.__class__.__name__} does not implement write_from_stream." + ) + @abstractmethod def list_directory(self, url: str, recursive: bool = True) -> list[str]: pass diff --git a/kloppy/infra/io/adapters/fsspec.py b/kloppy/infra/io/adapters/fsspec.py index b26217084..3a11db863 100644 --- a/kloppy/infra/io/adapters/fsspec.py +++ b/kloppy/infra/io/adapters/fsspec.py @@ -1,12 +1,14 @@ from abc import ABC, abstractmethod import re -from typing import BinaryIO, Optional +from typing import BinaryIO, Optional, List import fsspec from kloppy.config import get_config from kloppy.exceptions import InputNotFoundError +from kloppy.infra.io.buffered_stream import BufferedStream + from .adapter import Adapter @@ -28,7 +30,6 @@ def _get_filesystem( Get the appropriate fsspec filesystem for the given URL, with caching enabled. """ protocol = self._infer_protocol(url) - if no_cache: return fsspec.filesystem(protocol) @@ -60,21 +61,37 @@ def supports(self, url: str) -> bool: Check if the adapter can handle the URL. """ - def read_to_stream(self, url: str, output: BinaryIO): + def read_to_stream(self, url: str, output: BufferedStream): """ - Reads content from the given URL and writes it to the provided binary stream. - Uses caching for remote files. + Reads content from the given URL and writes it to the provided BufferedStream. + Uses caching for remote files. Copies data in chunks. """ fs = self._get_filesystem(url) compression = self._detect_compression(url) try: with fs.open(url, "rb", compression=compression) as source_file: - output.write(source_file.read()) + output.read_from(source_file) except FileNotFoundError as e: raise InputNotFoundError(f"Input file not found: {url}") from e - def list_directory(self, url: str, recursive: bool = True) -> list[str]: + def write_from_stream(self, url: str, input: BufferedStream, mode: str): + """ + Writes content from BufferedStream to the given URL. + Does not use caching for writes. Copies data in chunks. + + Args: + url: The destination URL + input: BufferedStream to read from + mode: Write mode ('wb' for write/overwrite or 'ab' for append) + """ + fs = self._get_filesystem(url, no_cache=True) + compression = self._detect_compression(url) + + with fs.open(url, mode, compression=compression) as dest_file: + input.write_to(dest_file) + + def list_directory(self, url: str, recursive: bool = True) -> List[str]: """ Lists the contents of a directory. """ @@ -85,9 +102,11 @@ def list_directory(self, url: str, recursive: bool = True) -> list[str]: else: files = fs.listdir(url, detail=False) return [ - f"{protocol}://{fp}" - if protocol != "file" and not fp.startswith(protocol) - else fp + ( + f"{protocol}://{fp}" + if protocol != "file" and not fp.startswith(protocol) + else fp + ) for fp in files ] diff --git a/kloppy/infra/io/adapters/zip.py b/kloppy/infra/io/adapters/zip.py index f74f1612a..a9653d5fc 100644 --- a/kloppy/infra/io/adapters/zip.py +++ b/kloppy/infra/io/adapters/zip.py @@ -42,8 +42,10 @@ def list_directory(self, url: str, recursive: bool = True) -> list[str]: else: files = fs.listdir(url, detail=False) return [ - f"{protocol}://{fp}" - if protocol != "file" and not fp.startswith(protocol) - else fp + ( + f"{protocol}://{fp}" + if protocol != "file" and not fp.startswith(protocol) + else fp + ) for fp in files ] diff --git a/kloppy/infra/io/buffered_stream.py b/kloppy/infra/io/buffered_stream.py new file mode 100644 index 000000000..5bc1ba4fa --- /dev/null +++ b/kloppy/infra/io/buffered_stream.py @@ -0,0 +1,76 @@ +"""Buffered stream utilities for efficient I/O operations.""" + +import shutil +import tempfile +from typing import BinaryIO, Protocol + +DEFAULT_BUFFER_SIZE = 5 * 1024 * 1024 # 5MB before spilling to disk + + +class SupportsWrite(Protocol): + """Protocol for objects that support write operations.""" + + def write(self, data: bytes) -> int: ... + + +class SupportsRead(Protocol): + """Protocol for objects that support read operations.""" + + def read(self, n: int) -> bytes: ... + + +class BufferedStream(tempfile.SpooledTemporaryFile): + """A spooled temporary file that can efficiently copy from streams in chunks.""" + + def __init__(self, max_size: int = DEFAULT_BUFFER_SIZE, mode: str = "w+b"): + super().__init__(max_size=max_size, mode=mode) + + def write(self, data: bytes) -> int: # make it clearly bytes-only + return super().write(data) + + def read(self, n: int = -1) -> bytes: # make it clearly bytes-only + return super().read(n) + + @classmethod + def from_stream( + cls, + source: BinaryIO, + max_size: int = DEFAULT_BUFFER_SIZE, + chunk_size: int = 0, + ) -> "BufferedStream": + """ + Create a BufferedStream by copying data from source stream in chunks. + + Args: + source: The source binary stream to read from + max_size: Maximum size to keep in memory before spilling to disk + chunk_size: Size of chunks to keep in memory before spilling to disk + + Returns: + A BufferedStream containing the copied data + """ + buffer = cls(max_size=max_size) + buffer.read_from(source, chunk_size) + return buffer + + def read_from(self, source: SupportsRead, chunk_size: int = 0): + """ + Read data from source into this BufferedStream in chunks. + + Args: + source: The source that supports read() method + chunk_size: Size of chunks to copy at a time (0 uses default) + """ + shutil.copyfileobj(source, self, chunk_size) + self.seek(0) + + def write_to(self, output: SupportsWrite, chunk_size: int = 0) -> None: + """ + Write all contents of this BufferedStream to the output in chunks. + + Args: + output: The destination that supports write() method + chunk_size: Size of chunks to keep in memory before spilling to disk + """ + self.seek(0) + shutil.copyfileobj(self, output, chunk_size) diff --git a/kloppy/infra/serializers/code/base.py b/kloppy/infra/serializers/code/base.py index d726dca18..8ebe3cf7f 100644 --- a/kloppy/infra/serializers/code/base.py +++ b/kloppy/infra/serializers/code/base.py @@ -3,16 +3,18 @@ from kloppy.domain import CodeDataset -T = TypeVar("T") +T_I = TypeVar("T_I") +T_O = TypeVar("T_O") -class CodeDataDeserializer(ABC, Generic[T]): + +class CodeDataDeserializer(ABC, Generic[T_I]): @abstractmethod - def deserialize(self, inputs: T) -> CodeDataset: + def deserialize(self, inputs: T_I) -> CodeDataset: raise NotImplementedError -class CodeDataSerializer(ABC): +class CodeDataSerializer(ABC, Generic[T_O]): @abstractmethod - def serialize(self, dataset: CodeDataset) -> bytes: + def serialize(self, dataset: CodeDataset, outputs: T_O) -> bool: raise NotImplementedError diff --git a/kloppy/infra/serializers/code/sportscode.py b/kloppy/infra/serializers/code/sportscode.py index 698de6675..bd063ddc1 100644 --- a/kloppy/infra/serializers/code/sportscode.py +++ b/kloppy/infra/serializers/code/sportscode.py @@ -45,6 +45,10 @@ class SportsCodeInputs(NamedTuple): data: IO[bytes] +class SportsCodeOutputs(NamedTuple): + data: IO[bytes] + + class SportsCodeDeserializer(CodeDataDeserializer[SportsCodeInputs]): def deserialize(self, inputs: SportsCodeInputs) -> CodeDataset: all_instances = objectify.fromstring(inputs.data.read()) @@ -88,8 +92,10 @@ def deserialize(self, inputs: SportsCodeInputs) -> CodeDataset: ) -class SportsCodeSerializer(CodeDataSerializer): - def serialize(self, dataset: CodeDataset) -> bytes: +class SportsCodeSerializer(CodeDataSerializer[SportsCodeOutputs]): + def serialize( + self, dataset: CodeDataset, outputs: SportsCodeOutputs + ) -> bool: root = etree.Element("file") all_instances = etree.SubElement(root, "ALL_INSTANCES") for i, code in enumerate(dataset.codes): @@ -138,10 +144,12 @@ def serialize(self, dataset: CodeDataset) -> bytes: text_ = etree.SubElement(label, "text") text_.text = str(text) - return etree.tostring( - root, - pretty_print=True, - xml_declaration=True, - encoding="utf-8", # This might not work with some tools because they expected 'ascii'. - method="xml", + outputs.data.write( + etree.tostring( + root, + pretty_print=True, + xml_declaration=True, + encoding="utf-8", # This might not work with some tools because they expected 'ascii'. + method="xml", + ) ) diff --git a/kloppy/infra/serializers/tracking/cdf/__init__.py b/kloppy/infra/serializers/tracking/cdf/__init__.py new file mode 100644 index 000000000..633a69ce8 --- /dev/null +++ b/kloppy/infra/serializers/tracking/cdf/__init__.py @@ -0,0 +1,4 @@ +from kloppy.domain.models.common import CDFCoordinateSystem +from .serializer import CDFTrackingDataSerializer, CDFOutputs + +__all__ = ["CDFCoordinateSystem", "CDFTrackingDataSerializer", "CDFOutputs"] diff --git a/kloppy/infra/serializers/tracking/cdf/helpers.py b/kloppy/infra/serializers/tracking/cdf/helpers.py new file mode 100644 index 000000000..feb6ae7c0 --- /dev/null +++ b/kloppy/infra/serializers/tracking/cdf/helpers.py @@ -0,0 +1,267 @@ +from kloppy.domain import PositionType, Ground, Point3D + +import warnings + +PERIODS_MAP = { + 1: "first_half", + 2: "second_half", + 3: "first_half_extratime", + 4: "second_half_extratime", + 5: "shootout", +} + + +def is_valid_cdf_position_code(x): + try: + from cdf.validators.common import POSITION_GROUPS + except ImportError: + raise ImportError( + "Seems like you don't have common-data-format-validator installed. Please" + " install it using: pip install common-data-format-validator" + ) + + return any(x in positions for positions in POSITION_GROUPS.values()) + + +def map_position_type_code_to_cdf(position_code): + """ + Docstring for map_position_type_code_to_cdf + + :param position_code: Description + """ + if is_valid_cdf_position_code(position_code): + return position_code + else: + if position_code == "UNK": + warnings.warn( + f"""position_code '{position_code}' identified within dataset. + \nThis means there is no appropriate mapping from the original position type (as provided by your data provider) to a kloppy.domain.PositionType. + \nTo resolve this, please open an issue at https://github.com/PySport/kloppy/issues""", + UserWarning, + ) + return None + elif position_code in ["DEF", "FB", "MID", "WM", "ATT"]: + warnings.warn( + f"""position_code '{position_code}' identified within dataset. + \nThere is no appropriate mapping for this position to Common Data Format.""", + UserWarning, + ) + return None + elif position_code == "LWB": + return "LB" + elif position_code == "RWB": + return "RB" + elif position_code == "DM": + return "CDM" + elif position_code == "AM": + return "CAM" + elif position_code == "LF": + return "LCF" + elif position_code == "ST": + return "CF" + elif position_code == "RF": + return "RCF" + else: + raise ValueError( + f"position.code '{position_code}' cannot be converted to CDF, because there is no appropriate mapping." + ) + + +def extract_team_players(team): + """Extract player IDs from a team.""" + return [player.player_id for player in team.players] + + +def get_player_coordinates(frame, ground: Ground): + """Create player data list for a team from frame coordinates.""" + + players = [] + for player, coordinates in frame.players_coordinates.items(): + if player.team.ground == ground: + players.append( + { + "id": str(player.player_id), + "x": round(coordinates.x, 3), + "y": round(coordinates.y, 3), + "position": map_position_type_code_to_cdf( + player.starting_position.code + ), + } + ) + return players + + +def get_ball_coordinates(frame): + if frame.ball_coordinates is not None: + if isinstance(frame.ball_coordinates, Point3D): + return { + "x": round(frame.ball_coordinates.x, 3), + "y": round(frame.ball_coordinates.y, 3), + "z": round(frame.ball_coordinates.z, 3), + } + else: + return { + "x": round(frame.ball_coordinates.x, 3), + "y": round(frame.ball_coordinates.y, 3), + } + + return {"x": None, "y": None, "z": None} + + +def initialize_period_tracking(periods): + """Initialize tracking dictionaries for all periods.""" + period_ids = [period.id for period in periods] + return { + "start_frame_id": {pid: None for pid in period_ids}, + "end_frame_id": {pid: None for pid in period_ids}, + "normalized_start_frame_id": {pid: None for pid in period_ids}, + "normalized_end_frame_id": {pid: None for pid in period_ids}, + "offset": {pid: 0 for pid in period_ids}, + } + + +def update_period_tracking(period_tracking, period_id, original_frame_id): + """Update period tracking information for the current frame.""" + if period_tracking["start_frame_id"][period_id] is None: + period_tracking["start_frame_id"][period_id] = original_frame_id + + if ( + period_id > 1 + and period_tracking["end_frame_id"][period_id - 1] is not None + ): + prev_period_length = ( + period_tracking["end_frame_id"][period_id - 1] + - period_tracking["start_frame_id"][period_id - 1] + + 1 + ) + period_tracking["offset"][period_id] = ( + period_tracking["offset"][period_id - 1] + prev_period_length + ) + + period_tracking["normalized_start_frame_id"][ + period_id + ] = period_tracking["offset"][period_id] + + period_tracking["end_frame_id"][period_id] = original_frame_id + + normalized_frame_id = ( + original_frame_id - period_tracking["start_frame_id"][period_id] + ) + period_tracking["offset"][period_id] + + period_tracking["normalized_end_frame_id"][period_id] = normalized_frame_id + + return normalized_frame_id + + +def get_starting_formation(team_players) -> str: + """ + determine the starting formation if not define. + + Args: + team: The team on which we want to infer the formation. + + Returns: + formation: the infered formation. + """ + default_formation = "4-3-3" + + defender = midfielder = attacker = 0 + for player in team_players: + if player.starting_position.position_group == None: + continue + elif player.starting_position.position_group == PositionType.Attacker: + attacker += 1 + elif ( + player.starting_position.position_group == PositionType.Midfielder + ): + midfielder += 1 + elif player.starting_position.position_group == PositionType.Defender: + defender += 1 + if defender + midfielder + attacker == 10: + return f"{defender}-{midfielder}-{attacker}" + elif defender + midfielder + attacker != 10: + return default_formation + return default_formation + + +def build_periods_info(dataset, period_tracking, home_team, away_team): + """Build period information for metadata.""" + periods_info = [] + for period in dataset.metadata.periods: + periods_info.append( + { + "period": PERIODS_MAP[period.id], + "play_direction": "left_right", + "start_time": str( + dataset.metadata.date + period.start_timestamp + ), + "end_time": str(dataset.metadata.date + period.end_timestamp), + "start_frame_id": period_tracking["normalized_start_frame_id"][ + period.id + ], + "end_frame_id": period_tracking["normalized_end_frame_id"][ + period.id + ], + "left_team_id": str(home_team.team_id), + "right_team_id": str(away_team.team_id), + } + ) + return periods_info + + +def build_whistles(periods_info): + """Build whistle events from period information.""" + whistles = [] + for period in periods_info: + whistles.append( + { + "type": period["period"], + "sub_type": "start", + "time": period["start_time"], + } + ) + whistles.append( + { + "type": period["period"], + "sub_type": "end", + "time": period["end_time"], + } + ) + return whistles + + +def get_starters_and_formation(team, first_frame): + """ + Extract starter IDs and determine formation from first frame. + + Returns: + tuple: (set of starter player IDs, formation string) + """ + team_starters = { + player.player_id + for player, _ in first_frame.players_coordinates.items() + if player.team == team + } + + starters_list = [p for p in team.players if p.player_id in team_starters] + + formation = team.starting_formation or get_starting_formation( + starters_list + ) + + return team_starters, formation + + +def build_team_players_metadata(team, starters): + """Build player metadata for a team.""" + players = [] + for player in team.players: + players.append( + { + "id": str(player.player_id), + "team_id": str(team.team_id), + "jersey_number": player.jersey_no, + "is_starter": player.player_id in starters, + } + ) + return players diff --git a/kloppy/infra/serializers/tracking/cdf/serializer.py b/kloppy/infra/serializers/tracking/cdf/serializer.py new file mode 100644 index 000000000..c1f5f6cf2 --- /dev/null +++ b/kloppy/infra/serializers/tracking/cdf/serializer.py @@ -0,0 +1,339 @@ +import json +import tempfile +from typing import IO, NamedTuple, Optional, Union, TYPE_CHECKING + +from kloppy.domain import ( + Provider, + TrackingDataset, + Orientation, + BallState, + CDFCoordinateSystem, + Ground, +) +from kloppy.infra.serializers.tracking.serializer import TrackingDataSerializer +from kloppy import __version__ + +from .helpers import ( + PERIODS_MAP, + get_player_coordinates, + get_ball_coordinates, + initialize_period_tracking, + update_period_tracking, + get_starters_and_formation, + build_periods_info, + build_whistles, + build_team_players_metadata, +) + +if TYPE_CHECKING: + from cdf.domain.latest.meta import ( + CdfMetaDataSchema, + Stadium, + Competition, + Season, + Meta, + Misc, + ) + +import warnings + +MISSING_MANDATORY_VALUE = "MISSING_MANDATORY_VALUE" + + +class CDFOutputs(NamedTuple): + meta_data: IO[bytes] + tracking_data: IO[bytes] + + +class CDFTrackingDataSerializer(TrackingDataSerializer[CDFOutputs]): + provider = Provider.CDF + + def serialize( + self, + dataset: TrackingDataset, + outputs: CDFOutputs, + additional_metadata: Optional[ + Union[ + "CdfMetaDataSchema", + "Stadium", + "Competition", + "Season", + "Meta", + "Misc", + dict, + ] + ] = None, + ) -> bool: + """ + Serialize a TrackingDataset to Common Data Format. + + Args: + dataset: The tracking dataset to serialize + outputs: CDFOutputs containing file handles for metadata and tracking data + additional_metadata: Either a complete CdfMetaDataSchema or partial metadata + dict containing any of: 'competition', 'season', 'stadium', 'meta', 'misc'. + Can also contain direct field updates like {'stadium': {'id': '123'}}. + + Returns: + bool: True if serialization was successful + """ + if all( + True if x.ball_state == BallState.ALIVE else False for x in dataset + ): + warnings.warn( + "All frames in 'tracking_dataset' are 'ALIVE', the Common Data Format expects 'DEAD' frames as well. Set `only_alive=False` in your kloppy `.load_tracking()` call to include 'DEAD' frames.", + UserWarning, + ) + + dataset = dataset.transform( + to_coordinate_system=CDFCoordinateSystem( + dataset.metadata.coordinate_system + ), + to_orientation=Orientation.STATIC_HOME_AWAY, + ) + + period_tracking = initialize_period_tracking(dataset.metadata.periods) + self._home_team, self._away_team = dataset.metadata.teams + + self._serialize_tracking_frames( + dataset, + outputs, + period_tracking, + ) + + self._serialize_metadata( + dataset, + outputs, + period_tracking, + additional_metadata or {}, + ) + + return True + + def _serialize_tracking_frames(self, dataset, outputs, period_tracking): + """Serialize tracking data frames to JSONL format. + + Iterates through all frames in the dataset and writes each frame's tracking + data (player positions, ball coordinates, timestamps) directly to the output + JSONL file. + + Args: + dataset: The kloppy tracking dataset containing frames to serialize. + outputs: CDFOutputs object containing the tracking data file handle. + period_tracking: Dictionary containing period frame ID tracking information. + """ + for frame in dataset.frames: + period_id = frame.period.id + ball_status = frame.ball_state == BallState.ALIVE + + normalized_frame_id = update_period_tracking( + period_tracking, period_id, frame.frame_id + ) + + home_players = get_player_coordinates(frame, Ground.HOME) + away_players = get_player_coordinates(frame, Ground.AWAY) + + if period_id not in PERIODS_MAP: + raise ValueError( + f"Incorrect period_id {period_id}. Period ID {period_id} this is not supported by the Common Data Format" + ) + + frame_data = { + "frame_id": normalized_frame_id, + "original_frame_id": frame.frame_id, + "timestamp": str(dataset.metadata.date + frame.timestamp), + "period": PERIODS_MAP[period_id], + "match": {"id": str(dataset.metadata.game_id)}, + "ball_status": ball_status, + "teams": { + "home": { + "id": str(self._home_team.team_id), + "players": home_players, + "name": self._home_team.name, + }, + "away": { + "id": str(self._away_team.team_id), + "players": away_players, + "name": self._away_team.name, + }, + }, + "ball": get_ball_coordinates(frame), + } + + outputs.tracking_data.write( + (json.dumps(frame_data) + "\n").encode("utf-8") + ) + + def _build_default_metadata_structure( + self, + dataset, + period_tracking, + ) -> "CdfMetaDataSchema": + """Build default CDF metadata structure from dataset.""" + try: + from cdf import VERSION + except ImportError: + raise ImportError( + "Seems like you don't have common-data-format-validator installed. Please" + " install it using: pip install common-data-format-validator" + ) + + first_frame = dataset[0] + + home_starters, home_formation = get_starters_and_formation( + self._home_team, first_frame + ) + away_starters, away_formation = get_starters_and_formation( + self._away_team, first_frame + ) + + periods_info = build_periods_info( + dataset, period_tracking, self._home_team, self._away_team + ) + + whistles = build_whistles(periods_info) + + return { + "competition": { + "id": MISSING_MANDATORY_VALUE, + }, + "season": { + "id": MISSING_MANDATORY_VALUE, + }, + "stadium": { + "id": MISSING_MANDATORY_VALUE, + "pitch_length": dataset.metadata.pitch_dimensions.pitch_length, + "pitch_width": dataset.metadata.pitch_dimensions.pitch_width, + }, + "match": { + "id": str(dataset.metadata.game_id), + "kickoff_time": str( + dataset.metadata.date + + dataset.metadata.periods[0].start_timestamp + ), + "periods": periods_info, + "whistles": whistles, + "scheduled_kickoff_time": str(dataset.metadata.date), + }, + "teams": { + "home": { + "id": str(self._home_team.team_id), + "players": build_team_players_metadata( + self._home_team, home_starters + ), + "name": self._home_team.name, + "formation": home_formation, + }, + "away": { + "id": str(self._away_team.team_id), + "players": build_team_players_metadata( + self._away_team, away_starters + ), + "name": self._away_team.name, + "formation": away_formation, + }, + }, + "meta": { + "video": None, + "tracking": { + "fps": dataset.metadata.frame_rate, + "name": dataset.metadata.provider.name.lower(), + "converted_by": f"kloppy-cdf-converter-{__version__}", + "version": MISSING_MANDATORY_VALUE, + "collection_timing": MISSING_MANDATORY_VALUE, + }, + "landmarks": None, + "ball": None, + "meta": None, + "cdf": {"version": VERSION}, + "event": None, + }, + } + + def _deep_merge_metadata(self, base: dict, updates: dict) -> dict: + """ + Deep merge metadata updates into base metadata. + + Args: + base: Base metadata dictionary + updates: Updates to apply (can be nested) + + Returns: + Merged metadata dictionary + """ + result = base.copy() + + for key, value in updates.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = self._deep_merge_metadata(result[key], value) + else: + result[key] = value + + return result + + def _internal_validation_metadata( + self, metadata: dict, path: str = "" + ) -> None: + """ + Validate metadata and warn about missing mandatory IDs. + + Args: + metadata: Metadata dictionary to validate + path: Current path in the metadata structure (for nested dicts) + """ + for key, value in metadata.items(): + current_path = f"{path}.{key}" if path else key + + if value == MISSING_MANDATORY_VALUE: + warnings.warn( + f"Missing mandatory ID at '{current_path}'. Currently replaced with the value '{MISSING_MANDATORY_VALUE}'. " + f"Please provide the correct value to 'additional_metadata' to completely adhere to the CDF specification.", + UserWarning, + ) + elif isinstance(value, dict): + self._internal_validation_metadata(value, current_path) + elif isinstance(value, list): + for i, item in enumerate(value): + if isinstance(item, dict): + self._internal_validation_metadata( + item, f"{current_path}[{i}]" + ) + + def _serialize_metadata( + self, + dataset, + outputs, + period_tracking, + additional_metadata: dict, + ): + """ + Serialize metadata to JSON format. + + Builds and writes the complete metadata JSON including competition, season, + match information, periods, whistles, team rosters with formations, and + stadium dimensions. Accepts additional metadata for overrides. + + Args: + dataset: The tracking dataset containing metadata to serialize. + outputs: CDFOutputs object containing the metadata file handle. + period_tracking: Dictionary containing normalized period frame IDs. + additional_metadata: Additional or override metadata following CdfMetaDataSchema. + """ + metadata_json = self._build_default_metadata_structure( + dataset, period_tracking + ) + + if additional_metadata: + metadata_json = self._deep_merge_metadata( + metadata_json, additional_metadata + ) + + self._internal_validation_metadata(metadata_json) + + outputs.meta_data.write( + (json.dumps(metadata_json) + "\n").encode("utf-8") + ) diff --git a/kloppy/infra/serializers/tracking/serializer.py b/kloppy/infra/serializers/tracking/serializer.py new file mode 100644 index 000000000..a7bc72e4c --- /dev/null +++ b/kloppy/infra/serializers/tracking/serializer.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +from kloppy.domain import Provider, TrackingDataset + +T = TypeVar("T") + + +class TrackingDataSerializer(ABC, Generic[T]): + @property + @abstractmethod + def provider(self) -> Provider: + raise NotImplementedError + + @abstractmethod + def serialize(self, dataset: TrackingDataset, outputs: T) -> bool: + raise NotImplementedError diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index 497c7da8e..f58cb1482 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta, timezone import json import logging -from typing import IO, NamedTuple, Optional, Union +from typing import IO, NamedTuple, Optional, Union, Dict import warnings from kloppy.domain import ( @@ -34,7 +34,7 @@ frame_rate = 10 -position_types_mapping: dict[int, PositionType] = { +position_types_mapping: Dict[int, PositionType] = { 0: PositionType.Goalkeeper, 1: PositionType.Unknown, # Does not exist 2: PositionType.CenterBack, # Provider: CB @@ -101,9 +101,7 @@ def _get_frame_data_v2( only_alive, ): ball_owning_team = cls._get_ball_owning_team(frame["possession"], teams) - ball_state = ( - BallState.ALIVE if ball_owning_team is not None else BallState.DEAD - ) + ball_state = BallState.ALIVE if ball_owning_team is not None else BallState.DEAD if ball_state == BallState.DEAD and only_alive: return None @@ -131,17 +129,12 @@ def _get_frame_data_v2( ball_coordinates = Point3D(x=float(x), y=float(y), z=z) continue - elif ( - trackable_object in referee_dict.keys() - or group_name == "referee" - ): + elif trackable_object in referee_dict.keys() or group_name == "referee": group_name = "referee" continue # Skip Referee Coords if group_name is None: - group_name = teamdict.get( - player_id_to_team_dict.get(trackable_object) - ) + group_name = teamdict.get(player_id_to_team_dict.get(trackable_object)) if group_name == "home_team": player = players["HOME"][trackable_object] @@ -173,9 +166,7 @@ def _get_frame_data_v2( players_data=players_data, period=periods[frame["period"]], ball_state=( - BallState.ALIVE - if ball_owning_team is not None - else BallState.DEAD + BallState.ALIVE if ball_owning_team is not None else BallState.DEAD ), ball_owning_team=ball_owning_team, other_data={}, @@ -199,9 +190,7 @@ def _get_frame_data_v3( only_alive, ): ball_owning_team = cls._get_ball_owning_team(frame["possession"], teams) - ball_state = ( - BallState.ALIVE if ball_owning_team is not None else BallState.DEAD - ) + ball_state = BallState.ALIVE if ball_owning_team is not None else BallState.DEAD if ball_state == BallState.DEAD and only_alive: return None @@ -222,9 +211,7 @@ def _get_frame_data_v3( player = all_players_mapping[str(raw_player_data["player_id"])] player_coordinates = cls._raw_coordinates_to_point(raw_player_data) if player_coordinates: - players_data[player] = PlayerData( - coordinates=player_coordinates - ) + players_data[player] = PlayerData(coordinates=player_coordinates) frame = create_frame( frame_id=frame_id, @@ -355,12 +342,8 @@ def __get_periods(cls, tracking): 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 - ), + start_timestamp=timedelta(seconds=_frames[0]["frame"] / frame_rate), + end_timestamp=timedelta(seconds=_frames[-1]["frame"] / frame_rate), ) return periods @@ -419,13 +402,11 @@ def deserialize(self, inputs: SkillCornerInputs) -> TrackingDataset: } player_dict = { - player["trackable_object"]: player - for player in metadata["players"] + player["trackable_object"]: player for player in metadata["players"] } referee_dict = { - ref["trackable_object"]: "referee" - for ref in metadata["referees"] + ref["trackable_object"]: "referee" for ref in metadata["referees"] } ball_id = metadata["ball"]["trackable_object"] @@ -562,9 +543,7 @@ def _iter(): frames.append(frame) n_frames += 1 - if self.limit and n_frames + 1 >= ( - self.limit / self.sample_rate - ): + if self.limit and n_frames + 1 >= (self.limit / self.sample_rate): break attacking_directions = attacking_directions_from_multi_frames( diff --git a/kloppy/io.py b/kloppy/io.py index 305576e1e..15f210afa 100644 --- a/kloppy/io.py +++ b/kloppy/io.py @@ -1,27 +1,34 @@ """I/O utilities for reading raw data.""" import bz2 -from collections.abc import Generator, Iterable, Iterator import contextlib -from contextlib import AbstractContextManager -from dataclasses import dataclass, replace import gzip -from io import BufferedWriter, BytesIO, TextIOWrapper import logging import lzma import os import re +import shutil +import tempfile +from dataclasses import dataclass, replace +from io import BufferedWriter, BytesIO, TextIOWrapper from typing import ( IO, Any, BinaryIO, Callable, + ContextManager, + Generator, + Iterable, + Iterator, + List, Optional, + Tuple, Union, ) from kloppy.exceptions import AdapterError, InputNotFoundError from kloppy.infra.io.adapters import get_adapter +from kloppy.infra.io.buffered_stream import BufferedStream logger = logging.getLogger(__name__) @@ -64,7 +71,7 @@ def create(cls, input_: Optional[FileOrPath], **kwargs): def _file_or_path_to_binary_stream( file_or_path: FileOrPath, binary_mode: str -) -> tuple[BinaryIO, bool]: +) -> Tuple[BinaryIO, bool]: """ Converts a file path or a file-like object to a binary stream. @@ -78,9 +85,7 @@ def _file_or_path_to_binary_stream( """ assert binary_mode in ("rb", "wb", "ab") - if isinstance(file_or_path, (str, bytes)) or hasattr( - file_or_path, "__fspath__" - ): + if isinstance(file_or_path, (str, bytes)) or hasattr(file_or_path, "__fspath__"): # If file_or_path is a path-like object, open it and return the binary stream return open(os.fspath(file_or_path), binary_mode), True # type: ignore @@ -173,7 +178,7 @@ def _open( filename: FileOrPath, mode: str = "rb", compresslevel: Optional[int] = None, - format: Optional[str] = None, # noqa: A002 + format: Optional[str] = None, ) -> BinaryIO: """ A replacement for the "open" function that can also read and write @@ -268,9 +273,7 @@ def _open_gz( if "r" in mode: return gzip.open(filename, mode) # type: ignore - return BufferedWriter( - gzip.open(filename, mode, compresslevel=compresslevel) - ) # type: ignore + return BufferedWriter(gzip.open(filename, mode, compresslevel=compresslevel)) # type: ignore def get_file_extension(file_or_path: FileLike) -> str: @@ -297,9 +300,7 @@ def get_file_extension(file_or_path: FileLike) -> str: >>> get_file_extension(Source(data="example.csv")) '.csv' """ - if isinstance(file_or_path, (str, bytes)) or hasattr( - file_or_path, "__fspath__" - ): + if isinstance(file_or_path, (str, bytes)) or hasattr(file_or_path, "__fspath__"): path = os.fspath(file_or_path) # type: ignore for ext in [".gz", ".xz", ".bz2"]: if path.endswith(ext): @@ -319,10 +320,33 @@ def dummy_context_mgr() -> Generator[None, None, None]: yield +@contextlib.contextmanager +def _write_context_manager(uri: str, mode: str) -> Generator[BinaryIO, None, None]: + """ + Context manager for write operations that buffers writes and flushes to adapter on exit. + + Args: + uri: The destination URI + mode: Write mode ('wb' or 'ab') + + Yields: + A BufferedStream for writing + """ + buffer = BufferedStream() + try: + yield buffer + finally: + adapter = get_adapter(uri) + if adapter: + adapter.write_from_stream(uri, buffer, mode) + else: + raise AdapterError(f"No adapter found for {uri}") + + def open_as_file( - input_: FileLike, -) -> AbstractContextManager[Optional[BinaryIO]]: - """Open a byte stream to the given input object. + input_: FileLike, mode: str = "rb" +) -> ContextManager[Optional[BinaryIO]]: + """Open a byte stream to/from the given input object. The following input types are supported: - A string or `pathlib.Path` object representing a local file path. @@ -338,37 +362,54 @@ def open_as_file( input types. Args: - input_ (FileLike): The input object to be opened. + input_ (FileLike): The input/output object to be opened. + mode (str): File mode - 'rb' (read), 'wb' (write), or 'ab' (append). + Defaults to 'rb'. Returns: - BinaryIO: A binary stream to the input object. + BinaryIO: A binary stream to/from the input object. Raises: - ValueError: If the input is required but not provided. + ValueError: If the input is required but not provided, or invalid mode. InputNotFoundError: If the input file is not found and should not be skipped. TypeError: If the input type is not supported. + NotImplementedError: If write mode is used with unsupported input types. Example: + >>> # Reading >>> with open_as_file("example.txt") as f: ... contents = f.read() + >>> + >>> # Writing + >>> with open_as_file("output.txt", mode="wb") as f: + ... f.write(b"Hello, world!") Note: To support reading data from other sources, see the [Adapter](`kloppy.io.adapters.Adapter`) class. If the given file path or URL ends with '.gz', '.xz', or '.bz2', the - file will be decompressed before being read. + file will be automatically compressed/decompressed. + + Write mode limitations: + - HTTP/HTTPS URLs: Not supported + - Zip archives: Not supported + - Inline strings/bytes: Not supported (invalid output destination) """ + # Validate mode + if mode not in ("rb", "wb", "ab"): + raise ValueError(f"Mode '{mode}' not supported. Use 'rb', 'wb', or 'ab'.") + + # Handle Source wrapper if isinstance(input_, Source): if input_.data is None and input_.optional: - # This saves us some additional code in every vendor specific code return dummy_context_mgr() elif input_.data is None: raise ValueError("Input required but not provided.") else: try: - return open_as_file(input_.data) + return open_as_file(input_.data, mode=mode) except InputNotFoundError as exc: if input_.skip_if_missing: logging.info(f"Input {input_.data} not found. Skipping") @@ -376,39 +417,61 @@ def open_as_file( else: raise exc - if isinstance(input_, str) and ("{" in input_ or "<" in input_): - # If input_ is a JSON or XML string, return it as a binary stream - return BytesIO(input_.encode("utf8")) - - if isinstance(input_, bytes): - # If input_ is a bytes object, return it as a binary stream - return BytesIO(input_) - + # Write modes: Cannot write to inline data + if mode in ("wb", "ab"): + if isinstance(input_, str) and ("{" in input_ or "<" in input_): + raise TypeError("Cannot write to inline JSON/XML string.") + if isinstance(input_, bytes): + raise TypeError("Cannot write to bytes object. Use BytesIO instead.") + + # Read modes: Handle inline data + if mode == "rb": + if isinstance(input_, str) and ("{" in input_ or "<" in input_): + return BytesIO(input_.encode("utf8")) + if isinstance(input_, bytes): + return BytesIO(input_) + + # Handle paths (local files, URLs, S3, etc.) if isinstance(input_, str) or hasattr(input_, "__fspath__"): - # If input_ is a path-like object, open it and return the binary stream uri = _filepath_from_path_or_filelike(input_) adapter = get_adapter(uri) - if adapter: - stream = BytesIO() + if not adapter: + raise AdapterError(f"No adapter found for {uri}") + + if mode == "rb": + # Read mode: buffer data from adapter + stream = BufferedStream() adapter.read_to_stream(uri, stream) stream.seek(0) + return stream else: - raise AdapterError(f"No adapter found for {uri}") - return stream + # Write mode: return context manager that flushes on exit + return _write_context_manager(uri, mode) + # Handle file-like objects if isinstance(input_, TextIOWrapper): - # If file_or_path is a TextIOWrapper, return its underlying binary buffer return input_.buffer - if hasattr(input_, "readinto"): - # If file_or_path is a file-like object, return it as is - return _open(input_) # type: ignore + if hasattr(input_, "readinto") or ( + mode in ("wb", "ab") and hasattr(input_, "write") + ): + # File-like object (BytesIO, file handles, etc.) + if hasattr(input_, "mode") and input_.mode != mode: # type: ignore + # If it's a real file with a mode, check compatibility + raise ValueError(f"File opened in mode '{input_.mode}' but '{mode}' requested") # type: ignore + + # Use _open to handle potential compression detection + if mode == "rb": + return _open(input_, mode) # type: ignore + else: + # For write modes, return file-like object directly with nullcontext + return contextlib.nullcontext(input_) # type: ignore raise TypeError(f"Unsupported input type: {type(input_)}") -def _natural_sort_key(path: str) -> list[Union[int, str]]: +def _natural_sort_key(path: str) -> List[Union[int, str]]: # Split string into list of chunks for natural sorting return [ int(text) if text.isdigit() else text.lower() diff --git a/kloppy/tests/files/skillcorner_v3_meta_data-2.json b/kloppy/tests/files/skillcorner_v3_meta_data-2.json new file mode 100644 index 000000000..10779035f --- /dev/null +++ b/kloppy/tests/files/skillcorner_v3_meta_data-2.json @@ -0,0 +1 @@ +{"id":1886347,"home_team_score":2,"away_team_score":0,"date_time":"2024-11-30T04:00:00Z","stadium":{"id":3811,"name":"Mount Smart Stadium","city":"Auckland","capacity":25000},"home_team":{"id":4177,"name":"Auckland FC","short_name":"Auckland FC","acronym":"AUC"},"home_team_kit":{"id":14025,"team_id":4177,"season":{"id":95,"start_year":2024,"end_year":2025,"name":"2024/2025"},"name":"Home","jersey_color":"#2800f0","number_color":"#ffffff"},"away_team":{"id":1805,"name":"Newcastle United Jets FC","short_name":"Newcastle","acronym":"NEW"},"away_team_kit":{"id":10376,"team_id":1805,"season":{"id":29,"start_year":2024,"end_year":2024,"name":"2024"},"name":"away","jersey_color":"#ffffff","number_color":"#000000"},"home_team_coach":null,"away_team_coach":null,"home_team_playing_time":{"minutes_tip":31.13,"minutes_otip":22.2},"away_team_playing_time":{"minutes_tip":22.2,"minutes_otip":31.13},"competition_edition":{"id":870,"competition":{"id":61,"area":"AUS","name":"A-League","gender":"male","age_group":"adult"},"season":{"id":95,"start_year":2024,"end_year":2025,"name":"2024/2025"},"name":"AUS - A-League - 2024/2025"},"match_periods":[{"period":1,"name":"period_1","start_frame":10,"end_frame":27790,"duration_frames":27780,"duration_minutes":46.3},{"period":2,"name":"period_2","start_frame":27800,"end_frame":59060,"duration_frames":31260,"duration_minutes":52.1}],"competition_round":{"id":611,"name":"Round 6","round_number":6,"potential_overtime":false},"referees":[],"players":[{"player_role":{"id":15,"position_group":"Center Forward","name":"Center Forward","acronym":"CF"},"start_time":"00:00:00","end_time":"01:25:21","number":10,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":29.55,"minutes_otip":18.76,"start_frame":10,"end_frame":52009,"minutes_played":86.65,"minutes_played_regular_time":86.65},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.34,"minutes_otip":7.73,"start_frame":27800,"end_frame":52009,"minutes_played":40.35}]},"team_player_id":1507965,"team_id":4177,"id":38673,"first_name":"Guillermo Luis","last_name":"May Bartesaghi","short_name":"G. May","birthday":"1998-03-11","trackable_object":39794,"gender":"male"},{"player_role":{"id":20,"position_group":"Full Back","name":"Right Back","acronym":"RB"},"start_time":"00:00:00","end_time":null,"number":17,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507968,"team_id":4177,"id":51713,"first_name":"Callan","last_name":"Elliot","short_name":"C. Elliott","birthday":"1999-07-07","trackable_object":52839,"gender":"male"},{"player_role":{"id":15,"position_group":"Center Forward","name":"Center Forward","acronym":"CF"},"start_time":"00:00:00","end_time":"01:16:37","number":22,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":26.28,"minutes_otip":17.94,"start_frame":10,"end_frame":46769,"minutes_played":77.91,"minutes_played_regular_time":77.91},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":8.07,"minutes_otip":6.91,"start_frame":27800,"end_frame":46769,"minutes_played":31.62}]},"team_player_id":1507963,"team_id":4177,"id":50951,"first_name":"Jake","last_name":"Brimmer","short_name":"J. Brimmer","birthday":"1998-04-03","trackable_object":52077,"gender":"male"},{"player_role":{"id":21,"position_group":"Midfield","name":"Left Defensive Midfield","acronym":"LDM"},"start_time":"00:00:00","end_time":"01:24:58","number":19,"yellow_card":1,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":18.76,"minutes_otip":29.55,"start_frame":10,"end_frame":51779,"minutes_played":86.26,"minutes_played_regular_time":86.26},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":7.73,"minutes_otip":11.34,"start_frame":27800,"end_frame":51779,"minutes_played":39.97}]},"team_player_id":1087119,"team_id":1805,"id":50978,"first_name":"Callum","last_name":"Timmins","short_name":"C. Timmins","birthday":"1999-12-23","trackable_object":52104,"gender":"male"},{"player_role":{"id":19,"position_group":"Full Back","name":"Left Back","acronym":"LB"},"start_time":"00:00:00","end_time":null,"number":15,"yellow_card":0,"red_card":0,"injured":false,"goal":1,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507956,"team_id":4177,"id":133498,"first_name":"Francis","last_name":"De Vries","short_name":"F. De Vries","birthday":"1994-11-28","trackable_object":135053,"gender":"male"},{"player_role":{"id":3,"position_group":"Central Defender","name":"Left Center Back","acronym":"LCB"},"start_time":"00:00:00","end_time":null,"number":4,"yellow_card":1,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507971,"team_id":4177,"id":33697,"first_name":"Nando","last_name":"Pijnaker","short_name":"N. Pijnaker","birthday":"1999-02-25","trackable_object":34805,"gender":"male"},{"player_role":{"id":4,"position_group":"Central Defender","name":"Right Center Back","acronym":"RCB"},"start_time":"00:00:00","end_time":null,"number":23,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507974,"team_id":4177,"id":51667,"first_name":"Daniel","last_name":"Hall","short_name":"D. Hall","birthday":"1999-06-14","trackable_object":52793,"gender":"male"},{"player_role":{"id":7,"position_group":"Midfield","name":"Defensive Midfield","acronym":"DM"},"start_time":"00:00:00","end_time":null,"number":6,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507966,"team_id":4177,"id":14736,"first_name":"Louis","last_name":"Verstraete","short_name":"L. Verstraete","birthday":"1999-05-04","trackable_object":14933,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":28,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1508548,"team_id":1805,"id":966125,"first_name":"Will ","last_name":"Dobson","short_name":"W. Will Dobson","birthday":null,"trackable_object":967688,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":21,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1508547,"team_id":1805,"id":966124,"first_name":"Noah Paul","last_name":"James","short_name":"N. James","birthday":"2001-02-14","trackable_object":967687,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":27,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1229663,"team_id":1805,"id":808965,"first_name":"Nathan","last_name":"Grimaldi","short_name":"N. Grimaldi","birthday":"2001-09-15","trackable_object":810528,"gender":"male"},{"player_role":{"id":12,"position_group":"Wide Attacker","name":"Left Winger","acronym":"LW"},"start_time":"00:00:00","end_time":null,"number":39,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1087109,"team_id":1805,"id":735573,"first_name":"Thomas","last_name":"Aquilina","short_name":"T. Aquilina","birthday":"2001-02-02","trackable_object":737136,"gender":"male"},{"player_role":{"id":0,"position_group":"Other","name":"Goalkeeper","acronym":"GK"},"start_time":"00:00:00","end_time":null,"number":1,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1204873,"team_id":1805,"id":51009,"first_name":"Ryan","last_name":"Scott","short_name":"R. Scott","birthday":"1995-12-18","trackable_object":52135,"gender":"male"},{"player_role":{"id":4,"position_group":"Central Defender","name":"Right Center Back","acronym":"RCB"},"start_time":"00:00:00","end_time":null,"number":4,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1204875,"team_id":1805,"id":176224,"first_name":"Phillip","last_name":"Cancar","short_name":"P. Cancar","birthday":"2001-05-11","trackable_object":177779,"gender":"male"},{"player_role":{"id":19,"position_group":"Full Back","name":"Left Back","acronym":"LB"},"start_time":"00:00:00","end_time":null,"number":33,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1087123,"team_id":1805,"id":735578,"first_name":"Mark","last_name":"Natta","short_name":"M. Natta","birthday":"2002-11-28","trackable_object":737141,"gender":"male"},{"player_role":{"id":22,"position_group":"Midfield","name":"Right Defensive Midfield","acronym":"RDM"},"start_time":"00:00:00","end_time":null,"number":17,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1087114,"team_id":1805,"id":735574,"first_name":"Kosta","last_name":"Grozos","short_name":"K. Grozos","birthday":"2000-08-10","trackable_object":737137,"gender":"male"},{"player_role":{"id":20,"position_group":"Full Back","name":"Right Back","acronym":"RB"},"start_time":"00:00:00","end_time":null,"number":14,"yellow_card":1,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1087111,"team_id":1805,"id":50983,"first_name":"Dane","last_name":"Ingham","short_name":"D. Ingham","birthday":"1999-06-08","trackable_object":52109,"gender":"male"},{"player_role":{"id":3,"position_group":"Central Defender","name":"Left Center Back","acronym":"LCB"},"start_time":"00:00:00","end_time":null,"number":15,"yellow_card":1,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":22.2,"minutes_otip":31.13,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":11.18,"minutes_otip":12.92,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1508538,"team_id":1805,"id":51649,"first_name":"Aleksandar","last_name":"Šušnjar","short_name":"A. Šušnjar","birthday":"1995-08-19","trackable_object":52775,"gender":"male"},{"player_role":{"id":12,"position_group":"Wide Attacker","name":"Left Winger","acronym":"LW"},"start_time":"00:00:00","end_time":null,"number":14,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507975,"team_id":4177,"id":965685,"first_name":"Liam ","last_name":"Gillion","short_name":"L. Gillion","birthday":"2002-10-17","trackable_object":967248,"gender":"male"},{"player_role":{"id":15,"position_group":"Center Forward","name":"Center Forward","acronym":"CF"},"start_time":"01:07:43","end_time":null,"number":13,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":6.28,"minutes_otip":6.56,"start_frame":41430,"end_frame":59059,"minutes_played":29.38,"minutes_played_regular_time":29.38},"by_period":[{"name":"period_2","minutes_tip":6.28,"minutes_otip":6.56,"start_frame":41430,"end_frame":59059,"minutes_played":29.38}]},"team_player_id":1204871,"team_id":1805,"id":795506,"first_name":"Clayton","last_name":"Taylor","short_name":"C. Taylor","birthday":"2004-03-01","trackable_object":797069,"gender":"male"},{"player_role":{"id":11,"position_group":"Midfield","name":"Attacking Midfield","acronym":"AM"},"start_time":"01:24:58","end_time":null,"number":6,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":3.45,"minutes_otip":1.58,"start_frame":51780,"end_frame":59059,"minutes_played":12.13,"minutes_played_regular_time":12.13},"by_period":[{"name":"period_2","minutes_tip":3.45,"minutes_otip":1.58,"start_frame":51780,"end_frame":59059,"minutes_played":12.13}]},"team_player_id":1508543,"team_id":1805,"id":797297,"first_name":"Matthew","last_name":"Scarcella","short_name":"M. Scarcella","birthday":"2004-03-04","trackable_object":798860,"gender":"male"},{"player_role":{"id":11,"position_group":"Midfield","name":"Attacking Midfield","acronym":"AM"},"start_time":"01:00:15","end_time":null,"number":23,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":6.98,"minutes_otip":7.76,"start_frame":36950,"end_frame":59059,"minutes_played":36.85,"minutes_played_regular_time":36.85},"by_period":[{"name":"period_2","minutes_tip":6.98,"minutes_otip":7.76,"start_frame":36950,"end_frame":59059,"minutes_played":36.85}]},"team_player_id":1204874,"team_id":1805,"id":560992,"first_name":"Daniel","last_name":"Wilmering","short_name":"D. Wilmering","birthday":"2000-12-19","trackable_object":562549,"gender":"male"},{"player_role":{"id":21,"position_group":"Midfield","name":"Left Defensive Midfield","acronym":"LDM"},"start_time":"01:24:58","end_time":null,"number":29,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":3.45,"minutes_otip":1.58,"start_frame":51780,"end_frame":59059,"minutes_played":12.13,"minutes_played_regular_time":12.13},"by_period":[{"name":"period_2","minutes_tip":3.45,"minutes_otip":1.58,"start_frame":51780,"end_frame":59059,"minutes_played":12.13}]},"team_player_id":1216248,"team_id":1805,"id":800320,"first_name":"Justin","last_name":"Vidic","short_name":"J. Vidic","birthday":"2004-04-29","trackable_object":801883,"gender":"male"},{"player_role":{"id":13,"position_group":"Wide Attacker","name":"Right Winger","acronym":"RW"},"start_time":"01:09:53","end_time":null,"number":25,"yellow_card":0,"red_card":0,"injured":false,"goal":1,"own_goal":0,"playing_time":{"total":{"minutes_tip":6.49,"minutes_otip":5.75,"start_frame":42730,"end_frame":59059,"minutes_played":27.22,"minutes_played_regular_time":27.22},"by_period":[{"name":"period_2","minutes_tip":6.49,"minutes_otip":5.75,"start_frame":42730,"end_frame":59059,"minutes_played":27.22}]},"team_player_id":1507969,"team_id":4177,"id":43829,"first_name":"Neyder Stiven","last_name":"Moreno Betancur","short_name":"N. Moreno","birthday":"1997-02-09","trackable_object":44955,"gender":"male"},{"player_role":{"id":2,"position_group":"Central Defender","name":"Center Back","acronym":"CB"},"start_time":"01:25:21","end_time":null,"number":5,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":1.58,"minutes_otip":3.45,"start_frame":52010,"end_frame":59059,"minutes_played":11.75,"minutes_played_regular_time":11.75},"by_period":[{"name":"period_2","minutes_tip":1.58,"minutes_otip":3.45,"start_frame":52010,"end_frame":59059,"minutes_played":11.75}]},"team_player_id":1507955,"team_id":4177,"id":31147,"first_name":"Tommy","last_name":"Smith","short_name":"T. Smith","birthday":"1990-03-31","trackable_object":32245,"gender":"male"},{"player_role":{"id":15,"position_group":"Center Forward","name":"Center Forward","acronym":"CF"},"start_time":"01:16:37","end_time":null,"number":9,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":4.85,"minutes_otip":4.27,"start_frame":46770,"end_frame":59059,"minutes_played":20.48,"minutes_played_regular_time":20.48},"by_period":[{"name":"period_2","minutes_tip":4.85,"minutes_otip":4.27,"start_frame":46770,"end_frame":59059,"minutes_played":20.48}]},"team_player_id":1507964,"team_id":4177,"id":163972,"first_name":"Max","last_name":"Mata","short_name":"M. Mata","birthday":"2000-07-10","trackable_object":165527,"gender":"male"},{"player_role":{"id":13,"position_group":"Wide Attacker","name":"Right Winger","acronym":"RW"},"start_time":"00:00:00","end_time":"01:09:53","number":27,"yellow_card":1,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":24.64,"minutes_otip":16.45,"start_frame":10,"end_frame":42729,"minutes_played":71.18,"minutes_played_regular_time":71.18},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":6.43,"minutes_otip":5.42,"start_frame":27800,"end_frame":42729,"minutes_played":24.88}]},"team_player_id":1507961,"team_id":4177,"id":133501,"first_name":"Logan","last_name":"Rogerson","short_name":"L. Rogerson","birthday":"1998-05-28","trackable_object":135056,"gender":"male"},{"player_role":{"id":11,"position_group":"Midfield","name":"Attacking Midfield","acronym":"AM"},"start_time":"00:00:00","end_time":null,"number":28,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507957,"team_id":4177,"id":23418,"first_name":"Luis Felipe","last_name":"Gallegos Leiva","short_name":"F. Gallegos","birthday":"1991-12-03","trackable_object":24342,"gender":"male"},{"player_role":{"id":11,"position_group":"Midfield","name":"Attacking Midfield","acronym":"AM"},"start_time":"00:00:00","end_time":"01:00:15","number":37,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":15.23,"minutes_otip":23.37,"start_frame":10,"end_frame":36949,"minutes_played":61.55,"minutes_played_regular_time":61.55},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":4.2,"minutes_otip":5.16,"start_frame":27800,"end_frame":36949,"minutes_played":15.25}]},"team_player_id":1204872,"team_id":1805,"id":795507,"first_name":"Lachlan","last_name":"Bayliss","short_name":"L. Bayliss","birthday":"2002-07-24","trackable_object":797070,"gender":"male"},{"player_role":{"id":13,"position_group":"Wide Attacker","name":"Right Winger","acronym":"RW"},"start_time":"00:00:00","end_time":"01:24:58","number":7,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":18.76,"minutes_otip":29.55,"start_frame":10,"end_frame":51779,"minutes_played":86.26,"minutes_played_regular_time":86.26},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":7.73,"minutes_otip":11.34,"start_frame":27800,"end_frame":51779,"minutes_played":39.97}]},"team_player_id":1508540,"team_id":1805,"id":795505,"first_name":"Eli","last_name":"Adams","short_name":"E. Adams","birthday":"2002-03-12","trackable_object":797068,"gender":"male"},{"player_role":{"id":15,"position_group":"Center Forward","name":"Center Forward","acronym":"CF"},"start_time":"00:00:00","end_time":"01:07:43","number":22,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":15.92,"minutes_otip":24.57,"start_frame":10,"end_frame":41429,"minutes_played":69.02,"minutes_played_regular_time":69.02},"by_period":[{"name":"period_1","minutes_tip":11.03,"minutes_otip":18.21,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":4.89,"minutes_otip":6.36,"start_frame":27800,"end_frame":41429,"minutes_played":22.72}]},"team_player_id":1508542,"team_id":1805,"id":966120,"first_name":"Benjamin","last_name":"Gibson","short_name":"B. Gibson","birthday":"2003-01-03","trackable_object":967683,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":3,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1507960,"team_id":4177,"id":23928,"first_name":"Scott","last_name":"Galloway","short_name":"S. Galloway","birthday":"1995-04-25","trackable_object":24886,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":1,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1507962,"team_id":4177,"id":14355,"first_name":"Michael","last_name":"Woud","short_name":"M. Woud","birthday":"1999-01-16","trackable_object":14552,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":8,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1507973,"team_id":4177,"id":965684,"first_name":"Luis","last_name":"Toomey","short_name":"L. Toomey","birthday":"2001-07-01","trackable_object":967247,"gender":"male"},{"player_role":{"id":17,"position_group":"Other","name":"Substitute","acronym":"SUB"},"start_time":null,"end_time":null,"number":7,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":null,"by_period":[]},"team_player_id":1507959,"team_id":4177,"id":133495,"first_name":"Cameron Drew","last_name":"Howieson","short_name":"C. Howieson","birthday":"1994-12-22","trackable_object":135050,"gender":"male"},{"player_role":{"id":0,"position_group":"Other","name":"Goalkeeper","acronym":"GK"},"start_time":"00:00:00","end_time":null,"number":12,"yellow_card":0,"red_card":0,"injured":false,"goal":0,"own_goal":0,"playing_time":{"total":{"minutes_tip":31.13,"minutes_otip":22.2,"start_frame":10,"end_frame":59059,"minutes_played":98.4,"minutes_played_regular_time":98.4},"by_period":[{"name":"period_1","minutes_tip":18.21,"minutes_otip":11.03,"start_frame":10,"end_frame":27790,"minutes_played":46.3},{"name":"period_2","minutes_tip":12.92,"minutes_otip":11.18,"start_frame":27800,"end_frame":59059,"minutes_played":52.1}]},"team_player_id":1507970,"team_id":4177,"id":285188,"first_name":"Alex Noah","last_name":"Paulsen","short_name":"A. Paulsen","birthday":"2002-07-04","trackable_object":286745,"gender":"male"}],"status":"closed","home_team_side":["right_to_left","left_to_right"],"ball":{"trackable_object":55},"pitch_length":104,"pitch_width":68} \ No newline at end of file diff --git a/kloppy/tests/files/skillcorner_v3_raw_data-2.jsonl b/kloppy/tests/files/skillcorner_v3_raw_data-2.jsonl new file mode 100644 index 000000000..b5d6c6cf1 --- /dev/null +++ b/kloppy/tests/files/skillcorner_v3_raw_data-2.jsonl @@ -0,0 +1,17 @@ +{"frame": 10, "timestamp": "00:00:00.00", "period": 1, "ball_data": {"x": 0.32, "y": 0.38, "z": 0.13, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -52.52, "y_top_left": 39.0, "x_bottom_left": -23.21, "y_bottom_left": -37.05, "x_bottom_right": 22.76, "y_bottom_right": -36.88, "x_top_right": 50.99, "y_top_right": 39.0}, "player_data": [{"x": -39.63, "y": -0.08, "player_id": 51009, "is_detected": false}, {"x": -19.21, "y": -9.18, "player_id": 176224, "is_detected": true}, {"x": -21.83, "y": 0.47, "player_id": 51649, "is_detected": true}, {"x": -1.16, "y": -32.47, "player_id": 50983, "is_detected": true}, {"x": -18.88, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.41, "y": 7.13, "player_id": 50978, "is_detected": true}, {"x": -9.51, "y": -5.01, "player_id": 735574, "is_detected": true}, {"x": -2.5, "y": 7.27, "player_id": 795507, "is_detected": false}, {"x": -0.78, "y": -20.69, "player_id": 795505, "is_detected": true}, {"x": -1.85, "y": 18.8, "player_id": 735573, "is_detected": true}, {"x": 1.27, "y": 0.88, "player_id": 966120, "is_detected": true}, {"x": 40.47, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 17.85, "y": 5.52, "player_id": 51667, "is_detected": true}, {"x": 16.78, "y": -3.67, "player_id": 33697, "is_detected": true}, {"x": 17.03, "y": 14.69, "player_id": 51713, "is_detected": true}, {"x": 17.55, "y": -13.6, "player_id": 133498, "is_detected": true}, {"x": 11.7, "y": 6.73, "player_id": 14736, "is_detected": true}, {"x": 10.16, "y": -2.12, "player_id": 23418, "is_detected": true}, {"x": 0.91, "y": 18.96, "player_id": 133501, "is_detected": false}, {"x": 7.74, "y": -16.27, "player_id": 965685, "is_detected": true}, {"x": 0.4, "y": -8.28, "player_id": 50951, "is_detected": true}, {"x": 2.67, "y": 9.94, "player_id": 38673, "is_detected": true}]} +{"frame": 11, "timestamp": "00:00:00.10", "period": 1, "ball_data": {"x": 0.54, "y": 0.08, "z": 0.22, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -52.37, "y_top_left": 39.0, "x_bottom_left": -23.18, "y_bottom_left": -36.89, "x_bottom_right": 22.69, "y_bottom_right": -36.7, "x_top_right": 50.74, "y_top_right": 39.0}, "player_data": [{"x": -39.86, "y": -0.13, "player_id": 51009, "is_detected": false}, {"x": -19.23, "y": -9.23, "player_id": 176224, "is_detected": true}, {"x": -21.82, "y": 0.43, "player_id": 51649, "is_detected": true}, {"x": -1.14, "y": -32.57, "player_id": 50983, "is_detected": true}, {"x": -18.98, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.37, "y": 7.13, "player_id": 50978, "is_detected": true}, {"x": -9.48, "y": -5.08, "player_id": 735574, "is_detected": true}, {"x": -2.29, "y": 7.33, "player_id": 795507, "is_detected": false}, {"x": -0.84, "y": -20.66, "player_id": 795505, "is_detected": true}, {"x": -1.82, "y": 18.78, "player_id": 735573, "is_detected": true}, {"x": 1.24, "y": 0.74, "player_id": 966120, "is_detected": true}, {"x": 40.65, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 17.92, "y": 5.42, "player_id": 51667, "is_detected": true}, {"x": 16.81, "y": -3.7, "player_id": 33697, "is_detected": true}, {"x": 17.09, "y": 14.62, "player_id": 51713, "is_detected": true}, {"x": 17.57, "y": -13.63, "player_id": 133498, "is_detected": true}, {"x": 11.58, "y": 6.69, "player_id": 14736, "is_detected": true}, {"x": 10.13, "y": -2.22, "player_id": 23418, "is_detected": true}, {"x": 0.98, "y": 18.84, "player_id": 133501, "is_detected": false}, {"x": 7.78, "y": -16.35, "player_id": 965685, "is_detected": true}, {"x": 0.44, "y": -8.39, "player_id": 50951, "is_detected": true}, {"x": 2.61, "y": 9.91, "player_id": 38673, "is_detected": true}]} +{"frame": 12, "timestamp": "00:00:00.20", "period": 1, "ball_data": {"x": 0.57, "y": -0.07, "z": 0.19, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -52.11, "y_top_left": 39.0, "x_bottom_left": -23.09, "y_bottom_left": -36.74, "x_bottom_right": 22.58, "y_bottom_right": -36.58, "x_top_right": 50.48, "y_top_right": 39.0}, "player_data": [{"x": -40.06, "y": -0.18, "player_id": 51009, "is_detected": false}, {"x": -19.24, "y": -9.27, "player_id": 176224, "is_detected": true}, {"x": -21.81, "y": 0.4, "player_id": 51649, "is_detected": true}, {"x": -1.13, "y": -32.66, "player_id": 50983, "is_detected": true}, {"x": -19.07, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.32, "y": 7.14, "player_id": 50978, "is_detected": true}, {"x": -9.46, "y": -5.15, "player_id": 735574, "is_detected": true}, {"x": -2.09, "y": 7.39, "player_id": 795507, "is_detected": false}, {"x": -0.9, "y": -20.64, "player_id": 795505, "is_detected": true}, {"x": -1.8, "y": 18.77, "player_id": 735573, "is_detected": true}, {"x": 1.22, "y": 0.61, "player_id": 966120, "is_detected": true}, {"x": 40.83, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 17.98, "y": 5.34, "player_id": 51667, "is_detected": true}, {"x": 16.83, "y": -3.72, "player_id": 33697, "is_detected": true}, {"x": 17.13, "y": 14.55, "player_id": 51713, "is_detected": true}, {"x": 17.59, "y": -13.66, "player_id": 133498, "is_detected": true}, {"x": 11.46, "y": 6.66, "player_id": 14736, "is_detected": true}, {"x": 10.09, "y": -2.31, "player_id": 23418, "is_detected": true}, {"x": 1.05, "y": 18.74, "player_id": 133501, "is_detected": false}, {"x": 7.8, "y": -16.43, "player_id": 965685, "is_detected": true}, {"x": 0.48, "y": -8.49, "player_id": 50951, "is_detected": true}, {"x": 2.56, "y": 9.87, "player_id": 38673, "is_detected": true}]} +{"frame": 13, "timestamp": "00:00:00.30", "period": 1, "ball_data": {"x": 0.56, "y": -0.07, "z": 0.14, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -52.02, "y_top_left": 39.0, "x_bottom_left": -23.09, "y_bottom_left": -36.67, "x_bottom_right": 22.54, "y_bottom_right": -36.51, "x_top_right": 50.28, "y_top_right": 39.0}, "player_data": [{"x": -40.24, "y": -0.22, "player_id": 51009, "is_detected": false}, {"x": -19.25, "y": -9.31, "player_id": 176224, "is_detected": true}, {"x": -21.8, "y": 0.36, "player_id": 51649, "is_detected": true}, {"x": -1.12, "y": -32.74, "player_id": 50983, "is_detected": true}, {"x": -19.16, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.28, "y": 7.14, "player_id": 50978, "is_detected": true}, {"x": -9.45, "y": -5.2, "player_id": 735574, "is_detected": true}, {"x": -1.91, "y": 7.45, "player_id": 795507, "is_detected": false}, {"x": -0.95, "y": -20.62, "player_id": 795505, "is_detected": true}, {"x": -1.76, "y": 18.76, "player_id": 735573, "is_detected": true}, {"x": 1.19, "y": 0.51, "player_id": 966120, "is_detected": true}, {"x": 40.98, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 18.04, "y": 5.26, "player_id": 51667, "is_detected": true}, {"x": 16.86, "y": -3.73, "player_id": 33697, "is_detected": true}, {"x": 17.17, "y": 14.48, "player_id": 51713, "is_detected": true}, {"x": 17.6, "y": -13.68, "player_id": 133498, "is_detected": true}, {"x": 11.34, "y": 6.63, "player_id": 14736, "is_detected": true}, {"x": 10.06, "y": -2.39, "player_id": 23418, "is_detected": true}, {"x": 1.11, "y": 18.63, "player_id": 133501, "is_detected": false}, {"x": 7.83, "y": -16.51, "player_id": 965685, "is_detected": true}, {"x": 0.51, "y": -8.57, "player_id": 50951, "is_detected": true}, {"x": 2.5, "y": 9.83, "player_id": 38673, "is_detected": true}]} +{"frame": 14, "timestamp": "00:00:00.40", "period": 1, "ball_data": {"x": 0.59, "y": -0.03, "z": 0.14, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.78, "y_top_left": 39.0, "x_bottom_left": -22.98, "y_bottom_left": -36.59, "x_bottom_right": 22.49, "y_bottom_right": -36.4, "x_top_right": 50.16, "y_top_right": 39.0}, "player_data": [{"x": -40.39, "y": -0.25, "player_id": 51009, "is_detected": false}, {"x": -19.26, "y": -9.36, "player_id": 176224, "is_detected": true}, {"x": -21.78, "y": 0.34, "player_id": 51649, "is_detected": true}, {"x": -1.11, "y": -32.82, "player_id": 50983, "is_detected": true}, {"x": -19.25, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.23, "y": 7.13, "player_id": 50978, "is_detected": true}, {"x": -9.44, "y": -5.24, "player_id": 735574, "is_detected": true}, {"x": -1.75, "y": 7.52, "player_id": 795507, "is_detected": false}, {"x": -1.0, "y": -20.6, "player_id": 795505, "is_detected": true}, {"x": -1.73, "y": 18.75, "player_id": 735573, "is_detected": true}, {"x": 1.17, "y": 0.41, "player_id": 966120, "is_detected": true}, {"x": 41.12, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 18.08, "y": 5.19, "player_id": 51667, "is_detected": true}, {"x": 16.88, "y": -3.74, "player_id": 33697, "is_detected": true}, {"x": 17.19, "y": 14.43, "player_id": 51713, "is_detected": true}, {"x": 17.62, "y": -13.71, "player_id": 133498, "is_detected": true}, {"x": 11.22, "y": 6.61, "player_id": 14736, "is_detected": true}, {"x": 10.02, "y": -2.45, "player_id": 23418, "is_detected": true}, {"x": 1.17, "y": 18.53, "player_id": 133501, "is_detected": false}, {"x": 7.84, "y": -16.57, "player_id": 965685, "is_detected": true}, {"x": 0.53, "y": -8.64, "player_id": 50951, "is_detected": true}, {"x": 2.45, "y": 9.79, "player_id": 38673, "is_detected": true}]} +{"frame": 15, "timestamp": "00:00:00.50", "period": 1, "ball_data": {"x": 0.63, "y": 0.02, "z": 0.14, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.61, "y_top_left": 39.0, "x_bottom_left": -22.99, "y_bottom_left": -36.5, "x_bottom_right": 22.4, "y_bottom_right": -36.42, "x_top_right": 49.91, "y_top_right": 39.0}, "player_data": [{"x": -40.52, "y": -0.28, "player_id": 51009, "is_detected": false}, {"x": -19.27, "y": -9.4, "player_id": 176224, "is_detected": true}, {"x": -21.77, "y": 0.31, "player_id": 51649, "is_detected": true}, {"x": -1.1, "y": -32.88, "player_id": 50983, "is_detected": true}, {"x": -19.33, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.19, "y": 7.12, "player_id": 50978, "is_detected": true}, {"x": -9.43, "y": -5.28, "player_id": 735574, "is_detected": true}, {"x": -1.6, "y": 7.59, "player_id": 795507, "is_detected": false}, {"x": -1.04, "y": -20.59, "player_id": 795505, "is_detected": true}, {"x": -1.7, "y": 18.75, "player_id": 735573, "is_detected": true}, {"x": 1.15, "y": 0.34, "player_id": 966120, "is_detected": true}, {"x": 41.25, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 18.12, "y": 5.13, "player_id": 51667, "is_detected": true}, {"x": 16.9, "y": -3.74, "player_id": 33697, "is_detected": true}, {"x": 17.2, "y": 14.37, "player_id": 51713, "is_detected": true}, {"x": 17.63, "y": -13.73, "player_id": 133498, "is_detected": true}, {"x": 11.11, "y": 6.59, "player_id": 14736, "is_detected": true}, {"x": 9.98, "y": -2.51, "player_id": 23418, "is_detected": true}, {"x": 1.22, "y": 18.44, "player_id": 133501, "is_detected": false}, {"x": 7.85, "y": -16.64, "player_id": 965685, "is_detected": true}, {"x": 0.55, "y": -8.69, "player_id": 50951, "is_detected": true}, {"x": 2.4, "y": 9.75, "player_id": 38673, "is_detected": true}]} +{"frame": 16, "timestamp": "00:00:00.60", "period": 1, "ball_data": {"x": 0.65, "y": 0.03, "z": 0.14, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.55, "y_top_left": 39.0, "x_bottom_left": -22.93, "y_bottom_left": -36.56, "x_bottom_right": 22.38, "y_bottom_right": -36.37, "x_top_right": 49.8, "y_top_right": 39.0}, "player_data": [{"x": -40.63, "y": -0.3, "player_id": 51009, "is_detected": false}, {"x": -19.28, "y": -9.43, "player_id": 176224, "is_detected": true}, {"x": -21.75, "y": 0.29, "player_id": 51649, "is_detected": true}, {"x": -1.09, "y": -32.93, "player_id": 50983, "is_detected": true}, {"x": -19.41, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -7.14, "y": 7.11, "player_id": 50978, "is_detected": true}, {"x": -9.43, "y": -5.3, "player_id": 735574, "is_detected": true}, {"x": -1.47, "y": 7.66, "player_id": 795507, "is_detected": false}, {"x": -1.08, "y": -20.57, "player_id": 795505, "is_detected": true}, {"x": -1.66, "y": 18.75, "player_id": 735573, "is_detected": true}, {"x": 1.13, "y": 0.28, "player_id": 966120, "is_detected": true}, {"x": 41.36, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 18.15, "y": 5.08, "player_id": 51667, "is_detected": true}, {"x": 16.93, "y": -3.74, "player_id": 33697, "is_detected": true}, {"x": 17.2, "y": 14.33, "player_id": 51713, "is_detected": true}, {"x": 17.63, "y": -13.75, "player_id": 133498, "is_detected": true}, {"x": 11.0, "y": 6.58, "player_id": 14736, "is_detected": true}, {"x": 9.93, "y": -2.56, "player_id": 23418, "is_detected": true}, {"x": 1.26, "y": 18.35, "player_id": 133501, "is_detected": false}, {"x": 7.85, "y": -16.69, "player_id": 965685, "is_detected": true}, {"x": 0.56, "y": -8.73, "player_id": 50951, "is_detected": true}, {"x": 2.35, "y": 9.7, "player_id": 38673, "is_detected": true}]} +{"frame": 17, "timestamp": "00:00:00.70", "period": 1, "ball_data": {"x": 0.66, "y": 0.05, "z": 0.14, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.62, "y_top_left": 39.0, "x_bottom_left": -22.93, "y_bottom_left": -36.58, "x_bottom_right": 22.37, "y_bottom_right": -36.37, "x_top_right": 49.81, "y_top_right": 39.0}, "player_data": [{"x": -40.71, "y": -0.31, "player_id": 51009, "is_detected": false}, {"x": -19.28, "y": -9.47, "player_id": 176224, "is_detected": true}, {"x": -21.74, "y": 0.27, "player_id": 51649, "is_detected": true}, {"x": -1.08, "y": -32.98, "player_id": 50983, "is_detected": true}, {"x": -19.48, "y": 15.72, "player_id": 735578, "is_detected": true}, {"x": -7.09, "y": 7.09, "player_id": 50978, "is_detected": true}, {"x": -9.44, "y": -5.31, "player_id": 735574, "is_detected": true}, {"x": -1.35, "y": 7.73, "player_id": 795507, "is_detected": false}, {"x": -1.11, "y": -20.56, "player_id": 795505, "is_detected": true}, {"x": -1.62, "y": 18.76, "player_id": 735573, "is_detected": true}, {"x": 1.11, "y": 0.24, "player_id": 966120, "is_detected": true}, {"x": 41.45, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 18.17, "y": 5.04, "player_id": 51667, "is_detected": true}, {"x": 16.95, "y": -3.73, "player_id": 33697, "is_detected": true}, {"x": 17.19, "y": 14.29, "player_id": 51713, "is_detected": true}, {"x": 17.64, "y": -13.78, "player_id": 133498, "is_detected": true}, {"x": 10.89, "y": 6.56, "player_id": 14736, "is_detected": true}, {"x": 9.89, "y": -2.6, "player_id": 23418, "is_detected": true}, {"x": 1.3, "y": 18.26, "player_id": 133501, "is_detected": false}, {"x": 7.85, "y": -16.74, "player_id": 965685, "is_detected": true}, {"x": 0.56, "y": -8.75, "player_id": 50951, "is_detected": true}, {"x": 2.3, "y": 9.65, "player_id": 38673, "is_detected": true}]} +{"frame": 18, "timestamp": "00:00:00.80", "period": 1, "ball_data": {"x": 0.67, "y": 0.06, "z": 0.15, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.57, "y_top_left": 39.0, "x_bottom_left": -22.89, "y_bottom_left": -36.59, "x_bottom_right": 22.3, "y_bottom_right": -36.39, "x_top_right": 49.71, "y_top_right": 39.0}, "player_data": [{"x": -40.72, "y": -0.29, "player_id": 51009, "is_detected": false}, {"x": -19.27, "y": -9.5, "player_id": 176224, "is_detected": true}, {"x": -21.72, "y": 0.27, "player_id": 51649, "is_detected": true}, {"x": -1.08, "y": -32.99, "player_id": 50983, "is_detected": true}, {"x": -19.57, "y": 15.7, "player_id": 735578, "is_detected": true}, {"x": -7.05, "y": 7.04, "player_id": 50978, "is_detected": true}, {"x": -9.47, "y": -5.3, "player_id": 735574, "is_detected": true}, {"x": -1.3, "y": 7.83, "player_id": 795507, "is_detected": false}, {"x": -1.14, "y": -20.54, "player_id": 795505, "is_detected": true}, {"x": -1.57, "y": 18.81, "player_id": 735573, "is_detected": true}, {"x": 1.11, "y": 0.24, "player_id": 966120, "is_detected": true}, {"x": 41.49, "y": 0.25, "player_id": 285188, "is_detected": false}, {"x": 18.17, "y": 5.02, "player_id": 51667, "is_detected": true}, {"x": 16.94, "y": -3.7, "player_id": 33697, "is_detected": true}, {"x": 17.15, "y": 14.27, "player_id": 51713, "is_detected": true}, {"x": 17.64, "y": -13.78, "player_id": 133498, "is_detected": true}, {"x": 10.77, "y": 6.58, "player_id": 14736, "is_detected": true}, {"x": 9.84, "y": -2.61, "player_id": 23418, "is_detected": true}, {"x": 1.35, "y": 18.22, "player_id": 133501, "is_detected": false}, {"x": 7.81, "y": -16.77, "player_id": 965685, "is_detected": true}, {"x": 0.55, "y": -8.74, "player_id": 50951, "is_detected": true}, {"x": 2.26, "y": 9.61, "player_id": 38673, "is_detected": true}]} +{"frame": 19, "timestamp": "00:00:00.90", "period": 1, "ball_data": {"x": 0.69, "y": 0.01, "z": 0.15, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.48, "y_top_left": 39.0, "x_bottom_left": -22.86, "y_bottom_left": -36.57, "x_bottom_right": 22.32, "y_bottom_right": -36.37, "x_top_right": 49.74, "y_top_right": 39.0}, "player_data": [{"x": -40.73, "y": -0.27, "player_id": 51009, "is_detected": false}, {"x": -19.27, "y": -9.53, "player_id": 176224, "is_detected": true}, {"x": -21.71, "y": 0.25, "player_id": 51649, "is_detected": true}, {"x": -1.09, "y": -33.03, "player_id": 50983, "is_detected": true}, {"x": -19.64, "y": 15.69, "player_id": 735578, "is_detected": true}, {"x": -7.0, "y": 7.0, "player_id": 50978, "is_detected": true}, {"x": -9.5, "y": -5.28, "player_id": 735574, "is_detected": true}, {"x": -1.25, "y": 7.93, "player_id": 795507, "is_detected": false}, {"x": -1.16, "y": -20.55, "player_id": 795505, "is_detected": true}, {"x": -1.52, "y": 18.84, "player_id": 735573, "is_detected": true}, {"x": 1.1, "y": 0.23, "player_id": 966120, "is_detected": true}, {"x": 41.53, "y": 0.25, "player_id": 285188, "is_detected": false}, {"x": 18.17, "y": 5.01, "player_id": 51667, "is_detected": true}, {"x": 16.96, "y": -3.66, "player_id": 33697, "is_detected": true}, {"x": 17.11, "y": 14.25, "player_id": 51713, "is_detected": true}, {"x": 17.64, "y": -13.8, "player_id": 133498, "is_detected": true}, {"x": 10.67, "y": 6.6, "player_id": 14736, "is_detected": true}, {"x": 9.79, "y": -2.61, "player_id": 23418, "is_detected": true}, {"x": 1.37, "y": 18.16, "player_id": 133501, "is_detected": false}, {"x": 7.79, "y": -16.8, "player_id": 965685, "is_detected": true}, {"x": 0.55, "y": -8.73, "player_id": 50951, "is_detected": true}, {"x": 2.22, "y": 9.56, "player_id": 38673, "is_detected": true}]} +{"frame": 20, "timestamp": "00:00:01.00", "period": 1, "ball_data": {"x": 0.69, "y": -0.05, "z": 0.16, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.46, "y_top_left": 39.0, "x_bottom_left": -22.87, "y_bottom_left": -36.57, "x_bottom_right": 22.29, "y_bottom_right": -36.35, "x_top_right": 49.59, "y_top_right": 39.0}, "player_data": [{"x": -40.74, "y": -0.26, "player_id": 51009, "is_detected": false}, {"x": -19.27, "y": -9.57, "player_id": 176224, "is_detected": true}, {"x": -21.7, "y": 0.25, "player_id": 51649, "is_detected": true}, {"x": -1.1, "y": -33.05, "player_id": 50983, "is_detected": true}, {"x": -19.69, "y": 15.69, "player_id": 735578, "is_detected": true}, {"x": -6.94, "y": 6.96, "player_id": 50978, "is_detected": true}, {"x": -9.52, "y": -5.26, "player_id": 735574, "is_detected": true}, {"x": -1.17, "y": 8.01, "player_id": 795507, "is_detected": false}, {"x": -1.18, "y": -20.55, "player_id": 795505, "is_detected": true}, {"x": -1.47, "y": 18.87, "player_id": 735573, "is_detected": true}, {"x": 1.09, "y": 0.24, "player_id": 966120, "is_detected": true}, {"x": 41.58, "y": 0.24, "player_id": 285188, "is_detected": false}, {"x": 18.17, "y": 4.99, "player_id": 51667, "is_detected": true}, {"x": 16.98, "y": -3.63, "player_id": 33697, "is_detected": true}, {"x": 17.06, "y": 14.23, "player_id": 51713, "is_detected": true}, {"x": 17.62, "y": -13.83, "player_id": 133498, "is_detected": true}, {"x": 10.57, "y": 6.59, "player_id": 14736, "is_detected": true}, {"x": 9.74, "y": -2.61, "player_id": 23418, "is_detected": true}, {"x": 1.39, "y": 18.08, "player_id": 133501, "is_detected": false}, {"x": 7.75, "y": -16.82, "player_id": 965685, "is_detected": true}, {"x": 0.54, "y": -8.71, "player_id": 50951, "is_detected": true}, {"x": 2.17, "y": 9.5, "player_id": 38673, "is_detected": true}]} +{"frame": 21, "timestamp": "00:00:01.10", "period": 1, "ball_data": {"x": 0.67, "y": -0.08, "z": 0.16, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.33, "y_top_left": 39.0, "x_bottom_left": -22.86, "y_bottom_left": -36.49, "x_bottom_right": 22.15, "y_bottom_right": -36.31, "x_top_right": 49.26, "y_top_right": 39.0}, "player_data": [{"x": -40.74, "y": -0.26, "player_id": 51009, "is_detected": false}, {"x": -19.27, "y": -9.6, "player_id": 176224, "is_detected": true}, {"x": -21.68, "y": 0.24, "player_id": 51649, "is_detected": true}, {"x": -1.08, "y": -33.06, "player_id": 50983, "is_detected": true}, {"x": -19.73, "y": 15.69, "player_id": 735578, "is_detected": true}, {"x": -6.87, "y": 6.93, "player_id": 50978, "is_detected": true}, {"x": -9.54, "y": -5.23, "player_id": 735574, "is_detected": true}, {"x": -1.1, "y": 8.06, "player_id": 795507, "is_detected": false}, {"x": -1.2, "y": -20.55, "player_id": 795505, "is_detected": true}, {"x": -1.41, "y": 18.88, "player_id": 735573, "is_detected": true}, {"x": 1.08, "y": 0.26, "player_id": 966120, "is_detected": true}, {"x": 41.62, "y": 0.23, "player_id": 285188, "is_detected": false}, {"x": 18.16, "y": 4.97, "player_id": 51667, "is_detected": true}, {"x": 17.02, "y": -3.61, "player_id": 33697, "is_detected": true}, {"x": 17.0, "y": 14.2, "player_id": 51713, "is_detected": true}, {"x": 17.61, "y": -13.85, "player_id": 133498, "is_detected": true}, {"x": 10.47, "y": 6.58, "player_id": 14736, "is_detected": true}, {"x": 9.69, "y": -2.61, "player_id": 23418, "is_detected": true}, {"x": 1.4, "y": 17.98, "player_id": 133501, "is_detected": false}, {"x": 7.72, "y": -16.84, "player_id": 965685, "is_detected": true}, {"x": 0.52, "y": -8.67, "player_id": 50951, "is_detected": true}, {"x": 2.12, "y": 9.43, "player_id": 38673, "is_detected": true}]} +{"frame": 22, "timestamp": "00:00:01.20", "period": 1, "ball_data": {"x": 0.63, "y": -0.06, "z": 0.18, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.28, "y_top_left": 39.0, "x_bottom_left": -22.83, "y_bottom_left": -36.57, "x_bottom_right": 22.2, "y_bottom_right": -36.37, "x_top_right": 49.34, "y_top_right": 39.0}, "player_data": [{"x": -40.74, "y": -0.27, "player_id": 51009, "is_detected": false}, {"x": -19.28, "y": -9.63, "player_id": 176224, "is_detected": true}, {"x": -21.66, "y": 0.24, "player_id": 51649, "is_detected": true}, {"x": -1.04, "y": -33.08, "player_id": 50983, "is_detected": true}, {"x": -19.79, "y": 15.69, "player_id": 735578, "is_detected": true}, {"x": -6.81, "y": 6.91, "player_id": 50978, "is_detected": true}, {"x": -9.55, "y": -5.2, "player_id": 735574, "is_detected": true}, {"x": -1.0, "y": 8.11, "player_id": 795507, "is_detected": false}, {"x": -1.22, "y": -20.55, "player_id": 795505, "is_detected": true}, {"x": -1.37, "y": 18.88, "player_id": 735573, "is_detected": true}, {"x": 1.05, "y": 0.3, "player_id": 966120, "is_detected": true}, {"x": 41.66, "y": 0.21, "player_id": 285188, "is_detected": false}, {"x": 18.15, "y": 4.95, "player_id": 51667, "is_detected": true}, {"x": 17.06, "y": -3.59, "player_id": 33697, "is_detected": true}, {"x": 16.95, "y": 14.18, "player_id": 51713, "is_detected": true}, {"x": 17.6, "y": -13.88, "player_id": 133498, "is_detected": true}, {"x": 10.36, "y": 6.57, "player_id": 14736, "is_detected": true}, {"x": 9.63, "y": -2.62, "player_id": 23418, "is_detected": true}, {"x": 1.41, "y": 17.88, "player_id": 133501, "is_detected": false}, {"x": 7.7, "y": -16.88, "player_id": 965685, "is_detected": true}, {"x": 0.5, "y": -8.62, "player_id": 50951, "is_detected": true}, {"x": 2.07, "y": 9.38, "player_id": 38673, "is_detected": true}]} +{"frame": 23, "timestamp": "00:00:01.30", "period": 1, "ball_data": {"x": 0.57, "y": 0.03, "z": 0.22, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.24, "y_top_left": 39.0, "x_bottom_left": -22.77, "y_bottom_left": -36.53, "x_bottom_right": 22.14, "y_bottom_right": -36.24, "x_top_right": 49.17, "y_top_right": 39.0}, "player_data": [{"x": -40.75, "y": -0.27, "player_id": 51009, "is_detected": false}, {"x": -19.28, "y": -9.64, "player_id": 176224, "is_detected": true}, {"x": -21.65, "y": 0.24, "player_id": 51649, "is_detected": true}, {"x": -1.0, "y": -33.09, "player_id": 50983, "is_detected": true}, {"x": -19.84, "y": 15.7, "player_id": 735578, "is_detected": true}, {"x": -6.76, "y": 6.9, "player_id": 50978, "is_detected": true}, {"x": -9.56, "y": -5.17, "player_id": 735574, "is_detected": true}, {"x": -0.88, "y": 8.14, "player_id": 795507, "is_detected": false}, {"x": -1.24, "y": -20.55, "player_id": 795505, "is_detected": true}, {"x": -1.33, "y": 18.87, "player_id": 735573, "is_detected": true}, {"x": 1.01, "y": 0.33, "player_id": 966120, "is_detected": true}, {"x": 41.7, "y": 0.19, "player_id": 285188, "is_detected": false}, {"x": 18.13, "y": 4.93, "player_id": 51667, "is_detected": true}, {"x": 17.11, "y": -3.57, "player_id": 33697, "is_detected": true}, {"x": 16.89, "y": 14.15, "player_id": 51713, "is_detected": true}, {"x": 17.59, "y": -13.92, "player_id": 133498, "is_detected": true}, {"x": 10.26, "y": 6.55, "player_id": 14736, "is_detected": true}, {"x": 9.56, "y": -2.64, "player_id": 23418, "is_detected": true}, {"x": 1.39, "y": 17.77, "player_id": 133501, "is_detected": true}, {"x": 7.68, "y": -16.92, "player_id": 965685, "is_detected": true}, {"x": 0.47, "y": -8.57, "player_id": 50951, "is_detected": true}, {"x": 2.03, "y": 9.33, "player_id": 38673, "is_detected": true}]} +{"frame": 24, "timestamp": "00:00:01.40", "period": 1, "ball_data": {"x": 0.53, "y": 0.01, "z": 0.28, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.09, "y_top_left": 39.0, "x_bottom_left": -22.75, "y_bottom_left": -36.47, "x_bottom_right": 22.1, "y_bottom_right": -36.28, "x_top_right": 49.11, "y_top_right": 39.0}, "player_data": [{"x": -40.75, "y": -0.28, "player_id": 51009, "is_detected": false}, {"x": -19.29, "y": -9.66, "player_id": 176224, "is_detected": true}, {"x": -21.65, "y": 0.23, "player_id": 51649, "is_detected": true}, {"x": -0.94, "y": -33.1, "player_id": 50983, "is_detected": true}, {"x": -19.89, "y": 15.71, "player_id": 735578, "is_detected": true}, {"x": -6.71, "y": 6.89, "player_id": 50978, "is_detected": true}, {"x": -9.57, "y": -5.14, "player_id": 735574, "is_detected": true}, {"x": -0.76, "y": 8.16, "player_id": 795507, "is_detected": true}, {"x": -1.24, "y": -20.54, "player_id": 795505, "is_detected": true}, {"x": -1.29, "y": 18.85, "player_id": 735573, "is_detected": true}, {"x": 0.96, "y": 0.37, "player_id": 966120, "is_detected": true}, {"x": 41.75, "y": 0.18, "player_id": 285188, "is_detected": false}, {"x": 18.1, "y": 4.91, "player_id": 51667, "is_detected": true}, {"x": 17.16, "y": -3.56, "player_id": 33697, "is_detected": true}, {"x": 16.83, "y": 14.13, "player_id": 51713, "is_detected": true}, {"x": 17.57, "y": -13.96, "player_id": 133498, "is_detected": true}, {"x": 10.16, "y": 6.53, "player_id": 14736, "is_detected": true}, {"x": 9.48, "y": -2.65, "player_id": 23418, "is_detected": true}, {"x": 1.35, "y": 17.66, "player_id": 133501, "is_detected": true}, {"x": 7.66, "y": -16.97, "player_id": 965685, "is_detected": true}, {"x": 0.41, "y": -8.5, "player_id": 50951, "is_detected": true}, {"x": 1.98, "y": 9.28, "player_id": 38673, "is_detected": true}]} +{"frame": 25, "timestamp": "00:00:01.50", "period": 1, "ball_data": {"x": 0.53, "y": -0.17, "z": 0.31, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -51.18, "y_top_left": 39.0, "x_bottom_left": -22.76, "y_bottom_left": -36.51, "x_bottom_right": 22.12, "y_bottom_right": -36.32, "x_top_right": 49.21, "y_top_right": 39.0}, "player_data": [{"x": -40.75, "y": -0.29, "player_id": 51009, "is_detected": false}, {"x": -19.29, "y": -9.66, "player_id": 176224, "is_detected": true}, {"x": -21.65, "y": 0.23, "player_id": 51649, "is_detected": true}, {"x": -0.88, "y": -33.09, "player_id": 50983, "is_detected": true}, {"x": -19.94, "y": 15.72, "player_id": 735578, "is_detected": true}, {"x": -6.66, "y": 6.9, "player_id": 50978, "is_detected": true}, {"x": -9.57, "y": -5.11, "player_id": 735574, "is_detected": true}, {"x": -0.61, "y": 8.16, "player_id": 795507, "is_detected": true}, {"x": -1.23, "y": -20.53, "player_id": 795505, "is_detected": true}, {"x": -1.25, "y": 18.82, "player_id": 735573, "is_detected": true}, {"x": 0.91, "y": 0.41, "player_id": 966120, "is_detected": true}, {"x": 41.8, "y": 0.16, "player_id": 285188, "is_detected": false}, {"x": 18.07, "y": 4.89, "player_id": 51667, "is_detected": true}, {"x": 17.23, "y": -3.56, "player_id": 33697, "is_detected": true}, {"x": 16.76, "y": 14.11, "player_id": 51713, "is_detected": true}, {"x": 17.56, "y": -14.01, "player_id": 133498, "is_detected": true}, {"x": 10.06, "y": 6.51, "player_id": 14736, "is_detected": true}, {"x": 9.39, "y": -2.68, "player_id": 23418, "is_detected": true}, {"x": 1.3, "y": 17.53, "player_id": 133501, "is_detected": true}, {"x": 7.65, "y": -17.02, "player_id": 965685, "is_detected": true}, {"x": 0.34, "y": -8.43, "player_id": 50951, "is_detected": true}, {"x": 1.93, "y": 9.25, "player_id": 38673, "is_detected": true}]} +{"frame": 26, "timestamp": "00:00:01.60", "period": 1, "ball_data": {"x": 0.57, "y": -0.34, "z": 0.29, "is_detected": true}, "possession": {"player_id": null, "group": null}, "image_corners_projection": {"x_top_left": -50.99, "y_top_left": 39.0, "x_bottom_left": -22.7, "y_bottom_left": -36.46, "x_bottom_right": 22.03, "y_bottom_right": -36.28, "x_top_right": 48.99, "y_top_right": 39.0}, "player_data": [{"x": -40.74, "y": -0.29, "player_id": 51009, "is_detected": false}, {"x": -19.3, "y": -9.66, "player_id": 176224, "is_detected": true}, {"x": -21.66, "y": 0.22, "player_id": 51649, "is_detected": true}, {"x": -0.81, "y": -33.08, "player_id": 50983, "is_detected": true}, {"x": -20.0, "y": 15.73, "player_id": 735578, "is_detected": true}, {"x": -6.61, "y": 6.91, "player_id": 50978, "is_detected": true}, {"x": -9.58, "y": -5.08, "player_id": 735574, "is_detected": true}, {"x": -0.46, "y": 8.16, "player_id": 795507, "is_detected": true}, {"x": -1.2, "y": -20.52, "player_id": 795505, "is_detected": true}, {"x": -1.2, "y": 18.79, "player_id": 735573, "is_detected": true}, {"x": 0.84, "y": 0.45, "player_id": 966120, "is_detected": true}, {"x": 41.85, "y": 0.16, "player_id": 285188, "is_detected": false}, {"x": 18.03, "y": 4.86, "player_id": 51667, "is_detected": true}, {"x": 17.29, "y": -3.55, "player_id": 33697, "is_detected": true}, {"x": 16.69, "y": 14.1, "player_id": 51713, "is_detected": true}, {"x": 17.54, "y": -14.06, "player_id": 133498, "is_detected": true}, {"x": 9.95, "y": 6.49, "player_id": 14736, "is_detected": true}, {"x": 9.28, "y": -2.71, "player_id": 23418, "is_detected": true}, {"x": 1.24, "y": 17.41, "player_id": 133501, "is_detected": true}, {"x": 7.64, "y": -17.08, "player_id": 965685, "is_detected": true}, {"x": 0.24, "y": -8.34, "player_id": 50951, "is_detected": true}, {"x": 1.88, "y": 9.23, "player_id": 38673, "is_detected": true}]} \ No newline at end of file diff --git a/kloppy/tests/test_cdf.py b/kloppy/tests/test_cdf.py new file mode 100644 index 000000000..ab1ad884b --- /dev/null +++ b/kloppy/tests/test_cdf.py @@ -0,0 +1,295 @@ +import tempfile +from pathlib import Path + +import pytest + +from kloppy import sportec, skillcorner +from kloppy.domain import TrackingDataset, PositionType +from kloppy.infra.serializers.tracking.cdf.serializer import ( + CDFTrackingDataSerializer, + CDFOutputs, +) +from kloppy.infra.serializers.tracking.cdf.helpers import ( + is_valid_cdf_position_code, +) + + +def mimimum_valid_cdf_output( + dataset, meta_data_validator, tracking_data_validator, tmp_path +): + """Test that CDFTrackingDataSerializer produces valid CDF output.""" + meta_path = tmp_path / "metadata.json" + tracking_path = tmp_path / "tracking.jsonl" + + with pytest.warns( + UserWarning, + ): + dataset.to_cdf( + metadata_output_file=str(meta_path), + tracking_output_file=str(tracking_path), + additional_metadata={}, + ) + + dataset.to_cdf( + metadata_output_file=str(meta_path), + tracking_output_file=str(tracking_path), + additional_metadata={ + "competition": dict( + id="61", + ), + "season": dict( + id="95", + ), + "stadium": dict( + id="2914", + ), + "meta": dict( + tracking=dict(version="v3", collection_timing="post_match") + ), + }, + ) + + meta_data_validator.validate_schema(sample=meta_path) + tracking_data_validator.validate_schema(sample=tracking_path, limit=None) + + +def produces_valid_cdf_output_with_additional_metadata( + dataset, meta_data_validator, tracking_data_validator, tmp_path +): + """Test that CDFTrackingDataSerializer produces valid CDF output with additional metadata.""" + + from cdf.domain import ( + CdfMetaDataSchema, + Stadium, + Competition, + Season, + Meta, + Tracking, + ) + + # Define additional metadata + additional_meta_data = CdfMetaDataSchema( + competition=Competition( + id="61", name="A-League", type="mens", format="league" + ), + season=Season(id="95", name="2024/2025"), + stadium=Stadium( + id="2914", + name="Kayo Stadium", + ), + meta=Meta( + tracking=Tracking( + version="v3", + collection_timing="post_match", + ) + ), + ) + + meta_path = tmp_path / "metadata.json" + tracking_path = tmp_path / "tracking.jsonl" + + dataset.to_cdf( + metadata_output_file=str(meta_path), + tracking_output_file=str(tracking_path), + additional_metadata=additional_meta_data, + ) + + meta_data_validator.validate_schema(sample=meta_path) + tracking_data_validator.validate_schema(sample=tracking_path, limit=None) + + +def serializer_handles_invalid_metadata_types(dataset): + """Test that CDFTrackingDataSerializer handles invalid metadata types gracefully.""" + import cdf + + serializer = CDFTrackingDataSerializer() + + with tempfile.NamedTemporaryFile( + mode="w+b", suffix=".json", delete=False + ) as meta_file, tempfile.NamedTemporaryFile( + mode="w+b", suffix=".jsonl", delete=False + ) as tracking_file: + + meta_path = meta_file.name + tracking_path = tracking_file.name + + outputs = CDFOutputs(meta_data=meta_file, tracking_data=tracking_file) + + # Test with invalid metadata types - should still serialize but may fail validation + invalid_metadata = { + "competition": { + "id": 123, # Should be string + }, + "season": { + "id": ["2024"], # Should be string, not list + }, + "stadium": { + "id": None, # Should be string + "pitch_length": "one hundred five", # Should be float/int + }, + "meta": { + "tracking": { + "fps": "25", # Should be int + "version": 1.0, # Should be string, + "collection_timing": "Nothing", + } + }, + } + + # Serialization should succeed (no type checking in serializer) + success = serializer.serialize( + dataset, outputs, additional_metadata=invalid_metadata + ) + assert success is True + + # The file should be created but validation should fail + meta_validator = cdf.MetaSchemaValidator( + schema=f"cdf/files/v{cdf.VERSION}/schema/meta.json" + ) + + # Validation should fail due to type mismatches + with pytest.raises(Exception): # Could be ValidationError or similar + meta_validator.validate_schema(sample=meta_path) + + # Clean up + Path(meta_path).unlink() + Path(tracking_path).unlink() + + +class TestCDFSerializer: + @pytest.fixture + def raw_data(self, base_dir) -> Path: + return base_dir / "files/sportec_positional.xml" + + @pytest.fixture + def meta_data(self, base_dir) -> Path: + return base_dir / "files/sportec_meta.xml" + + @pytest.fixture + def meta_data_v3(self, base_dir) -> str: + return base_dir / "files/skillcorner_v3_meta_data-2.json" + + @pytest.fixture + def raw_data_v3(self, base_dir) -> str: + return base_dir / "files/skillcorner_v3_raw_data-2.jsonl" + + @pytest.fixture + def dataset_sportec( + self, raw_data: Path, meta_data: Path + ) -> TrackingDataset: + """Load a small Sportec tracking data snippet for testing CDF serialization.""" + return sportec.load_tracking( + raw_data=raw_data, + meta_data=meta_data, + coordinates="sportec", + limit=None, + only_alive=False, + ) + + @pytest.fixture + def dataset_skillcorner(self, raw_data_v3: Path, meta_data_v3: Path): + return skillcorner.load( + meta_data=meta_data_v3, + raw_data=raw_data_v3, + coordinates="skillcorner", + include_empty_frames=True, + only_alive=False, + ) + + @pytest.fixture + def meta_data_validator(self): + import cdf + + # Instantiate Validators + return cdf.MetaSchemaValidator( + schema=f"cdf/files/v{cdf.VERSION}/schema/meta.json" + ) + + @pytest.fixture + def tracking_data_validator(self): + import cdf + + # Instantiate Validators + return cdf.TrackingSchemaValidator( + schema=f"cdf/files/v{cdf.VERSION}/schema/tracking.json" + ) + + def test_produces_valid_cdf_output( + self, + dataset_sportec, + dataset_skillcorner, + tracking_data_validator, + meta_data_validator, + tmp_path, + ): + mimimum_valid_cdf_output( + dataset_sportec, + meta_data_validator, + tracking_data_validator, + tmp_path, + ) + mimimum_valid_cdf_output( + dataset_skillcorner, + meta_data_validator, + tracking_data_validator, + tmp_path, + ) + + def test_produces_valid_cdf_output_with_additional_metadata( + self, + dataset_skillcorner, + dataset_sportec, + tracking_data_validator, + meta_data_validator, + tmp_path, + ): + produces_valid_cdf_output_with_additional_metadata( + dataset_skillcorner, + meta_data_validator, + tracking_data_validator, + tmp_path, + ) + produces_valid_cdf_output_with_additional_metadata( + dataset_sportec, + meta_data_validator, + tracking_data_validator, + tmp_path, + ) + + def test_serializer_handles_invalid_metadata_types( + self, dataset_skillcorner, dataset_sportec + ): + serializer_handles_invalid_metadata_types(dataset=dataset_skillcorner) + serializer_handles_invalid_metadata_types(dataset=dataset_sportec) + + def test_cdf_positions(self): + """ + Make sure we have not introduced any non-cdf supported positions to kloppy PositionType. + If we did, update map_position_type_code_to_cdf + """ + + test_list = [] + + for position in PositionType: + if is_valid_cdf_position_code(position.code): + pass + else: + test_list.append(position.code) + + assert set(test_list) == set( + [ + "UNK", + "DEF", + "FB", + "LWB", + "RWB", + "MID", + "DM", + "AM", + "WM", + "ATT", + "LF", + "ST", + "RF", + ] + ) diff --git a/kloppy/tests/test_write_support.py b/kloppy/tests/test_write_support.py new file mode 100644 index 000000000..f625f9575 --- /dev/null +++ b/kloppy/tests/test_write_support.py @@ -0,0 +1,147 @@ +import bz2 +import gzip +import lzma +from io import BytesIO +from pathlib import Path +from typing import BinaryIO, List + +import pytest + +from kloppy.io import open_as_file +from kloppy.infra.io.buffered_stream import BufferedStream +from kloppy.infra.io.adapters import Adapter + + +class TestBufferedStream: + """Tests for BufferedStream chunked copying.""" + + def test_from_stream_small_data(self): + """It should copy small data in chunks and keep in memory.""" + source = BytesIO(b"Small data content") + buffer = BufferedStream.from_stream(source, chunk_size=8) + + assert buffer.read() == b"Small data content" + assert buffer._rolled is False # Still in memory + + def test_from_stream_large_data(self): + """It should spill large data to disk.""" + buffer_size = 5 * 1024 * 1024 # 5MB + large_data = b"x" * (buffer_size + 1000) + source = BytesIO(large_data) + buffer = BufferedStream.from_stream(source, max_size=buffer_size) + + assert buffer._rolled is True # Spilled to disk + assert buffer.read() == large_data + + +class MockWriteAdapter(Adapter): + """Mock adapter for testing write support.""" + + def __init__(self): + self.written_data = {} + + def supports(self, url: str) -> bool: + return url.startswith("mock://") + + def is_directory(self, url: str) -> bool: + return False + + def is_file(self, url: str) -> bool: + return url in self.written_data + + def read_to_stream(self, url: str, output: BinaryIO): + if url in self.written_data: + output.write(self.written_data[url]) + else: + raise FileNotFoundError(f"Mock file not found: {url}") + + def write_from_stream(self, url: str, input: BinaryIO, mode: str): + """Write data from input stream to mock storage.""" + input.seek(0) + self.written_data[url] = input.read() + + def list_directory(self, url: str, recursive: bool = True) -> List[str]: + return [] + + +class TestOpenAsFileWrite: + """Tests for write support in open_as_file.""" + + def test_write_local_file(self, tmp_path: Path): + """It should be able to write to a local file.""" + output_path = tmp_path / "output.txt" + with open_as_file(output_path, mode="wb") as fp: + assert fp is not None + fp.write(b"Hello, write!") + + assert output_path.read_bytes() == b"Hello, write!" + + def test_write_compressed_gz(self, tmp_path: Path): + """It should be able to write compressed gzip files.""" + output_path = tmp_path / "output.txt.gz" + with open_as_file(output_path, mode="wb") as fp: + assert fp is not None + fp.write(b"Compressed content") + + # Verify by reading back + with gzip.open(output_path, "rb") as f: + assert f.read() == b"Compressed content" + + def test_write_compressed_bz2(self, tmp_path: Path): + """It should be able to write compressed bz2 files.""" + output_path = tmp_path / "output.txt.bz2" + with open_as_file(output_path, mode="wb") as fp: + assert fp is not None + fp.write(b"BZ2 content") + + with bz2.open(output_path, "rb") as f: + assert f.read() == b"BZ2 content" + + def test_write_compressed_xz(self, tmp_path: Path): + """It should be able to write compressed xz files.""" + output_path = tmp_path / "output.txt.xz" + with open_as_file(output_path, mode="wb") as fp: + assert fp is not None + fp.write(b"XZ content") + + with lzma.open(output_path, "rb") as f: + assert f.read() == b"XZ content" + + def test_write_bytesio(self): + """It should be able to write to BytesIO.""" + buffer = BytesIO() + with open_as_file(buffer, mode="wb") as fp: + assert fp is not None + fp.write(b"In-memory write") + + buffer.seek(0) + assert buffer.read() == b"In-memory write" + + +class TestAdapterWrite: + """Tests for adapter write support.""" + + def test_write_via_adapter(self, monkeypatch): + """It should use adapter's write_from_stream for remote writes.""" + from kloppy.infra.io import adapters + + mock_adapter = MockWriteAdapter() + # Inject our mock adapter + original_adapters = adapters.adapters + monkeypatch.setattr( + adapters, "adapters", [mock_adapter] + original_adapters + ) + + # Write via adapter + with open_as_file("mock://test/file.txt", mode="wb") as fp: + fp.write(b"Adapter write test") + + # Verify data was written to mock storage + assert ( + mock_adapter.written_data["mock://test/file.txt"] + == b"Adapter write test" + ) + + # Verify we can read it back + with open_as_file("mock://test/file.txt") as fp: + assert fp.read() == b"Adapter write test" diff --git a/kloppy/tests/test_xml.py b/kloppy/tests/test_xml.py index bd663285e..4e745f235 100644 --- a/kloppy/tests/test_xml.py +++ b/kloppy/tests/test_xml.py @@ -1,11 +1,15 @@ from datetime import timedelta +from io import BytesIO from pandas import DataFrame from pandas._testing import assert_frame_equal from kloppy import sportscode from kloppy.domain import Period -from kloppy.infra.serializers.code.sportscode import SportsCodeSerializer +from kloppy.infra.serializers.code.sportscode import ( + SportsCodeSerializer, + SportsCodeOutputs, +) class TestXMLCodeTracking: @@ -80,7 +84,10 @@ def test_correct_serialization(self, base_dir): dataset.codes[1].period = dataset.metadata.periods[1] serializer = SportsCodeSerializer() - output = serializer.serialize(dataset) + with BytesIO() as buffer: + serializer.serialize(dataset, SportsCodeOutputs(data=buffer)) + buffer.seek(0) + output = buffer.read() expected_output = """