Case Study 1: NFL Combine Tracking Analysis Dashboard

Overview

This case study develops a comprehensive spatial analysis system for NFL Combine tracking data. The system visualizes player movement during drills, calculates performance metrics from positional data, and generates comparative reports for scouts and analysts.

Background

The NFL Combine is the premier pre-draft evaluation event where prospects showcase their athletic abilities. Modern tracking technology captures player positions during drills at 10 frames per second, generating millions of data points that can reveal insights beyond traditional stopwatch times.

Business Problem

An NFL team's scouting department needs a system to: 1. Visualize player movement during Combine drills 2. Calculate advanced metrics from tracking data (acceleration curves, change of direction efficiency) 3. Compare prospects at the same position 4. Identify outliers in movement patterns that traditional metrics miss 5. Generate visual reports for position meetings

Available Data

The tracking data includes: - Player ID and biographical information - Position (x, y) at each frame - Timestamp for each frame - Drill identifier (40-yard dash, 3-cone, shuttle) - Event markers (start, split times, finish)

Solution Architecture

System Components

┌─────────────────────────────────────────────────────────────────┐
│                    Combine Analysis Dashboard                    │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐ │
│  │   Data      │  │   Spatial   │  │   Comparison            │ │
│  │   Loader    │──│   Analyzer  │──│   Engine                │ │
│  └─────────────┘  └─────────────┘  └─────────────────────────┘ │
│         │                │                      │               │
│         ▼                ▼                      ▼               │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │              Visualization Layer                            ││
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐   ││
│  │  │  Drill   │  │  Speed   │  │  Accel   │  │  Compare │   ││
│  │  │  Path    │  │  Chart   │  │  Profile │  │  View    │   ││
│  │  └──────────┘  └──────────┘  └──────────┘  └──────────┘   ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

Implementation

Part 1: Data Model and Loading

"""
Combine Tracking Analysis System
Part 1: Data Model and Loading
"""

import pandas as pd
import numpy as np
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from enum import Enum
import json


class DrillType(Enum):
    """Combine drill types."""
    FORTY_YARD = "40_yard"
    THREE_CONE = "3_cone"
    SHUTTLE = "shuttle"
    VERTICAL = "vertical"
    BROAD_JUMP = "broad_jump"


@dataclass
class Player:
    """Prospect information."""
    player_id: str
    name: str
    position: str
    college: str
    height: float  # inches
    weight: float  # pounds
    hand_size: Optional[float] = None
    arm_length: Optional[float] = None


@dataclass
class TrackingFrame:
    """Single frame of tracking data."""
    frame_id: int
    timestamp: float
    x: float
    y: float
    speed: Optional[float] = None
    acceleration: Optional[float] = None


@dataclass
class DrillAttempt:
    """Complete drill attempt with tracking data."""
    player: Player
    drill_type: DrillType
    attempt_number: int
    official_time: float
    frames: List[TrackingFrame] = field(default_factory=list)
    splits: Dict[str, float] = field(default_factory=dict)

    def get_positions(self) -> Tuple[np.ndarray, np.ndarray]:
        """Extract x, y arrays from frames."""
        x = np.array([f.x for f in self.frames])
        y = np.array([f.y for f in self.frames])
        return x, y

    def get_timestamps(self) -> np.ndarray:
        """Extract timestamp array from frames."""
        return np.array([f.timestamp for f in self.frames])


class CombineDataLoader:
    """Load and parse Combine tracking data."""

    def __init__(self, data_path: str):
        self.data_path = data_path
        self.players: Dict[str, Player] = {}
        self.attempts: List[DrillAttempt] = []

    def load_from_csv(self, tracking_file: str, player_file: str):
        """Load data from CSV files."""
        # Load player info
        player_df = pd.read_csv(f"{self.data_path}/{player_file}")
        for _, row in player_df.iterrows():
            player = Player(
                player_id=row['player_id'],
                name=row['name'],
                position=row['position'],
                college=row['college'],
                height=row['height'],
                weight=row['weight'],
                hand_size=row.get('hand_size'),
                arm_length=row.get('arm_length')
            )
            self.players[player.player_id] = player

        # Load tracking data
        tracking_df = pd.read_csv(f"{self.data_path}/{tracking_file}")

        # Group by player and drill attempt
        for (pid, drill, attempt), group in tracking_df.groupby(
            ['player_id', 'drill_type', 'attempt_number']
        ):
            if pid not in self.players:
                continue

            frames = []
            for _, row in group.sort_values('frame_id').iterrows():
                frame = TrackingFrame(
                    frame_id=row['frame_id'],
                    timestamp=row['timestamp'],
                    x=row['x'],
                    y=row['y']
                )
                frames.append(frame)

            attempt_obj = DrillAttempt(
                player=self.players[pid],
                drill_type=DrillType(drill),
                attempt_number=attempt,
                official_time=group['official_time'].iloc[0],
                frames=frames
            )
            self.attempts.append(attempt_obj)

    def get_attempts_by_drill(self, drill_type: DrillType) -> List[DrillAttempt]:
        """Filter attempts by drill type."""
        return [a for a in self.attempts if a.drill_type == drill_type]

    def get_attempts_by_position(self, position: str) -> List[DrillAttempt]:
        """Filter attempts by player position."""
        return [a for a in self.attempts if a.player.position == position]


def generate_sample_data() -> CombineDataLoader:
    """Generate sample Combine data for demonstration."""
    np.random.seed(42)

    loader = CombineDataLoader("")

    # Sample players
    sample_players = [
        Player("P001", "Marcus Thompson", "WR", "Alabama", 73, 198),
        Player("P002", "Jaylen Williams", "WR", "Ohio State", 71, 185),
        Player("P003", "DeShawn Carter", "RB", "Georgia", 70, 212),
        Player("P004", "Tyler Jackson", "CB", "Michigan", 72, 192),
        Player("P005", "Andre Davis", "WR", "USC", 74, 205),
    ]

    for player in sample_players:
        loader.players[player.player_id] = player

    # Generate 40-yard dash tracking data
    for player in sample_players:
        base_speed = 4.3 + np.random.uniform(-0.2, 0.3)  # Base 40 time

        frames = []
        n_frames = 50  # 5 seconds at 10 Hz
        dt = 0.1

        # Simulate acceleration curve
        x_pos = 0.0
        for i in range(n_frames):
            t = i * dt

            # S-curve acceleration model
            if t < 1.5:
                # Acceleration phase
                accel = 8 * (1 - t/1.5) + 2
            else:
                # Maintenance phase
                accel = 0.5

            # Calculate velocity and position
            velocity = min(10.5, 2 + t * 2.5)  # Cap at ~10.5 yards/sec
            x_pos = min(40, x_pos + velocity * dt)

            frame = TrackingFrame(
                frame_id=i,
                timestamp=t,
                x=x_pos,
                y=0 + np.random.normal(0, 0.05)  # Small lateral variance
            )
            frames.append(frame)

            if x_pos >= 40:
                break

        attempt = DrillAttempt(
            player=player,
            drill_type=DrillType.FORTY_YARD,
            attempt_number=1,
            official_time=base_speed,
            frames=frames,
            splits={'10_yard': base_speed * 0.38, '20_yard': base_speed * 0.56}
        )
        loader.attempts.append(attempt)

    return loader

Part 2: Spatial Analysis Engine

"""
Combine Tracking Analysis System
Part 2: Spatial Analysis Engine
"""

import numpy as np
from scipy.signal import savgol_filter
from scipy.interpolate import interp1d
from dataclasses import dataclass
from typing import List, Tuple, Optional


@dataclass
class SpeedProfile:
    """Speed analysis results."""
    timestamps: np.ndarray
    speeds: np.ndarray
    max_speed: float
    max_speed_time: float
    avg_speed: float
    time_to_max: float


@dataclass
class AccelerationProfile:
    """Acceleration analysis results."""
    timestamps: np.ndarray
    accelerations: np.ndarray
    max_acceleration: float
    max_accel_time: float
    acceleration_distance: float  # Distance covered while accelerating


@dataclass
class DrillMetrics:
    """Comprehensive drill metrics."""
    total_time: float
    total_distance: float
    speed_profile: SpeedProfile
    accel_profile: AccelerationProfile
    efficiency_score: float
    path_deviation: float


class SpatialAnalyzer:
    """Analyze spatial tracking data from Combine drills."""

    def __init__(self, frame_rate: float = 10.0):
        self.frame_rate = frame_rate
        self.dt = 1.0 / frame_rate

    def calculate_speed(self, attempt: 'DrillAttempt',
                       smooth: bool = True) -> SpeedProfile:
        """
        Calculate instantaneous speed from position data.

        Parameters:
        -----------
        attempt : DrillAttempt
            Drill attempt with tracking frames
        smooth : bool
            Whether to apply smoothing filter

        Returns:
        --------
        SpeedProfile : Speed analysis results
        """
        x, y = attempt.get_positions()
        timestamps = attempt.get_timestamps()

        # Calculate displacement between frames
        dx = np.diff(x)
        dy = np.diff(y)
        distances = np.sqrt(dx**2 + dy**2)

        # Speed = distance / time
        dt = np.diff(timestamps)
        dt[dt == 0] = self.dt  # Handle zero time differences
        speeds = distances / dt

        # Apply smoothing if requested
        if smooth and len(speeds) > 5:
            window = min(5, len(speeds) - 1)
            if window % 2 == 0:
                window -= 1
            if window >= 3:
                speeds = savgol_filter(speeds, window, 2)

        # Timestamps for speed (between position samples)
        speed_timestamps = timestamps[:-1] + dt/2

        # Calculate metrics
        max_speed = np.max(speeds)
        max_speed_idx = np.argmax(speeds)
        max_speed_time = speed_timestamps[max_speed_idx]
        avg_speed = np.mean(speeds)
        time_to_max = max_speed_time

        return SpeedProfile(
            timestamps=speed_timestamps,
            speeds=speeds,
            max_speed=max_speed,
            max_speed_time=max_speed_time,
            avg_speed=avg_speed,
            time_to_max=time_to_max
        )

    def calculate_acceleration(self, speed_profile: SpeedProfile,
                              smooth: bool = True) -> AccelerationProfile:
        """
        Calculate acceleration from speed profile.

        Parameters:
        -----------
        speed_profile : SpeedProfile
            Calculated speed profile
        smooth : bool
            Whether to apply smoothing

        Returns:
        --------
        AccelerationProfile : Acceleration analysis results
        """
        speeds = speed_profile.speeds
        timestamps = speed_profile.timestamps

        # Calculate acceleration
        dt = np.diff(timestamps)
        dt[dt == 0] = self.dt
        accelerations = np.diff(speeds) / dt

        # Apply smoothing
        if smooth and len(accelerations) > 5:
            window = min(5, len(accelerations) - 1)
            if window % 2 == 0:
                window -= 1
            if window >= 3:
                accelerations = savgol_filter(accelerations, window, 2)

        accel_timestamps = timestamps[:-1] + dt/2

        # Find max acceleration
        max_accel = np.max(accelerations)
        max_accel_idx = np.argmax(accelerations)
        max_accel_time = accel_timestamps[max_accel_idx]

        # Calculate distance covered during acceleration phase
        accel_end_idx = np.where(accelerations < 1.0)[0]
        if len(accel_end_idx) > 0:
            accel_phase_end = accel_end_idx[0]
            accel_distance = np.sum(speeds[:accel_phase_end+1] * dt[:accel_phase_end+1])
        else:
            accel_distance = np.sum(speeds * dt)

        return AccelerationProfile(
            timestamps=accel_timestamps,
            accelerations=accelerations,
            max_acceleration=max_accel,
            max_accel_time=max_accel_time,
            acceleration_distance=accel_distance
        )

    def calculate_path_deviation(self, attempt: 'DrillAttempt',
                                 ideal_path: str = 'straight') -> float:
        """
        Calculate how much the actual path deviated from ideal.

        Parameters:
        -----------
        attempt : DrillAttempt
            Drill attempt data
        ideal_path : str
            Type of ideal path ('straight', '3_cone', 'shuttle')

        Returns:
        --------
        float : Root mean square deviation from ideal path
        """
        x, y = attempt.get_positions()

        if ideal_path == 'straight':
            # Ideal path is straight line at y=0
            ideal_y = np.zeros_like(y)
            deviation = np.sqrt(np.mean((y - ideal_y)**2))
        else:
            # For complex drills, compare to template path
            deviation = 0.0  # Placeholder

        return deviation

    def analyze_drill(self, attempt: 'DrillAttempt') -> DrillMetrics:
        """
        Perform comprehensive drill analysis.

        Parameters:
        -----------
        attempt : DrillAttempt
            Drill attempt with tracking data

        Returns:
        --------
        DrillMetrics : Complete analysis results
        """
        # Calculate profiles
        speed_profile = self.calculate_speed(attempt)
        accel_profile = self.calculate_acceleration(speed_profile)

        # Total distance
        x, y = attempt.get_positions()
        dx = np.diff(x)
        dy = np.diff(y)
        total_distance = np.sum(np.sqrt(dx**2 + dy**2))

        # Path deviation
        path_deviation = self.calculate_path_deviation(attempt)

        # Efficiency score (ratio of straight-line distance to actual distance)
        straight_distance = np.sqrt((x[-1] - x[0])**2 + (y[-1] - y[0])**2)
        efficiency_score = straight_distance / total_distance if total_distance > 0 else 0

        return DrillMetrics(
            total_time=attempt.official_time,
            total_distance=total_distance,
            speed_profile=speed_profile,
            accel_profile=accel_profile,
            efficiency_score=efficiency_score,
            path_deviation=path_deviation
        )


class ComparisonEngine:
    """Compare prospects based on spatial analysis."""

    def __init__(self, analyzer: SpatialAnalyzer):
        self.analyzer = analyzer

    def compare_speed_profiles(self, attempts: List['DrillAttempt']
                              ) -> pd.DataFrame:
        """
        Compare speed profiles across multiple attempts.

        Returns DataFrame with speed metrics for comparison.
        """
        results = []

        for attempt in attempts:
            metrics = self.analyzer.analyze_drill(attempt)

            results.append({
                'player_name': attempt.player.name,
                'position': attempt.player.position,
                'college': attempt.player.college,
                'official_time': attempt.official_time,
                'max_speed': metrics.speed_profile.max_speed,
                'time_to_max_speed': metrics.speed_profile.time_to_max,
                'avg_speed': metrics.speed_profile.avg_speed,
                'max_acceleration': metrics.accel_profile.max_acceleration,
                'accel_distance': metrics.accel_profile.acceleration_distance,
                'efficiency': metrics.efficiency_score,
                'path_deviation': metrics.path_deviation
            })

        return pd.DataFrame(results)

    def calculate_percentiles(self, comparison_df: pd.DataFrame,
                             position: Optional[str] = None) -> pd.DataFrame:
        """
        Calculate percentile rankings for each metric.
        """
        if position:
            df = comparison_df[comparison_df['position'] == position].copy()
        else:
            df = comparison_df.copy()

        numeric_cols = ['official_time', 'max_speed', 'time_to_max_speed',
                       'avg_speed', 'max_acceleration', 'efficiency']

        for col in numeric_cols:
            if col == 'official_time':
                # Lower is better
                df[f'{col}_pct'] = 100 - df[col].rank(pct=True) * 100
            else:
                # Higher is better
                df[f'{col}_pct'] = df[col].rank(pct=True) * 100

        return df

Part 3: Visualization Components

"""
Combine Tracking Analysis System
Part 3: Visualization Components
"""

import matplotlib.pyplot as plt
import matplotlib.patches as patches
from matplotlib.collections import LineCollection
import numpy as np
from typing import List, Optional, Tuple


class DrillVisualizer:
    """Visualize Combine drill tracking data."""

    def __init__(self, figsize: Tuple[int, int] = (12, 6)):
        self.figsize = figsize
        self.colors = {
            'track': '#2E4053',
            'speed_high': '#27AE60',
            'speed_low': '#E74C3C',
            'reference': '#95A5A6',
            'highlight': '#F39C12'
        }

    def draw_forty_yard_track(self, ax: plt.Axes) -> plt.Axes:
        """Draw 40-yard dash track layout."""
        # Track surface
        track = patches.Rectangle((0, -3), 42, 6, linewidth=1,
                                   edgecolor='white', facecolor='#8B4513',
                                   alpha=0.3)
        ax.add_patch(track)

        # Start and finish lines
        ax.axvline(x=0, color='white', linewidth=3, label='Start')
        ax.axvline(x=40, color='white', linewidth=3, label='Finish')

        # Split markers
        for split in [10, 20]:
            ax.axvline(x=split, color='white', linewidth=1.5,
                      linestyle='--', alpha=0.7)
            ax.text(split, -2.5, f'{split}', ha='center', fontsize=10,
                   color='white')

        # Yard markers
        for yard in range(0, 41, 5):
            ax.plot([yard, yard], [-0.3, 0.3], 'w-', linewidth=1)

        ax.set_xlim(-2, 44)
        ax.set_ylim(-4, 4)
        ax.set_xlabel('Distance (yards)')
        ax.set_ylabel('Lateral Position (yards)')
        ax.set_aspect('equal')

        return ax

    def plot_path(self, ax: plt.Axes, attempt: 'DrillAttempt',
                  color_by_speed: bool = True,
                  show_markers: bool = True) -> plt.Axes:
        """
        Plot player path with optional speed coloring.

        Parameters:
        -----------
        ax : plt.Axes
            Axes to plot on
        attempt : DrillAttempt
            Drill attempt data
        color_by_speed : bool
            Color path by instantaneous speed
        show_markers : bool
            Show position markers at intervals
        """
        x, y = attempt.get_positions()

        if color_by_speed:
            # Calculate speeds for coloring
            analyzer = SpatialAnalyzer()
            speed_profile = analyzer.calculate_speed(attempt)

            # Create colored line segments
            points = np.array([x[:-1], y[:-1]]).T.reshape(-1, 1, 2)
            segments = np.concatenate([points[:-1], points[1:]], axis=1)

            # Normalize speeds for colormap
            norm = plt.Normalize(speed_profile.speeds.min(),
                                speed_profile.speeds.max())
            lc = LineCollection(segments, cmap='RdYlGn', norm=norm)
            lc.set_array(speed_profile.speeds[:-1])
            lc.set_linewidth(3)
            line = ax.add_collection(lc)

            # Add colorbar
            plt.colorbar(line, ax=ax, label='Speed (yards/sec)')
        else:
            ax.plot(x, y, color=self.colors['track'], linewidth=2,
                   label=attempt.player.name)

        if show_markers:
            # Mark start
            ax.scatter(x[0], y[0], s=100, c='green', marker='o',
                      zorder=5, edgecolor='white')
            # Mark finish
            ax.scatter(x[-1], y[-1], s=100, c='red', marker='s',
                      zorder=5, edgecolor='white')

        return ax

    def plot_speed_chart(self, attempts: List['DrillAttempt'],
                        ax: Optional[plt.Axes] = None) -> plt.Figure:
        """
        Plot speed over time for multiple attempts.

        Parameters:
        -----------
        attempts : List[DrillAttempt]
            List of drill attempts to compare
        ax : plt.Axes, optional
            Existing axes to use
        """
        if ax is None:
            fig, ax = plt.subplots(figsize=self.figsize)
        else:
            fig = ax.get_figure()

        analyzer = SpatialAnalyzer()
        colors = plt.cm.tab10(np.linspace(0, 1, len(attempts)))

        for i, attempt in enumerate(attempts):
            speed_profile = analyzer.calculate_speed(attempt)

            ax.plot(speed_profile.timestamps, speed_profile.speeds,
                   color=colors[i], linewidth=2,
                   label=f"{attempt.player.name} ({attempt.official_time:.2f}s)")

            # Mark max speed
            ax.scatter(speed_profile.max_speed_time, speed_profile.max_speed,
                      color=colors[i], s=80, marker='*', zorder=5)

        ax.set_xlabel('Time (seconds)')
        ax.set_ylabel('Speed (yards/second)')
        ax.set_title('Speed Profile Comparison')
        ax.legend(loc='lower right')
        ax.grid(True, alpha=0.3)

        return fig

    def plot_acceleration_chart(self, attempts: List['DrillAttempt'],
                               ax: Optional[plt.Axes] = None) -> plt.Figure:
        """Plot acceleration over time for multiple attempts."""
        if ax is None:
            fig, ax = plt.subplots(figsize=self.figsize)
        else:
            fig = ax.get_figure()

        analyzer = SpatialAnalyzer()
        colors = plt.cm.tab10(np.linspace(0, 1, len(attempts)))

        for i, attempt in enumerate(attempts):
            speed_profile = analyzer.calculate_speed(attempt)
            accel_profile = analyzer.calculate_acceleration(speed_profile)

            ax.plot(accel_profile.timestamps, accel_profile.accelerations,
                   color=colors[i], linewidth=2, label=attempt.player.name)

        ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
        ax.set_xlabel('Time (seconds)')
        ax.set_ylabel('Acceleration (yards/sec²)')
        ax.set_title('Acceleration Profile Comparison')
        ax.legend(loc='upper right')
        ax.grid(True, alpha=0.3)

        return fig


class ComparisonVisualizer:
    """Visualize prospect comparisons."""

    def __init__(self):
        self.figsize = (14, 8)

    def create_radar_chart(self, comparison_df: pd.DataFrame,
                          players: List[str],
                          metrics: List[str]) -> plt.Figure:
        """
        Create radar chart comparing selected players.

        Parameters:
        -----------
        comparison_df : pd.DataFrame
            DataFrame with percentile rankings
        players : List[str]
            Player names to compare
        metrics : List[str]
            Metric names to include
        """
        fig, ax = plt.subplots(figsize=(10, 10),
                               subplot_kw=dict(projection='polar'))

        # Set up angles for radar chart
        angles = np.linspace(0, 2*np.pi, len(metrics), endpoint=False)
        angles = np.concatenate([angles, [angles[0]]])

        # Plot each player
        colors = plt.cm.Set2(np.linspace(0, 1, len(players)))

        for i, player in enumerate(players):
            player_data = comparison_df[comparison_df['player_name'] == player]
            if len(player_data) == 0:
                continue

            values = [player_data[f'{m}_pct'].iloc[0] for m in metrics]
            values = np.concatenate([values, [values[0]]])

            ax.plot(angles, values, 'o-', linewidth=2,
                   color=colors[i], label=player)
            ax.fill(angles, values, alpha=0.25, color=colors[i])

        # Customize chart
        ax.set_xticks(angles[:-1])
        ax.set_xticklabels([m.replace('_', ' ').title() for m in metrics])
        ax.set_ylim(0, 100)
        ax.set_yticks([25, 50, 75, 100])
        ax.set_yticklabels(['25th', '50th', '75th', '100th'])
        ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.0))
        ax.set_title('Prospect Comparison - Percentile Rankings', size=14, y=1.1)

        return fig

    def create_comparison_table(self, comparison_df: pd.DataFrame,
                               highlight_best: bool = True) -> plt.Figure:
        """
        Create visual comparison table.
        """
        fig, ax = plt.subplots(figsize=self.figsize)
        ax.axis('off')

        # Select columns for display
        display_cols = ['player_name', 'position', 'official_time',
                       'max_speed', 'max_acceleration', 'efficiency']

        table_data = comparison_df[display_cols].round(3)

        # Create table
        table = ax.table(
            cellText=table_data.values,
            colLabels=[c.replace('_', ' ').title() for c in display_cols],
            loc='center',
            cellLoc='center'
        )

        table.auto_set_font_size(False)
        table.set_fontsize(10)
        table.scale(1.2, 1.5)

        # Style header
        for i in range(len(display_cols)):
            table[(0, i)].set_facecolor('#2E4053')
            table[(0, i)].set_text_props(color='white', weight='bold')

        # Highlight best values
        if highlight_best:
            for j, col in enumerate(display_cols[2:], start=2):
                if col == 'official_time':
                    best_idx = table_data[col].idxmin()
                else:
                    best_idx = table_data[col].idxmax()

                row_idx = table_data.index.get_loc(best_idx) + 1
                table[(row_idx, j)].set_facecolor('#27AE60')
                table[(row_idx, j)].set_text_props(color='white', weight='bold')

        ax.set_title('Prospect Metrics Comparison', fontsize=14, pad=20)

        return fig

Part 4: Dashboard Assembly

"""
Combine Tracking Analysis System
Part 4: Dashboard Assembly
"""

import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
import pandas as pd
from typing import List, Optional


class CombineAnalysisDashboard:
    """
    Complete dashboard for Combine tracking analysis.

    Integrates data loading, analysis, and visualization components.
    """

    def __init__(self, data_loader: 'CombineDataLoader'):
        self.loader = data_loader
        self.analyzer = SpatialAnalyzer()
        self.drill_viz = DrillVisualizer()
        self.compare_viz = ComparisonVisualizer()
        self.comparison_engine = ComparisonEngine(self.analyzer)

    def generate_player_report(self, player_name: str,
                              drill_type: DrillType) -> plt.Figure:
        """
        Generate comprehensive report for a single player.

        Parameters:
        -----------
        player_name : str
            Name of player to analyze
        drill_type : DrillType
            Type of drill to analyze

        Returns:
        --------
        plt.Figure : Multi-panel report figure
        """
        # Find player's attempt
        attempts = [a for a in self.loader.attempts
                   if a.player.name == player_name and a.drill_type == drill_type]

        if not attempts:
            raise ValueError(f"No {drill_type.value} data found for {player_name}")

        attempt = attempts[0]
        metrics = self.analyzer.analyze_drill(attempt)

        # Create figure with subplots
        fig = plt.figure(figsize=(16, 12))
        gs = GridSpec(3, 3, figure=fig, hspace=0.3, wspace=0.3)

        # Player info header
        ax_header = fig.add_subplot(gs[0, :])
        ax_header.axis('off')
        header_text = (
            f"{attempt.player.name} - {attempt.player.position}\n"
            f"{attempt.player.college} | "
            f"{attempt.player.height}\" | {attempt.player.weight} lbs\n"
            f"Official 40 Time: {attempt.official_time:.2f}s"
        )
        ax_header.text(0.5, 0.5, header_text, ha='center', va='center',
                      fontsize=16, transform=ax_header.transAxes)

        # Path visualization
        ax_path = fig.add_subplot(gs[1, 0:2])
        self.drill_viz.draw_forty_yard_track(ax_path)
        self.drill_viz.plot_path(ax_path, attempt, color_by_speed=True)
        ax_path.set_title('Dash Path (Colored by Speed)')

        # Metrics panel
        ax_metrics = fig.add_subplot(gs[1, 2])
        ax_metrics.axis('off')
        metrics_text = (
            f"Key Metrics\n"
            f"{'─'*30}\n"
            f"Max Speed: {metrics.speed_profile.max_speed:.2f} yd/s\n"
            f"Time to Max: {metrics.speed_profile.time_to_max:.2f}s\n"
            f"Avg Speed: {metrics.speed_profile.avg_speed:.2f} yd/s\n"
            f"Max Accel: {metrics.accel_profile.max_acceleration:.2f} yd/s²\n"
            f"Efficiency: {metrics.efficiency_score:.3f}\n"
            f"Path Dev: {metrics.path_deviation:.3f} yd"
        )
        ax_metrics.text(0.1, 0.9, metrics_text, ha='left', va='top',
                       fontsize=12, family='monospace',
                       transform=ax_metrics.transAxes)

        # Speed profile
        ax_speed = fig.add_subplot(gs[2, 0])
        ax_speed.plot(metrics.speed_profile.timestamps,
                     metrics.speed_profile.speeds, 'b-', linewidth=2)
        ax_speed.axhline(metrics.speed_profile.max_speed, color='r',
                        linestyle='--', alpha=0.5)
        ax_speed.set_xlabel('Time (s)')
        ax_speed.set_ylabel('Speed (yd/s)')
        ax_speed.set_title('Speed Profile')
        ax_speed.grid(True, alpha=0.3)

        # Acceleration profile
        ax_accel = fig.add_subplot(gs[2, 1])
        ax_accel.plot(metrics.accel_profile.timestamps,
                     metrics.accel_profile.accelerations, 'g-', linewidth=2)
        ax_accel.axhline(0, color='gray', linestyle='--', alpha=0.5)
        ax_accel.set_xlabel('Time (s)')
        ax_accel.set_ylabel('Acceleration (yd/s²)')
        ax_accel.set_title('Acceleration Profile')
        ax_accel.grid(True, alpha=0.3)

        # Splits comparison
        ax_splits = fig.add_subplot(gs[2, 2])
        splits = ['10 yd', '20 yd', '40 yd']
        times = [attempt.splits.get('10_yard', 0),
                attempt.splits.get('20_yard', 0),
                attempt.official_time]
        colors = ['#3498DB', '#2ECC71', '#E74C3C']
        bars = ax_splits.barh(splits, times, color=colors)
        ax_splits.set_xlabel('Time (seconds)')
        ax_splits.set_title('Split Times')
        for bar, time in zip(bars, times):
            ax_splits.text(bar.get_width() + 0.05, bar.get_y() + bar.get_height()/2,
                          f'{time:.2f}s', va='center')

        fig.suptitle(f'40-Yard Dash Analysis Report', fontsize=18, y=0.98)

        return fig

    def generate_position_comparison(self, position: str,
                                    drill_type: DrillType) -> plt.Figure:
        """
        Generate comparison report for all players at a position.
        """
        # Get all attempts for position and drill
        attempts = [a for a in self.loader.attempts
                   if a.player.position == position and a.drill_type == drill_type]

        if not attempts:
            raise ValueError(f"No data found for {position} in {drill_type.value}")

        # Generate comparison data
        comparison_df = self.comparison_engine.compare_speed_profiles(attempts)
        comparison_df = self.comparison_engine.calculate_percentiles(comparison_df)

        # Create figure
        fig = plt.figure(figsize=(18, 14))
        gs = GridSpec(3, 2, figure=fig, hspace=0.3, wspace=0.2)

        # Title
        fig.suptitle(f'{position} 40-Yard Dash Comparison', fontsize=18, y=0.98)

        # Speed profiles
        ax_speed = fig.add_subplot(gs[0, 0])
        self.drill_viz.plot_speed_chart(attempts, ax=ax_speed)

        # Acceleration profiles
        ax_accel = fig.add_subplot(gs[0, 1])
        self.drill_viz.plot_acceleration_chart(attempts, ax=ax_accel)

        # Path comparison
        ax_paths = fig.add_subplot(gs[1, :])
        self.drill_viz.draw_forty_yard_track(ax_paths)
        colors = plt.cm.tab10(np.linspace(0, 1, len(attempts)))
        for i, attempt in enumerate(attempts):
            x, y = attempt.get_positions()
            ax_paths.plot(x, y + i*0.3, color=colors[i], linewidth=2,
                         label=f"{attempt.player.name}")
        ax_paths.legend(loc='upper left')
        ax_paths.set_title('Path Comparison (offset for visibility)')

        # Rankings table
        ax_table = fig.add_subplot(gs[2, :])
        ax_table.axis('off')

        display_cols = ['player_name', 'official_time', 'max_speed',
                       'avg_speed', 'max_acceleration', 'efficiency']
        table_data = comparison_df[display_cols].sort_values('official_time')

        table = ax_table.table(
            cellText=table_data.round(3).values,
            colLabels=[c.replace('_', ' ').title() for c in display_cols],
            loc='center',
            cellLoc='center'
        )
        table.auto_set_font_size(False)
        table.set_fontsize(10)
        table.scale(1.2, 1.5)

        # Style header
        for i in range(len(display_cols)):
            table[(0, i)].set_facecolor('#2E4053')
            table[(0, i)].set_text_props(color='white', weight='bold')

        return fig


# =============================================================================
# DEMONSTRATION
# =============================================================================

if __name__ == "__main__":
    print("=" * 70)
    print("NFL COMBINE TRACKING ANALYSIS DASHBOARD")
    print("=" * 70)

    # Generate sample data
    print("\nLoading sample data...")
    loader = generate_sample_data()
    print(f"Loaded {len(loader.players)} players, {len(loader.attempts)} attempts")

    # Create dashboard
    dashboard = CombineAnalysisDashboard(loader)

    # Generate player report
    print("\nGenerating player report for Marcus Thompson...")
    fig1 = dashboard.generate_player_report("Marcus Thompson", DrillType.FORTY_YARD)
    fig1.savefig('combine_player_report.png', dpi=150, bbox_inches='tight')
    print("Saved: combine_player_report.png")

    # Generate position comparison
    print("\nGenerating WR comparison report...")
    fig2 = dashboard.generate_position_comparison("WR", DrillType.FORTY_YARD)
    fig2.savefig('combine_wr_comparison.png', dpi=150, bbox_inches='tight')
    print("Saved: combine_wr_comparison.png")

    print("\n" + "=" * 70)
    print("DEMONSTRATION COMPLETE")
    print("=" * 70)

Key Insights

Technical Learnings

  1. Speed Calculation from Position Data: Converting position data to speed requires careful handling of time intervals and smoothing to reduce noise.

  2. Acceleration Analysis: Second-order derivatives (acceleration) are particularly sensitive to noise and require robust smoothing techniques.

  3. Visualization Layering: Complex drill visualizations require careful z-ordering of track surface, reference lines, paths, and markers.

Analytical Findings

  1. Beyond Official Times: Tracking data reveals acceleration patterns that official times miss - two players with identical 40 times may have very different speed curves.

  2. Path Efficiency: Even in "straight line" drills, path deviation varies significantly between players and may indicate running form issues.

  3. Time to Max Speed: This metric often better predicts game speed than raw 40 time, especially for positions requiring quick bursts.

Exercises

  1. Add 3-Cone Drill Support: Extend the system to handle the 3-cone drill with its complex path requirements.

  2. Build Position Templates: Create ideal speed/acceleration profiles for each position based on successful NFL players.

  3. Implement Anomaly Detection: Develop algorithms to identify unusual movement patterns that warrant further investigation.

Further Reading

  • NFL Combine Tracking Methodology documentation
  • Sports biomechanics research on sprint mechanics
  • Signal processing for motion analysis