Skip to content

Layouts

fast-forward supports three DataFrame layouts that determine how tracking data is structured. Choose based on your analysis needs.

Long Layout (default)

layout="long"

Each row represents one object (player or ball) in one frame. The ball is included as a row with team_id="ball" and player_id="ball".

┌──────────┬───────────┬───────────┬─────────────┬─────────┬────────────┬──────┬──────┬──────┐
│ frame_id ┆ period_id ┆ timestamp ┆ ball_state  ┆ team_id ┆ player_id  ┆ x    ┆ y    ┆ z    │
│ u32      ┆ i32       ┆ dur[ms]   ┆ str         ┆ str     ┆ str        ┆ f32  ┆ f32  ┆ f32  │
╞══════════╪═══════════╪═══════════╪═════════════╪═════════╪════════════╪══════╪══════╪══════╡
│ 1        ┆ 1         ┆ 0ms       ┆ alive       ┆ ball    ┆ ball       ┆ 0.5  ┆ 0.0  ┆ 0.1  │
│ 1        ┆ 1         ┆ 0ms       ┆ alive       ┆ T01     ┆ P001       ┆ -20  ┆ 5.0  ┆ 0.0  │
│ 1        ┆ 1         ┆ 0ms       ┆ alive       ┆ T01     ┆ P002       ┆ -15  ┆ -3.0 ┆ 0.0  │
│ 1        ┆ 1         ┆ 0ms       ┆ alive       ┆ T02     ┆ P011       ┆ 10   ┆ 2.0  ┆ 0.0  │
│ 2        ┆ 1         ┆ 40ms      ┆ alive       ┆ ball    ┆ ball       ┆ 1.2  ┆ 0.3  ┆ 0.2  │
│ ...      ┆ ...       ┆ ...       ┆ ...         ┆ ...     ┆ ...        ┆ ...  ┆ ...  ┆ ...  │
└──────────┴───────────┴───────────┴─────────────┴─────────┴────────────┴──────┴──────┴──────┘

Best for: Most analysis workflows. Easy to filter by player, team, or frame. Group-by operations are straightforward.

import polars as pl

# Average position per player
dataset.tracking.filter(
    pl.col("team_id") != "ball"
).group_by("player_id").agg(
    pl.col("x").mean(),
    pl.col("y").mean(),
)

Long Ball Layout

layout="long_ball"

Each row represents one player in one frame. Ball coordinates are in separate columns on every row (not as a separate row). No ball rows exist.

┌──────────┬───────────┬───────────┬────────┬────────┬────────┬─────────┬────────────┬──────┬──────┬──────┐
│ frame_id ┆ period_id ┆ timestamp ┆ ball_x ┆ ball_y ┆ ball_z ┆ team_id ┆ player_id  ┆ x    ┆ y    ┆ z    │
│ u32      ┆ i32       ┆ dur[ms]   ┆ f32    ┆ f32    ┆ f32    ┆ str     ┆ str        ┆ f32  ┆ f32  ┆ f32  │
╞══════════╪═══════════╪═══════════╪════════╪════════╪════════╪═════════╪════════════╪══════╪══════╪══════╡
│ 1        ┆ 1         ┆ 0ms       ┆ 0.5    ┆ 0.0    ┆ 0.1    ┆ T01     ┆ P001       ┆ -20  ┆ 5.0  ┆ 0.0  │
│ 1        ┆ 1         ┆ 0ms       ┆ 0.5    ┆ 0.0    ┆ 0.1    ┆ T01     ┆ P002       ┆ -15  ┆ -3.0 ┆ 0.0  │
│ 1        ┆ 1         ┆ 0ms       ┆ 0.5    ┆ 0.0    ┆ 0.1    ┆ T02     ┆ P011       ┆ 10   ┆ 2.0  ┆ 0.0  │
│ 2        ┆ 1         ┆ 40ms      ┆ 1.2    ┆ 0.3    ┆ 0.2    ┆ T01     ┆ P001       ┆ -19  ┆ 5.2  ┆ 0.0  │
│ ...      ┆ ...       ┆ ...       ┆ ...    ┆ ...    ┆ ...    ┆ ...     ┆ ...        ┆ ...  ┆ ...  ┆ ...  │
└──────────┴───────────┴───────────┴────────┴────────┴────────┴─────────┴────────────┴──────┴──────┴──────┘

Best for: Analyses that need both player and ball positions simultaneously without joining. Calculating distances to ball, for example.

import polars as pl

# Distance from each player to the ball
dataset.tracking.with_columns(
    ((pl.col("x") - pl.col("ball_x"))**2 +
     (pl.col("y") - pl.col("ball_y"))**2).sqrt().alias("dist_to_ball")
)

Wide Layout

layout="wide"

Each row represents one frame. Player coordinates are stored in columns named {player_id}_x, {player_id}_y, {player_id}_z.

┌──────────┬───────────┬───────────┬────────┬────────┬────────┬──────────┬──────────┬──────────┬──────────┬─────┐
│ frame_id ┆ period_id ┆ timestamp ┆ ball_x ┆ ball_y ┆ ball_z ┆ P001_x   ┆ P001_y   ┆ P001_z   ┆ P002_x   ┆ ... │
│ u32      ┆ i32       ┆ dur[ms]   ┆ f32    ┆ f32    ┆ f32    ┆ f32      ┆ f32      ┆ f32      ┆ f32      ┆     │
╞══════════╪═══════════╪═══════════╪════════╪════════╪════════╪══════════╪══════════╪══════════╪══════════╪═════╡
│ 1        ┆ 1         ┆ 0ms       ┆ 0.5    ┆ 0.0    ┆ 0.1    ┆ -20.0    ┆ 5.0      ┆ 0.0      ┆ -15.0    ┆ ... │
│ 2        ┆ 1         ┆ 40ms      ┆ 1.2    ┆ 0.3    ┆ 0.2    ┆ -19.0    ┆ 5.2      ┆ 0.0      ┆ -14.8    ┆ ... │
│ ...      ┆ ...       ┆ ...       ┆ ...    ┆ ...    ┆ ...    ┆ ...      ┆ ...      ┆ ...      ┆ ...      ┆ ... │
└──────────┴───────────┴───────────┴────────┴────────┴────────┴──────────┴──────────┴──────────┴──────────┴─────┘

Best for: Frame-level operations, matrix computations, Voronoi diagrams, and convex hull calculations where you need all positions in a single row.

Warning

Wide layout produces DataFrames with many columns (3 per player + shared columns). Column names are game-specific (player IDs), which means the schema varies between matches.

Comparison

Aspect Long Long Ball Wide
Rows per frame ~23 (22 players + ball) ~22 (players only) 1
Ball data Row with team_id="ball" Columns: ball_x/y/z Columns: ball_x/y/z
Schema Fixed Fixed Varies per match
Group-by player Easy Easy Not applicable
Frame-level ops Needs pivot Needs pivot Native
Memory Moderate Moderate Compact