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
-
Speed Calculation from Position Data: Converting position data to speed requires careful handling of time intervals and smoothing to reduce noise.
-
Acceleration Analysis: Second-order derivatives (acceleration) are particularly sensitive to noise and require robust smoothing techniques.
-
Visualization Layering: Complex drill visualizations require careful z-ordering of track surface, reference lines, paths, and markers.
Analytical Findings
-
Beyond Official Times: Tracking data reveals acceleration patterns that official times miss - two players with identical 40 times may have very different speed curves.
-
Path Efficiency: Even in "straight line" drills, path deviation varies significantly between players and may indicate running form issues.
-
Time to Max Speed: This metric often better predicts game speed than raw 40 time, especially for positions requiring quick bursts.
Exercises
-
Add 3-Cone Drill Support: Extend the system to handle the 3-cone drill with its complex path requirements.
-
Build Position Templates: Create ideal speed/acceleration profiles for each position based on successful NFL players.
-
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