Case Study 2: Route Efficiency and Coverage Analysis System

Overview

This case study develops a spatial analysis system that evaluates receiver route efficiency, maps defensive coverage tendencies, and identifies exploitable matchups. The system uses tracking data to quantify route running quality and coverage effectiveness.

Background

Modern NFL and college football tracking data captures player positions at 10 frames per second, enabling detailed analysis of route running and coverage. This data reveals insights that film study alone cannot provide:

  • Precise separation measurements at each point of a route
  • Quantified route sharpness at break points
  • Coverage shell recognition in real-time
  • Zone boundary identification

Business Problem

A college football program's offensive staff needs a system to: 1. Evaluate receiver route running efficiency 2. Analyze opposing team coverage tendencies 3. Identify zone soft spots to attack 4. Create data-driven game plans 5. Provide visual feedback to players in meetings

Available Data

The system uses: - Player tracking data (x, y positions at 10 Hz) - Play-by-play outcomes - Route tags for each receiver - Coverage calls from film review - Game context (down, distance, field position)

Solution Architecture

System Overview

┌─────────────────────────────────────────────────────────────────┐
│              Route and Coverage Analysis System                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐      │
│  │   Route      │    │   Coverage   │    │   Matchup    │      │
│  │   Analyzer   │    │   Mapper     │    │   Finder     │      │
│  └──────┬───────┘    └──────┬───────┘    └──────┬───────┘      │
│         │                   │                   │               │
│         └─────────┬─────────┴─────────┬────────┘               │
│                   │                   │                         │
│         ┌─────────▼─────────┐  ┌─────▼────────────┐            │
│         │   Spatial        │  │   Report         │            │
│         │   Visualizer     │  │   Generator      │            │
│         └──────────────────┘  └──────────────────┘            │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Implementation

Part 1: Route Analysis Engine

"""
Route Efficiency and Coverage Analysis System
Part 1: Route Analysis Engine
"""

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import List, Dict, Tuple, Optional
from enum import Enum
from scipy.signal import savgol_filter
from scipy.spatial.distance import euclidean


class RouteType(Enum):
    """Standard route tree."""
    HITCH = "hitch"
    FLAT = "flat"
    SLANT = "slant"
    COMEBACK = "comeback"
    CURL = "curl"
    OUT = "out"
    IN = "in"
    CORNER = "corner"
    POST = "post"
    GO = "go"
    WHEEL = "wheel"
    SCREEN = "screen"


@dataclass
class RouteTemplate:
    """Template for ideal route execution."""
    route_type: RouteType
    stem_depth: float  # Yards before break
    break_angle: float  # Degrees (0 = straight, 90 = perpendicular)
    final_depth: float  # Target depth
    expected_separation: float  # Average expected separation


# Define standard route templates
ROUTE_TEMPLATES = {
    RouteType.HITCH: RouteTemplate(RouteType.HITCH, 5, 180, 5, 2.5),
    RouteType.SLANT: RouteTemplate(RouteType.SLANT, 3, 45, 8, 3.0),
    RouteType.OUT: RouteTemplate(RouteType.OUT, 12, 90, 12, 2.0),
    RouteType.IN: RouteTemplate(RouteType.IN, 12, -90, 12, 2.5),
    RouteType.CURL: RouteTemplate(RouteType.CURL, 12, 160, 10, 2.0),
    RouteType.POST: RouteTemplate(RouteType.POST, 10, -45, 25, 3.5),
    RouteType.CORNER: RouteTemplate(RouteType.CORNER, 10, 45, 20, 3.0),
    RouteType.GO: RouteTemplate(RouteType.GO, 0, 0, 40, 2.5),
}


@dataclass
class TrackingPoint:
    """Single tracking data point."""
    frame: int
    timestamp: float
    x: float  # Yards downfield from LOS
    y: float  # Yards from center (positive = offense's right)
    speed: Optional[float] = None
    direction: Optional[float] = None  # Degrees


@dataclass
class RouteExecution:
    """Complete route execution data."""
    play_id: str
    receiver_id: str
    receiver_name: str
    route_type: RouteType
    tracking: List[TrackingPoint]
    target: bool = False
    completion: bool = False

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


@dataclass
class RouteMetrics:
    """Calculated route efficiency metrics."""
    route_type: RouteType
    total_distance: float
    route_efficiency: float  # Ideal distance / actual distance
    stem_speed: float
    break_sharpness: float  # Angle change rate at break
    break_depth: float
    separation_at_break: float
    max_separation: float
    time_to_break: float
    grade: str  # A, B, C, D, F


class RouteAnalyzer:
    """Analyze route execution quality."""

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

    def calculate_break_point(self, route: RouteExecution) -> Tuple[int, float, float]:
        """
        Identify the break point in a route.

        Returns:
        --------
        tuple : (frame_index, x_position, y_position)
        """
        x, y = route.get_positions()

        if len(x) < 5:
            return 0, x[0], y[0]

        # Calculate direction at each point
        dx = np.diff(x)
        dy = np.diff(y)
        directions = np.arctan2(dy, dx)

        # Find largest direction change
        direction_changes = np.abs(np.diff(directions))

        # Handle angle wrapping
        direction_changes = np.minimum(direction_changes,
                                       2*np.pi - direction_changes)

        # Smooth to avoid noise
        if len(direction_changes) > 5:
            direction_changes = savgol_filter(direction_changes, 5, 2)

        # Find peak direction change
        break_idx = np.argmax(direction_changes) + 1

        return break_idx, x[break_idx], y[break_idx]

    def calculate_break_sharpness(self, route: RouteExecution,
                                  break_idx: int) -> float:
        """
        Calculate sharpness of the route break.

        Higher values indicate crisper breaks.
        """
        x, y = route.get_positions()

        if break_idx < 2 or break_idx >= len(x) - 2:
            return 0.0

        # Direction before break (2 frames before)
        dx_before = x[break_idx] - x[break_idx-2]
        dy_before = y[break_idx] - y[break_idx-2]
        dir_before = np.arctan2(dy_before, dx_before)

        # Direction after break (2 frames after)
        dx_after = x[break_idx+2] - x[break_idx]
        dy_after = y[break_idx+2] - y[break_idx]
        dir_after = np.arctan2(dy_after, dx_after)

        # Angle change
        angle_change = abs(dir_after - dir_before)
        angle_change = min(angle_change, 2*np.pi - angle_change)

        # Time to complete change (4 frames)
        time_window = 4 * self.dt

        # Sharpness = angle change / time (degrees per second)
        sharpness = np.degrees(angle_change) / time_window

        return sharpness

    def calculate_stem_speed(self, route: RouteExecution,
                            break_idx: int) -> float:
        """
        Calculate average speed during the stem (before break).
        """
        x, y = route.get_positions()
        timestamps = np.array([p.timestamp for p in route.tracking])

        if break_idx < 2:
            return 0.0

        # Distance covered during stem
        dx = np.diff(x[:break_idx+1])
        dy = np.diff(y[:break_idx+1])
        distances = np.sqrt(dx**2 + dy**2)
        total_distance = np.sum(distances)

        # Time during stem
        stem_time = timestamps[break_idx] - timestamps[0]

        if stem_time <= 0:
            return 0.0

        return total_distance / stem_time

    def calculate_route_efficiency(self, route: RouteExecution) -> float:
        """
        Calculate route efficiency as ratio of ideal to actual distance.

        Perfect efficiency = 1.0, lower values indicate wasted motion.
        """
        x, y = route.get_positions()

        # Actual distance traveled
        dx = np.diff(x)
        dy = np.diff(y)
        actual_distance = np.sum(np.sqrt(dx**2 + dy**2))

        # Ideal distance (straight lines to break point and target)
        break_idx, break_x, break_y = self.calculate_break_point(route)

        # Distance to break
        ideal_to_break = np.sqrt((break_x - x[0])**2 + (break_y - y[0])**2)

        # Distance from break to end
        ideal_from_break = np.sqrt((x[-1] - break_x)**2 + (y[-1] - break_y)**2)

        ideal_distance = ideal_to_break + ideal_from_break

        if actual_distance <= 0:
            return 0.0

        return ideal_distance / actual_distance

    def analyze_route(self, route: RouteExecution,
                     defender_tracking: Optional[List[TrackingPoint]] = None
                     ) -> RouteMetrics:
        """
        Perform comprehensive route analysis.

        Parameters:
        -----------
        route : RouteExecution
            Route tracking data
        defender_tracking : List[TrackingPoint], optional
            Covering defender's tracking data

        Returns:
        --------
        RouteMetrics : Complete route analysis
        """
        x, y = route.get_positions()
        timestamps = np.array([p.timestamp for p in route.tracking])

        # Calculate break point
        break_idx, break_x, break_y = self.calculate_break_point(route)

        # Calculate metrics
        break_sharpness = self.calculate_break_sharpness(route, break_idx)
        stem_speed = self.calculate_stem_speed(route, break_idx)
        efficiency = self.calculate_route_efficiency(route)

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

        # Time to break
        time_to_break = timestamps[break_idx] - timestamps[0] if break_idx > 0 else 0

        # Separation calculations
        separation_at_break = 0.0
        max_separation = 0.0

        if defender_tracking:
            def_x = np.array([p.x for p in defender_tracking])
            def_y = np.array([p.y for p in defender_tracking])

            # Ensure same length
            min_len = min(len(x), len(def_x))
            x = x[:min_len]
            y = y[:min_len]
            def_x = def_x[:min_len]
            def_y = def_y[:min_len]

            separations = np.sqrt((x - def_x)**2 + (y - def_y)**2)
            max_separation = np.max(separations)

            if break_idx < min_len:
                separation_at_break = separations[break_idx]

        # Grade the route
        grade = self._calculate_grade(efficiency, break_sharpness, stem_speed)

        return RouteMetrics(
            route_type=route.route_type,
            total_distance=total_distance,
            route_efficiency=efficiency,
            stem_speed=stem_speed,
            break_sharpness=break_sharpness,
            break_depth=break_x,
            separation_at_break=separation_at_break,
            max_separation=max_separation,
            time_to_break=time_to_break,
            grade=grade
        )

    def _calculate_grade(self, efficiency: float,
                        sharpness: float,
                        speed: float) -> str:
        """Calculate overall route grade."""
        score = 0

        # Efficiency (0-40 points)
        if efficiency >= 0.95:
            score += 40
        elif efficiency >= 0.90:
            score += 35
        elif efficiency >= 0.85:
            score += 30
        elif efficiency >= 0.80:
            score += 25
        else:
            score += 15

        # Break sharpness (0-30 points, based on degrees/sec)
        if sharpness >= 400:
            score += 30
        elif sharpness >= 300:
            score += 25
        elif sharpness >= 200:
            score += 20
        elif sharpness >= 100:
            score += 15
        else:
            score += 10

        # Stem speed (0-30 points, yards/sec)
        if speed >= 8.0:
            score += 30
        elif speed >= 7.0:
            score += 25
        elif speed >= 6.0:
            score += 20
        elif speed >= 5.0:
            score += 15
        else:
            score += 10

        # Convert to letter grade
        if score >= 90:
            return 'A'
        elif score >= 80:
            return 'B'
        elif score >= 70:
            return 'C'
        elif score >= 60:
            return 'D'
        else:
            return 'F'

Part 2: Coverage Analysis Engine

"""
Route Efficiency and Coverage Analysis System
Part 2: Coverage Analysis Engine
"""

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import List, Dict, Tuple, Optional
from enum import Enum
from scipy.spatial import ConvexHull
from scipy.stats import gaussian_kde


class CoverageType(Enum):
    """Common coverage schemes."""
    COVER_0 = "Cover 0"
    COVER_1 = "Cover 1"
    COVER_2 = "Cover 2"
    COVER_2_MAN = "Cover 2 Man"
    COVER_3 = "Cover 3"
    COVER_4 = "Cover 4"
    COVER_6 = "Cover 6"
    MAN_FREE = "Man Free"
    QUARTERS = "Quarters"


@dataclass
class CoverageZone:
    """Definition of a coverage zone."""
    name: str
    x_min: float  # Yards from LOS
    x_max: float
    y_min: float  # Yards from center (negative = defense's left)
    y_max: float
    color: str = '#3498DB'
    alpha: float = 0.3


@dataclass
class DefenderPosition:
    """Defender position and assignment."""
    defender_id: str
    position: str  # CB, S, LB
    x: float
    y: float
    zone: Optional[str] = None
    man_assignment: Optional[str] = None


@dataclass
class CoverageSnapshot:
    """Coverage at a single moment."""
    play_id: str
    frame: int
    coverage_type: CoverageType
    defenders: List[DefenderPosition]
    shell: str  # "1-high", "2-high", "0-high"


# Define standard zone boundaries
COVERAGE_ZONES = {
    CoverageType.COVER_3: [
        CoverageZone("Deep Left", 15, 50, -26.67, -8, '#2ECC71', 0.3),
        CoverageZone("Deep Middle", 15, 50, -8, 8, '#2ECC71', 0.3),
        CoverageZone("Deep Right", 15, 50, 8, 26.67, '#2ECC71', 0.3),
        CoverageZone("Flat Left", 0, 8, -26.67, -15, '#E74C3C', 0.3),
        CoverageZone("Hook/Curl Left", 5, 15, -15, -5, '#F39C12', 0.3),
        CoverageZone("Hook/Curl Right", 5, 15, 5, 15, '#F39C12', 0.3),
        CoverageZone("Flat Right", 0, 8, 15, 26.67, '#E74C3C', 0.3),
    ],
    CoverageType.COVER_2: [
        CoverageZone("Deep Left", 15, 50, -26.67, 0, '#2ECC71', 0.3),
        CoverageZone("Deep Right", 15, 50, 0, 26.67, '#2ECC71', 0.3),
        CoverageZone("Flat Left", 0, 10, -26.67, -15, '#E74C3C', 0.3),
        CoverageZone("Hook Left", 5, 15, -15, -5, '#F39C12', 0.3),
        CoverageZone("Middle Read", 5, 15, -5, 5, '#9B59B6', 0.3),
        CoverageZone("Hook Right", 5, 15, 5, 15, '#F39C12', 0.3),
        CoverageZone("Flat Right", 0, 10, 15, 26.67, '#E74C3C', 0.3),
    ],
    CoverageType.COVER_4: [
        CoverageZone("Deep Quarter 1", 12, 50, -26.67, -13, '#2ECC71', 0.3),
        CoverageZone("Deep Quarter 2", 12, 50, -13, 0, '#2ECC71', 0.3),
        CoverageZone("Deep Quarter 3", 12, 50, 0, 13, '#2ECC71', 0.3),
        CoverageZone("Deep Quarter 4", 12, 50, 13, 26.67, '#2ECC71', 0.3),
        CoverageZone("Short Left", 0, 12, -26.67, -13, '#E74C3C', 0.3),
        CoverageZone("Short Middle Left", 0, 12, -13, 0, '#F39C12', 0.3),
        CoverageZone("Short Middle Right", 0, 12, 0, 13, '#F39C12', 0.3),
        CoverageZone("Short Right", 0, 12, 13, 26.67, '#E74C3C', 0.3),
    ],
}


class CoverageAnalyzer:
    """Analyze defensive coverage patterns."""

    def __init__(self):
        self.zone_definitions = COVERAGE_ZONES

    def classify_shell(self, defenders: List[DefenderPosition]) -> str:
        """
        Classify coverage shell based on safety positions.

        Returns:
        --------
        str : "2-high", "1-high", or "0-high"
        """
        # Find safeties (players deep and relatively centered)
        deep_defenders = [d for d in defenders if d.x > 10]
        safeties = [d for d in deep_defenders if d.position in ['S', 'FS', 'SS']]

        if len(safeties) == 0:
            # Check for deep cornerbacks
            deep_count = len([d for d in deep_defenders if d.x > 15])
            if deep_count >= 2:
                return "2-high"
            elif deep_count == 1:
                return "1-high"
            return "0-high"

        # Count high safeties (>12 yards deep)
        high_safeties = [s for s in safeties if s.x > 12]

        if len(high_safeties) >= 2:
            return "2-high"
        elif len(high_safeties) == 1:
            return "1-high"
        else:
            return "0-high"

    def classify_coverage(self, snapshot: CoverageSnapshot) -> CoverageType:
        """
        Classify coverage type from defender positions.
        """
        shell = self.classify_shell(snapshot.defenders)

        # Count defenders in box
        box_defenders = [d for d in snapshot.defenders
                        if d.x < 7 and abs(d.y) < 10]

        # Check for man indicators
        # (This would be more sophisticated with receiver positions)

        if shell == "0-high":
            return CoverageType.COVER_0

        elif shell == "1-high":
            # Likely Cover 1 or Cover 3
            if len(box_defenders) >= 7:
                return CoverageType.COVER_1
            else:
                return CoverageType.COVER_3

        else:  # 2-high
            # Likely Cover 2 or Cover 4
            deep_defenders = [d for d in snapshot.defenders if d.x > 15]
            if len(deep_defenders) >= 4:
                return CoverageType.COVER_4
            else:
                return CoverageType.COVER_2

    def find_soft_spots(self, coverage_type: CoverageType,
                       zone_success_rates: Optional[Dict[str, float]] = None
                       ) -> List[Tuple[float, float, float]]:
        """
        Identify soft spots in a coverage scheme.

        Returns:
        --------
        List of (x, y, vulnerability_score) tuples
        """
        if coverage_type not in self.zone_definitions:
            return []

        zones = self.zone_definitions[coverage_type]
        soft_spots = []

        # Find gaps between zones
        for i, zone1 in enumerate(zones):
            for zone2 in zones[i+1:]:
                # Check for horizontal gaps
                if (zone1.x_max == zone2.x_min or
                    zone1.x_min == zone2.x_max):
                    # Vertical seam
                    x = zone1.x_max
                    y = (zone1.y_min + zone1.y_max + zone2.y_min + zone2.y_max) / 4
                    soft_spots.append((x, y, 0.7))

                # Check for vertical gaps
                if (zone1.y_max == zone2.y_min or
                    zone1.y_min == zone2.y_max):
                    # Horizontal seam
                    y = zone1.y_max
                    x = (zone1.x_min + zone1.x_max + zone2.x_min + zone2.x_max) / 4
                    soft_spots.append((x, y, 0.6))

        # Known vulnerability points for each coverage
        if coverage_type == CoverageType.COVER_3:
            # Holes in Cover 3
            soft_spots.append((12, -18, 0.8))  # Flat-to-curl seam
            soft_spots.append((12, 18, 0.8))
            soft_spots.append((15, 0, 0.6))    # Deep middle seam

        elif coverage_type == CoverageType.COVER_2:
            # Holes in Cover 2
            soft_spots.append((18, 0, 0.9))    # Deep middle
            soft_spots.append((5, -22, 0.7))   # Flat corner
            soft_spots.append((5, 22, 0.7))

        elif coverage_type == CoverageType.COVER_4:
            # Holes in Cover 4
            soft_spots.append((10, -7, 0.7))   # Seams
            soft_spots.append((10, 7, 0.7))

        return soft_spots

    def analyze_coverage_tendency(self, snapshots: List[CoverageSnapshot]
                                 ) -> pd.DataFrame:
        """
        Analyze coverage tendencies across multiple plays.
        """
        results = []

        for snap in snapshots:
            coverage = self.classify_coverage(snap)
            shell = self.classify_shell(snap.defenders)

            results.append({
                'play_id': snap.play_id,
                'coverage_type': coverage.value,
                'shell': shell,
                'frame': snap.frame
            })

        df = pd.DataFrame(results)

        # Calculate frequencies
        coverage_freq = df['coverage_type'].value_counts(normalize=True)
        shell_freq = df['shell'].value_counts(normalize=True)

        return df, coverage_freq, shell_freq


class MatchupAnalyzer:
    """Analyze receiver vs coverage matchups."""

    def __init__(self, route_analyzer: RouteAnalyzer,
                coverage_analyzer: CoverageAnalyzer):
        self.route_analyzer = route_analyzer
        self.coverage_analyzer = coverage_analyzer

    def evaluate_route_vs_coverage(self, route: RouteExecution,
                                   coverage_type: CoverageType
                                   ) -> Dict[str, float]:
        """
        Evaluate how well a route attacks a coverage.

        Returns:
        --------
        Dict with matchup scores
        """
        # Get route target location
        x, y = route.get_positions()
        target_x, target_y = x[-1], y[-1]

        # Find soft spots in coverage
        soft_spots = self.coverage_analyzer.find_soft_spots(coverage_type)

        # Calculate distance to nearest soft spot
        min_distance = float('inf')
        nearest_spot = None

        for spot_x, spot_y, vulnerability in soft_spots:
            dist = np.sqrt((target_x - spot_x)**2 + (target_y - spot_y)**2)
            weighted_dist = dist / vulnerability  # Lower is better
            if weighted_dist < min_distance:
                min_distance = weighted_dist
                nearest_spot = (spot_x, spot_y, vulnerability)

        # Calculate matchup grade
        if min_distance < 2:
            matchup_grade = 'A'
            score = 90 + (2 - min_distance) * 5
        elif min_distance < 4:
            matchup_grade = 'B'
            score = 80 + (4 - min_distance) * 2.5
        elif min_distance < 6:
            matchup_grade = 'C'
            score = 70 + (6 - min_distance) * 2.5
        else:
            matchup_grade = 'D'
            score = 60

        return {
            'route_type': route.route_type.value,
            'coverage_type': coverage_type.value,
            'target_x': target_x,
            'target_y': target_y,
            'nearest_soft_spot': nearest_spot,
            'distance_to_soft_spot': min_distance,
            'matchup_grade': matchup_grade,
            'matchup_score': score
        }

    def recommend_routes(self, coverage_type: CoverageType,
                        receiver_position: str = 'X'
                        ) -> List[Dict]:
        """
        Recommend best routes against a coverage.
        """
        soft_spots = self.coverage_analyzer.find_soft_spots(coverage_type)

        # Starting position based on receiver alignment
        if receiver_position == 'X':
            start_y = -20  # Boundary receiver
        elif receiver_position == 'Z':
            start_y = 20   # Field receiver
        else:
            start_y = 5    # Slot

        recommendations = []

        for route_type, template in ROUTE_TEMPLATES.items():
            # Estimate where route ends up
            if route_type == RouteType.POST:
                target_x = template.final_depth
                target_y = start_y * 0.3  # Breaks inside
            elif route_type == RouteType.CORNER:
                target_x = template.final_depth
                target_y = start_y * 1.2  # Breaks outside
            elif route_type in [RouteType.IN, RouteType.SLANT]:
                target_x = template.final_depth
                target_y = start_y * 0.5
            elif route_type == RouteType.OUT:
                target_x = template.final_depth
                target_y = start_y * 1.1
            else:
                target_x = template.final_depth
                target_y = start_y

            # Find distance to soft spots
            min_dist = float('inf')
            for spot_x, spot_y, vuln in soft_spots:
                dist = np.sqrt((target_x - spot_x)**2 + (target_y - spot_y)**2)
                dist = dist / vuln
                min_dist = min(min_dist, dist)

            score = max(0, 100 - min_dist * 10)

            recommendations.append({
                'route_type': route_type.value,
                'target_depth': target_x,
                'target_lateral': target_y,
                'coverage_beater_score': score,
                'recommended': score >= 70
            })

        # Sort by score
        recommendations.sort(key=lambda x: x['coverage_beater_score'], reverse=True)

        return recommendations

Part 3: Visualization System

"""
Route Efficiency and Coverage Analysis System
Part 3: Visualization System
"""

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


class FieldVisualizer:
    """Football field visualization for route/coverage analysis."""

    def __init__(self, half_field: bool = True):
        self.half_field = half_field
        self.field_length = 50 if half_field else 100
        self.field_width = 53.33

    def draw_field(self, ax: plt.Axes) -> plt.Axes:
        """Draw football field."""
        # Field surface
        field = patches.Rectangle(
            (0, -self.field_width/2),
            self.field_length,
            self.field_width,
            facecolor='#228B22',
            edgecolor='white',
            linewidth=2
        )
        ax.add_patch(field)

        # Yard lines
        for yard in range(0, self.field_length + 1, 5):
            ax.axvline(x=yard, color='white', linewidth=1, alpha=0.5)

        # Hash marks (college width)
        hash_y = 13.33  # 40 feet from sideline
        for yard in range(0, self.field_length + 1, 1):
            ax.plot([yard, yard], [hash_y - 0.5, hash_y + 0.5],
                   'w-', linewidth=0.5)
            ax.plot([yard, yard], [-hash_y - 0.5, -hash_y + 0.5],
                   'w-', linewidth=0.5)

        # LOS indicator
        ax.axvline(x=0, color='yellow', linewidth=3, alpha=0.7)

        ax.set_xlim(-5, self.field_length + 5)
        ax.set_ylim(-self.field_width/2 - 3, self.field_width/2 + 3)
        ax.set_aspect('equal')
        ax.set_xlabel('Yards from LOS')
        ax.set_ylabel('Yards from Center')

        return ax


class RouteVisualizer:
    """Visualize route executions."""

    def __init__(self, field_viz: FieldVisualizer):
        self.field_viz = field_viz

    def draw_route(self, ax: plt.Axes, route: RouteExecution,
                  color: str = '#3498DB',
                  show_break: bool = True,
                  show_metrics: bool = False,
                  metrics: Optional[RouteMetrics] = None) -> plt.Axes:
        """
        Draw a route on the field.

        Parameters:
        -----------
        ax : plt.Axes
            Axes to draw on
        route : RouteExecution
            Route data
        color : str
            Route line color
        show_break : bool
            Highlight break point
        show_metrics : bool
            Display route metrics
        metrics : RouteMetrics, optional
            Pre-calculated metrics
        """
        x, y = route.get_positions()

        # Draw route line
        ax.plot(x, y, color=color, linewidth=2.5, solid_capstyle='round')

        # Start marker
        ax.scatter(x[0], y[0], s=150, c=color, marker='o',
                  edgecolor='white', linewidth=2, zorder=5)

        # End arrow
        if len(x) >= 2:
            dx = x[-1] - x[-2]
            dy = y[-1] - y[-2]
            ax.annotate('', xy=(x[-1], y[-1]),
                       xytext=(x[-2], y[-2]),
                       arrowprops=dict(arrowstyle='->', color=color, lw=2))

        # Break point
        if show_break:
            analyzer = RouteAnalyzer()
            break_idx, break_x, break_y = analyzer.calculate_break_point(route)
            ax.scatter(break_x, break_y, s=100, c='white',
                      marker='o', edgecolor=color, linewidth=2, zorder=6)

        # Add metrics text
        if show_metrics and metrics:
            text = (f"{route.route_type.value.upper()}\n"
                   f"Grade: {metrics.grade}\n"
                   f"Eff: {metrics.route_efficiency:.2f}")
            ax.text(x[0], y[0] - 3, text, fontsize=8, ha='center',
                   color=color, weight='bold')

        return ax

    def draw_route_comparison(self, routes: List[RouteExecution],
                             title: str = 'Route Comparison') -> plt.Figure:
        """
        Compare multiple routes on the same field.
        """
        fig, ax = plt.subplots(figsize=(12, 10))
        self.field_viz.draw_field(ax)

        colors = plt.cm.tab10(np.linspace(0, 1, len(routes)))

        for route, color in zip(routes, colors):
            self.draw_route(ax, route, color=color)
            ax.plot([], [], color=color, linewidth=2,
                   label=route.receiver_name)

        ax.legend(loc='upper right')
        ax.set_title(title)

        return fig


class CoverageVisualizer:
    """Visualize coverage zones and soft spots."""

    def __init__(self, field_viz: FieldVisualizer):
        self.field_viz = field_viz

    def draw_zones(self, ax: plt.Axes, coverage_type: CoverageType) -> plt.Axes:
        """Draw coverage zones."""
        if coverage_type not in COVERAGE_ZONES:
            return ax

        zones = COVERAGE_ZONES[coverage_type]

        for zone in zones:
            rect = patches.Rectangle(
                (zone.x_min, zone.y_min),
                zone.x_max - zone.x_min,
                zone.y_max - zone.y_min,
                facecolor=zone.color,
                edgecolor='white',
                alpha=zone.alpha,
                linewidth=1
            )
            ax.add_patch(rect)

            # Zone label
            center_x = (zone.x_min + zone.x_max) / 2
            center_y = (zone.y_min + zone.y_max) / 2
            ax.text(center_x, center_y, zone.name, fontsize=8,
                   ha='center', va='center', color='white', weight='bold')

        return ax

    def draw_soft_spots(self, ax: plt.Axes,
                       coverage_type: CoverageType) -> plt.Axes:
        """Highlight soft spots in coverage."""
        analyzer = CoverageAnalyzer()
        soft_spots = analyzer.find_soft_spots(coverage_type)

        for x, y, vulnerability in soft_spots:
            # Size based on vulnerability
            size = 100 + vulnerability * 200

            ax.scatter(x, y, s=size, c='red', marker='X',
                      alpha=0.7, zorder=10, edgecolor='white')

        return ax

    def draw_coverage_shell(self, ax: plt.Axes,
                           defenders: List[DefenderPosition],
                           show_zones: bool = False,
                           coverage_type: Optional[CoverageType] = None
                           ) -> plt.Axes:
        """
        Draw defenders in their coverage positions.
        """
        # Draw zones if requested
        if show_zones and coverage_type:
            self.draw_zones(ax, coverage_type)

        # Draw defenders
        for defender in defenders:
            if defender.position in ['CB']:
                color = '#E74C3C'  # Red for corners
            elif defender.position in ['S', 'FS', 'SS']:
                color = '#9B59B6'  # Purple for safeties
            else:
                color = '#F39C12'  # Orange for linebackers

            ax.scatter(defender.x, defender.y, s=300, c=color,
                      marker='s', edgecolor='white', linewidth=2, zorder=8)
            ax.text(defender.x, defender.y, defender.position,
                   fontsize=8, ha='center', va='center', color='white',
                   weight='bold')

        return ax


class ReportGenerator:
    """Generate visual reports combining routes and coverage."""

    def __init__(self):
        self.field_viz = FieldVisualizer()
        self.route_viz = RouteVisualizer(self.field_viz)
        self.coverage_viz = CoverageVisualizer(self.field_viz)

    def create_route_report(self, route: RouteExecution,
                           metrics: RouteMetrics) -> plt.Figure:
        """Create detailed report for a single route."""
        fig = plt.figure(figsize=(16, 10))

        # Main route diagram
        ax1 = fig.add_subplot(2, 2, 1)
        self.field_viz.draw_field(ax1)
        self.route_viz.draw_route(ax1, route, show_break=True,
                                 show_metrics=True, metrics=metrics)
        ax1.set_title(f'{route.route_type.value.upper()} Route - {route.receiver_name}')

        # Speed profile
        ax2 = fig.add_subplot(2, 2, 2)
        x, y = route.get_positions()
        timestamps = np.array([p.timestamp for p in route.tracking])

        dx = np.diff(x)
        dy = np.diff(y)
        speeds = np.sqrt(dx**2 + dy**2) / np.diff(timestamps)

        ax2.plot(timestamps[:-1], speeds, 'b-', linewidth=2)
        ax2.axhline(metrics.stem_speed, color='r', linestyle='--',
                   label=f'Stem Speed: {metrics.stem_speed:.1f} yd/s')
        ax2.set_xlabel('Time (s)')
        ax2.set_ylabel('Speed (yd/s)')
        ax2.set_title('Speed Profile')
        ax2.legend()
        ax2.grid(True, alpha=0.3)

        # Metrics panel
        ax3 = fig.add_subplot(2, 2, 3)
        ax3.axis('off')

        metrics_text = f"""
ROUTE METRICS
{'═'*40}

Route Type:        {metrics.route_type.value.upper()}
Grade:             {metrics.grade}

Distance:          {metrics.total_distance:.1f} yards
Efficiency:        {metrics.route_efficiency:.2%}

Stem Speed:        {metrics.stem_speed:.1f} yd/s
Break Sharpness:   {metrics.break_sharpness:.0f} °/s
Break Depth:       {metrics.break_depth:.1f} yards
Time to Break:     {metrics.time_to_break:.2f} s

Separation:
  At Break:        {metrics.separation_at_break:.1f} yards
  Maximum:         {metrics.max_separation:.1f} yards
        """

        ax3.text(0.1, 0.9, metrics_text, fontsize=11, family='monospace',
                va='top', transform=ax3.transAxes)

        # Grade visualization
        ax4 = fig.add_subplot(2, 2, 4)
        grades = ['A', 'B', 'C', 'D', 'F']
        colors = ['#27AE60', '#2ECC71', '#F1C40F', '#E67E22', '#E74C3C']
        grade_idx = grades.index(metrics.grade)

        bars = ax4.barh(grades, [100, 80, 60, 40, 20], color='lightgray')
        bars[grade_idx].set_color(colors[grade_idx])

        ax4.set_xlim(0, 100)
        ax4.set_xlabel('Grade Threshold')
        ax4.set_title('Route Grade')
        ax4.invert_yaxis()

        fig.tight_layout()
        return fig

    def create_coverage_beater_report(self, coverage_type: CoverageType,
                                     recommendations: List[Dict]
                                     ) -> plt.Figure:
        """Create report showing best routes against a coverage."""
        fig = plt.figure(figsize=(16, 12))

        # Coverage zones with soft spots
        ax1 = fig.add_subplot(2, 2, (1, 2))
        self.field_viz.draw_field(ax1)
        self.coverage_viz.draw_zones(ax1, coverage_type)
        self.coverage_viz.draw_soft_spots(ax1, coverage_type)
        ax1.set_title(f'{coverage_type.value} - Zones and Soft Spots')

        # Recommendations table
        ax2 = fig.add_subplot(2, 2, 3)
        ax2.axis('off')

        table_data = []
        for rec in recommendations[:5]:  # Top 5
            table_data.append([
                rec['route_type'].upper(),
                f"{rec['target_depth']:.0f}",
                f"{rec['target_lateral']:.0f}",
                f"{rec['coverage_beater_score']:.0f}",
                '✓' if rec['recommended'] else ''
            ])

        table = ax2.table(
            cellText=table_data,
            colLabels=['Route', 'Depth', 'Lateral', 'Score', 'Recommended'],
            loc='center',
            cellLoc='center'
        )
        table.auto_set_font_size(False)
        table.set_fontsize(10)
        table.scale(1.2, 1.8)

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

        ax2.set_title('Route Recommendations')

        # Score distribution
        ax3 = fig.add_subplot(2, 2, 4)
        route_names = [r['route_type'].upper() for r in recommendations]
        scores = [r['coverage_beater_score'] for r in recommendations]
        colors = ['#27AE60' if r['recommended'] else '#E74C3C'
                 for r in recommendations]

        bars = ax3.barh(route_names, scores, color=colors)
        ax3.axvline(70, color='gray', linestyle='--', alpha=0.5,
                   label='Recommendation threshold')
        ax3.set_xlabel('Coverage Beater Score')
        ax3.set_title('Route Scores vs ' + coverage_type.value)
        ax3.set_xlim(0, 100)
        ax3.legend()

        fig.suptitle(f'Coverage Beater Analysis: {coverage_type.value}',
                    fontsize=16, y=1.02)
        fig.tight_layout()

        return fig

Part 4: Integration and Demonstration

"""
Route Efficiency and Coverage Analysis System
Part 4: Integration and Demonstration
"""

import numpy as np
import pandas as pd
from typing import List


def generate_sample_route() -> RouteExecution:
    """Generate sample slant route for demonstration."""
    np.random.seed(42)

    # Slant route: 3 yards stem, break inside
    tracking = []
    x, y = 0.0, -15.0  # Start outside receiver

    for frame in range(30):  # 3 seconds
        t = frame * 0.1

        if t < 0.3:
            # Pre-snap
            pass
        elif t < 0.8:
            # Stem - run vertical
            x += 0.6 + np.random.normal(0, 0.05)
        else:
            # Break - run slant
            x += 0.5 + np.random.normal(0, 0.05)
            y += 0.6 + np.random.normal(0, 0.05)

        tracking.append(TrackingPoint(
            frame=frame,
            timestamp=t,
            x=x,
            y=y
        ))

    return RouteExecution(
        play_id='PLAY001',
        receiver_id='WR11',
        receiver_name='DeVonta Smith',
        route_type=RouteType.SLANT,
        tracking=tracking,
        target=True,
        completion=True
    )


def generate_sample_defenders() -> List[DefenderPosition]:
    """Generate sample Cover 3 defense."""
    return [
        DefenderPosition('D1', 'CB', 1, -22, zone='Flat Left'),
        DefenderPosition('D2', 'CB', 1, 22, zone='Flat Right'),
        DefenderPosition('D3', 'S', 18, -15, zone='Deep Left'),
        DefenderPosition('D4', 'S', 25, 0, zone='Deep Middle'),
        DefenderPosition('D5', 'S', 18, 15, zone='Deep Right'),
        DefenderPosition('D6', 'LB', 5, -8, zone='Hook/Curl'),
        DefenderPosition('D7', 'LB', 5, 8, zone='Hook/Curl'),
    ]


# =============================================================================
# MAIN DEMONSTRATION
# =============================================================================

if __name__ == "__main__":
    print("=" * 70)
    print("ROUTE EFFICIENCY AND COVERAGE ANALYSIS SYSTEM")
    print("=" * 70)

    # Initialize analyzers
    route_analyzer = RouteAnalyzer()
    coverage_analyzer = CoverageAnalyzer()
    matchup_analyzer = MatchupAnalyzer(route_analyzer, coverage_analyzer)

    # Generate sample data
    print("\nGenerating sample data...")
    route = generate_sample_route()
    defenders = generate_sample_defenders()

    # Analyze route
    print("\nAnalyzing route execution...")
    metrics = route_analyzer.analyze_route(route)
    print(f"  Route Type: {metrics.route_type.value}")
    print(f"  Grade: {metrics.grade}")
    print(f"  Efficiency: {metrics.route_efficiency:.2%}")
    print(f"  Break Sharpness: {metrics.break_sharpness:.0f} °/s")

    # Analyze coverage
    print("\nAnalyzing coverage...")
    snapshot = CoverageSnapshot(
        play_id='PLAY001',
        frame=0,
        coverage_type=CoverageType.COVER_3,
        defenders=defenders,
        shell='1-high'
    )

    classified = coverage_analyzer.classify_coverage(snapshot)
    print(f"  Classified Coverage: {classified.value}")

    # Get route recommendations
    print("\nGenerating route recommendations vs Cover 3...")
    recommendations = matchup_analyzer.recommend_routes(
        CoverageType.COVER_3, 'X'
    )

    for i, rec in enumerate(recommendations[:5]):
        status = "RECOMMENDED" if rec['recommended'] else ""
        print(f"  {i+1}. {rec['route_type']:10} Score: {rec['coverage_beater_score']:.0f} {status}")

    # Generate visualizations
    print("\nGenerating reports...")

    field_viz = FieldVisualizer()
    route_viz = RouteVisualizer(field_viz)
    coverage_viz = CoverageVisualizer(field_viz)
    report_gen = ReportGenerator()

    # Route report
    fig1 = report_gen.create_route_report(route, metrics)
    fig1.savefig('route_analysis_report.png', dpi=150, bbox_inches='tight')
    print("  Saved: route_analysis_report.png")

    # Coverage beater report
    fig2 = report_gen.create_coverage_beater_report(
        CoverageType.COVER_3, recommendations
    )
    fig2.savefig('coverage_beater_report.png', dpi=150, bbox_inches='tight')
    print("  Saved: coverage_beater_report.png")

    # Coverage zone visualization
    fig3, ax3 = plt.subplots(figsize=(12, 10))
    field_viz.draw_field(ax3)
    coverage_viz.draw_coverage_shell(ax3, defenders, show_zones=True,
                                     coverage_type=CoverageType.COVER_3)
    ax3.set_title('Cover 3 Defense - Zones and Defenders')
    fig3.savefig('coverage_zones.png', dpi=150, bbox_inches='tight')
    print("  Saved: coverage_zones.png")

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

Key Insights

Route Analysis Findings

  1. Efficiency Metric: Route efficiency (ideal distance / actual distance) quickly identifies receivers who waste motion through rounded breaks or drift.

  2. Break Sharpness: Measured as degrees per second at the break point, this metric distinguishes elite route runners who can change direction quickly.

  3. Speed Maintenance: The best route runners maintain high stem speed before decelerating only at the last moment before the break.

Coverage Analysis Findings

  1. Shell Recognition: Pre-snap safety positions provide reliable indicators of coverage type, enabling quick identification of soft spots.

  2. Zone Vulnerability Mapping: Every zone coverage has predictable soft spots where routes can find open space between defenders.

  3. Matchup Optimization: Combining route templates with zone maps enables data-driven play calling against specific coverages.

Exercises

  1. Add Man Coverage Analysis: Extend the system to analyze man coverage by tracking receiver-defender distances throughout routes.

  2. Build Route Comparison Tool: Create a visualization comparing a receiver's routes to the ideal template for each route type.

  3. Create Game Plan Generator: Build a tool that takes an opponent's coverage tendencies and generates recommended passing concepts.

Applications

  • Pre-game preparation: Identify coverage tendencies and prepare route adjustments
  • Player development: Provide receivers with quantified feedback on route running
  • In-game adjustments: Quick identification of coverage soft spots between series
  • Recruiting evaluation: Compare prospect route running to college/NFL benchmarks

Further Reading

  • Route running metrics in football analytics literature
  • Zone coverage theory and deployment strategies
  • Tracking data applications in professional football