Case Study 2: Building a Coaching Staff Decision Support System

Overview

This case study develops a real-time decision support system for coaching staffs, providing in-game analytics, play recommendations, and situational analysis via tablets on the sideline.

Business Context

A Power Five football program wants to: - Provide coaches with real-time win probability and analytics - Support fourth-down, timeout, and clock management decisions - Analyze opponent tendencies during games - Track personnel groupings and substitution patterns - Integrate with existing video review systems

Requirements

requirements = {
    'latency': {
        'decision_analysis': '<2s',
        'tendency_update': '<5s',
        'video_sync': '<1s'
    },
    'reliability': {
        'uptime': '99.99% during games',
        'offline_capability': True,
        'data_redundancy': 'local + cloud'
    },
    'usability': {
        'one_tap_access': True,
        'glove_compatible': True,
        'sunlight_readable': True
    },
    'integration': {
        'video_system': 'Hudl Sideline',
        'stat_feed': 'NCAA Official',
        'personnel_tracking': 'RFID'
    }
}

System Design

SIDELINE DECISION SUPPORT ARCHITECTURE
======================================

               ┌─────────────────────────────────────┐
               │         Press Box Server            │
               │  ┌─────────────────────────────┐   │
               │  │  Analytics Engine (Primary)  │   │
               │  └───────────┬─────────────────┘   │
               └──────────────┼──────────────────────┘
                              │
              ┌───────────────┼───────────────┐
              │               │               │
              ▼               ▼               ▼
       ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
       │ HC Tablet   │ │ OC Tablet   │ │ DC Tablet   │
       │             │ │             │ │             │
       │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │
       │ │Decision │ │ │ │Play     │ │ │ │Coverage │ │
       │ │Support  │ │ │ │Suggest  │ │ │ │Analysis │ │
       │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │
       │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │
       │ │Win Prob │ │ │ │Tendency │ │ │ │Tendency │ │
       │ │Clock    │ │ │ │Charts   │ │ │ │Charts   │ │
       │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │
       └─────────────┘ └─────────────┘ └─────────────┘
              │               │               │
              └───────────────┼───────────────┘
                              │
                    ┌─────────▼─────────┐
                    │  Local Mesh WiFi  │
                    │  (Backup Network) │
                    └───────────────────┘

Implementation

Step 1: Core Analytics Engine

from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from datetime import datetime
import numpy as np
from collections import defaultdict

@dataclass
class GameSituation:
    """Current game situation."""
    quarter: int
    time_remaining: float
    home_score: int
    away_score: int
    possession: str
    field_position: int
    down: int
    distance: int
    timeouts_home: int
    timeouts_away: int
    is_home_team: bool


class DecisionSupportEngine:
    """Core decision support analytics engine."""

    def __init__(self, team_name: str, is_home: bool):
        self.team_name = team_name
        self.is_home = is_home
        self.play_history = []
        self.opponent_tendencies = OpponentTendencyTracker()
        self.personnel_tracker = PersonnelTracker()

    def analyze_fourth_down(self, situation: GameSituation) -> Dict:
        """Comprehensive fourth-down analysis."""
        analysis = {
            'situation': self._format_situation(situation),
            'options': {},
            'recommendation': None,
            'confidence': 0.0,
            'historical_context': None
        }

        # Calculate each option
        go_for_it = self._analyze_go_for_it(situation)
        analysis['options']['go_for_it'] = go_for_it

        # Field goal if in range
        if situation.field_position >= 55:
            fg = self._analyze_field_goal(situation)
            analysis['options']['field_goal'] = fg

        # Punt if not in FG range
        if situation.field_position < 70:
            punt = self._analyze_punt(situation)
            analysis['options']['punt'] = punt

        # Determine recommendation
        best_option = max(
            analysis['options'].items(),
            key=lambda x: x[1]['expected_wp']
        )
        analysis['recommendation'] = best_option[0]
        analysis['confidence'] = self._calculate_confidence(analysis['options'])

        # Add historical context
        analysis['historical_context'] = self._get_historical_context(situation)

        return analysis

    def _analyze_go_for_it(self, situation: GameSituation) -> Dict:
        """Analyze going for it on fourth down."""
        conversion_prob = self._get_conversion_probability(
            situation.distance,
            situation.field_position
        )

        # Expected states after conversion or failure
        success_wp = self._calculate_wp_after_conversion(situation)
        fail_wp = self._calculate_wp_after_turnover(situation)

        expected_wp = conversion_prob * success_wp + (1 - conversion_prob) * fail_wp

        return {
            'conversion_prob': conversion_prob,
            'success_wp': success_wp,
            'fail_wp': fail_wp,
            'expected_wp': expected_wp,
            'breakeven_conv_rate': self._calculate_breakeven(success_wp, fail_wp)
        }

    def _analyze_field_goal(self, situation: GameSituation) -> Dict:
        """Analyze field goal attempt."""
        fg_distance = 117 - situation.field_position
        make_prob = self._get_fg_probability(fg_distance)

        make_wp = self._calculate_wp_after_fg_make(situation)
        miss_wp = self._calculate_wp_after_fg_miss(situation)

        expected_wp = make_prob * make_wp + (1 - make_prob) * miss_wp

        return {
            'distance': fg_distance,
            'make_prob': make_prob,
            'make_wp': make_wp,
            'miss_wp': miss_wp,
            'expected_wp': expected_wp
        }

    def _analyze_punt(self, situation: GameSituation) -> Dict:
        """Analyze punting."""
        expected_net = self._get_expected_punt_net(situation.field_position)
        opponent_start = min(100 - situation.field_position - expected_net, 80)

        punt_wp = self._calculate_wp_after_punt(situation, opponent_start)

        return {
            'expected_net': expected_net,
            'opponent_start': opponent_start,
            'expected_wp': punt_wp
        }

    def _get_conversion_probability(self, distance: int, field_position: int) -> float:
        """Get conversion probability based on distance and field position."""
        # Base probabilities by distance
        base_probs = {
            1: 0.72, 2: 0.60, 3: 0.53, 4: 0.47, 5: 0.42,
            6: 0.38, 7: 0.35, 8: 0.32, 9: 0.30, 10: 0.28
        }

        base = base_probs.get(min(distance, 10), 0.20)

        # Red zone adjustment (slightly harder)
        if field_position >= 90:
            base *= 0.95

        return base

    def _get_fg_probability(self, distance: int) -> float:
        """Get field goal probability by distance."""
        if distance <= 30:
            return 0.95
        elif distance <= 40:
            return 0.87
        elif distance <= 45:
            return 0.78
        elif distance <= 50:
            return 0.65
        elif distance <= 55:
            return 0.50
        else:
            return 0.35

    def analyze_timeout_decision(self, situation: GameSituation) -> Dict:
        """Analyze whether to call timeout."""
        analysis = {
            'should_call': False,
            'reason': '',
            'value_saved': 0.0,
            'scenarios': []
        }

        our_timeouts = situation.timeouts_home if self.is_home else situation.timeouts_away

        # Check if we have timeouts
        if our_timeouts == 0:
            analysis['reason'] = 'No timeouts remaining'
            return analysis

        # Late game clock management
        if situation.quarter == 4 and situation.time_remaining < 120:
            time_value = self._calculate_time_value(situation)
            if time_value > 0.02:  # Worth 2% WP
                analysis['should_call'] = True
                analysis['reason'] = f'Save {time_value*100:.1f}% win probability'
                analysis['value_saved'] = time_value

        return analysis

    def _calculate_time_value(self, situation: GameSituation) -> float:
        """Calculate value of saving time in late-game situations."""
        score_diff = (situation.home_score - situation.away_score) * \
                     (1 if self.is_home else -1)

        if score_diff < 0:  # We're losing
            # Time is very valuable
            return 0.005 * abs(score_diff) * (120 - situation.time_remaining) / 60
        else:
            # Time value for opponent possession
            return 0.002 * (120 - situation.time_remaining) / 60

    def _format_situation(self, situation: GameSituation) -> str:
        """Format situation as readable string."""
        downs = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th'}
        return f"Q{situation.quarter} {int(situation.time_remaining//60)}:{int(situation.time_remaining%60):02d} - " \
               f"{downs.get(situation.down, '?')} & {situation.distance} at " \
               f"{'OPP' if situation.field_position > 50 else 'OWN'} {situation.field_position if situation.field_position <= 50 else 100-situation.field_position}"


class OpponentTendencyTracker:
    """Track and analyze opponent tendencies."""

    def __init__(self):
        self.plays = []
        self.formation_counts = defaultdict(int)
        self.play_type_by_situation = defaultdict(lambda: defaultdict(int))

    def record_play(self, play: Dict):
        """Record opponent play."""
        self.plays.append(play)

        # Update formation counts
        formation = play.get('formation', 'unknown')
        self.formation_counts[formation] += 1

        # Update play type by situation
        situation_key = self._get_situation_key(play)
        play_type = play.get('play_type', 'unknown')
        self.play_type_by_situation[situation_key][play_type] += 1

    def _get_situation_key(self, play: Dict) -> str:
        """Get situation category key."""
        down = play.get('down', 1)
        distance = play.get('distance', 10)

        if down == 1:
            return '1st_down'
        elif down == 2:
            if distance <= 3:
                return '2nd_short'
            elif distance >= 7:
                return '2nd_long'
            else:
                return '2nd_medium'
        elif down == 3:
            if distance <= 3:
                return '3rd_short'
            elif distance >= 7:
                return '3rd_long'
            else:
                return '3rd_medium'
        else:
            return '4th_down'

    def get_tendency_report(self) -> Dict:
        """Get current tendency report."""
        if not self.plays:
            return {'message': 'No data yet'}

        total_plays = len(self.plays)
        run_plays = sum(1 for p in self.plays if p.get('play_type') == 'run')

        report = {
            'total_plays': total_plays,
            'run_percentage': run_plays / total_plays if total_plays > 0 else 0,
            'pass_percentage': (total_plays - run_plays) / total_plays if total_plays > 0 else 0,
            'by_situation': {},
            'formation_breakdown': dict(self.formation_counts)
        }

        # Situation breakdown
        for situation, plays in self.play_type_by_situation.items():
            total = sum(plays.values())
            report['by_situation'][situation] = {
                'total': total,
                'run_pct': plays.get('run', 0) / total if total > 0 else 0,
                'pass_pct': plays.get('pass', 0) / total if total > 0 else 0
            }

        return report

    def predict_play_type(self, situation: Dict) -> Dict:
        """Predict likely play type for situation."""
        situation_key = self._get_situation_key(situation)
        plays = self.play_type_by_situation[situation_key]

        total = sum(plays.values())
        if total == 0:
            return {'run': 0.5, 'pass': 0.5, 'confidence': 'low'}

        return {
            'run': plays.get('run', 0) / total,
            'pass': plays.get('pass', 0) / total,
            'confidence': 'high' if total > 10 else 'medium' if total > 5 else 'low'
        }


class PersonnelTracker:
    """Track personnel groupings."""

    def __init__(self):
        self.personnel_history = []
        self.grouping_stats = defaultdict(lambda: {'plays': 0, 'success': 0})

    def record_personnel(self, play: Dict):
        """Record personnel grouping for a play."""
        grouping = play.get('personnel', 'unknown')
        success = play.get('success', False)

        self.personnel_history.append({
            'grouping': grouping,
            'success': success,
            'play_type': play.get('play_type', 'unknown')
        })

        self.grouping_stats[grouping]['plays'] += 1
        if success:
            self.grouping_stats[grouping]['success'] += 1

    def get_personnel_report(self) -> Dict:
        """Get personnel usage report."""
        report = {}

        for grouping, stats in self.grouping_stats.items():
            plays = stats['plays']
            report[grouping] = {
                'plays': plays,
                'success_rate': stats['success'] / plays if plays > 0 else 0,
                'usage_pct': plays / len(self.personnel_history) if self.personnel_history else 0
            }

        return report

Step 2: Tablet Interface Service

from flask import Flask, jsonify, request
from flask_socketio import SocketIO, emit

class TabletInterfaceService:
    """Service for tablet communication."""

    def __init__(self, engine: DecisionSupportEngine):
        self.app = Flask(__name__)
        self.socketio = SocketIO(self.app, cors_allowed_origins="*")
        self.engine = engine
        self._setup_routes()
        self._setup_socket_handlers()

    def _setup_routes(self):
        """Setup HTTP routes."""

        @self.app.route('/api/situation', methods=['GET'])
        def get_situation():
            # Return current game situation
            return jsonify(self._get_current_situation())

        @self.app.route('/api/fourth-down', methods=['POST'])
        def analyze_fourth_down():
            data = request.json
            situation = GameSituation(**data)
            analysis = self.engine.analyze_fourth_down(situation)
            return jsonify(analysis)

        @self.app.route('/api/tendencies', methods=['GET'])
        def get_tendencies():
            return jsonify(self.engine.opponent_tendencies.get_tendency_report())

        @self.app.route('/api/personnel', methods=['GET'])
        def get_personnel():
            return jsonify(self.engine.personnel_tracker.get_personnel_report())

    def _setup_socket_handlers(self):
        """Setup WebSocket handlers."""

        @self.socketio.on('connect')
        def handle_connect():
            # Send current state on connection
            emit('situation_update', self._get_current_situation())

        @self.socketio.on('request_analysis')
        def handle_analysis_request(data):
            if data.get('type') == 'fourth_down':
                situation = GameSituation(**data.get('situation', {}))
                analysis = self.engine.analyze_fourth_down(situation)
                emit('fourth_down_analysis', analysis)

    def broadcast_update(self, update_type: str, data: Dict):
        """Broadcast update to all connected tablets."""
        self.socketio.emit(update_type, data)

    def _get_current_situation(self) -> Dict:
        """Get current game situation summary."""
        # Would pull from game state tracker
        return {
            'quarter': 3,
            'time': '8:42',
            'score': {'us': 21, 'them': 17},
            'possession': 'us',
            'situation': '2nd & 7',
            'win_prob': 0.68
        }

    def run(self, host: str = '0.0.0.0', port: int = 5000):
        """Run the service."""
        self.socketio.run(self.app, host=host, port=port)

Step 3: Quick Decision Cards

class QuickDecisionCards:
    """Pre-computed decision cards for common situations."""

    def __init__(self):
        self.cards = self._generate_cards()

    def _generate_cards(self) -> Dict:
        """Pre-generate decision cards for common situations."""
        cards = {}

        # Fourth down cards
        for distance in range(1, 11):
            for field_pos in range(25, 100, 5):
                key = f"4th_{distance}_{field_pos}"
                cards[key] = self._compute_fourth_down_card(distance, field_pos)

        # Timeout cards (by time remaining and score diff)
        for time_remaining in [120, 90, 60, 30, 15]:
            for score_diff in range(-14, 15, 7):
                key = f"timeout_{time_remaining}_{score_diff}"
                cards[key] = self._compute_timeout_card(time_remaining, score_diff)

        return cards

    def _compute_fourth_down_card(self, distance: int, field_pos: int) -> Dict:
        """Compute decision card for fourth down."""
        # Pre-computed recommendations
        go_threshold = self._get_go_for_it_threshold(distance, field_pos)

        return {
            'distance': distance,
            'field_position': field_pos,
            'recommendation': 'GO' if field_pos >= go_threshold else 'PUNT/FG',
            'breakeven_conversion': self._get_breakeven(distance, field_pos),
            'quick_note': self._get_quick_note(distance, field_pos)
        }

    def _get_go_for_it_threshold(self, distance: int, field_pos: int) -> int:
        """Get field position threshold for going for it."""
        # Simplified thresholds
        if distance <= 2:
            return 30  # Go for it from own 30+
        elif distance <= 5:
            return 50  # Go for it from midfield+
        else:
            return 65  # Go for it from opponent 35+

    def _get_breakeven(self, distance: int, field_pos: int) -> float:
        """Get breakeven conversion rate."""
        # Simplified calculation
        if field_pos >= 90:  # Red zone
            return 0.35
        elif field_pos >= 70:
            return 0.45
        elif field_pos >= 50:
            return 0.55
        else:
            return 0.65

    def _get_quick_note(self, distance: int, field_pos: int) -> str:
        """Get quick decision note."""
        if distance <= 1:
            return "Short yardage - high success rate expected"
        elif field_pos >= 90:
            return "Red zone - points likely either way"
        elif field_pos <= 35:
            return "Own territory - punt gives better field position"
        else:
            return "Midfield - consider game situation"

    def _compute_timeout_card(self, time: int, score_diff: int) -> Dict:
        """Compute timeout usage card."""
        return {
            'time_remaining': time,
            'score_diff': score_diff,
            'save_timeout': time > 60 or abs(score_diff) > 8,
            'use_timeout': time <= 30 and abs(score_diff) <= 8,
            'note': self._get_timeout_note(time, score_diff)
        }

    def _get_timeout_note(self, time: int, score_diff: int) -> str:
        """Get timeout usage note."""
        if score_diff <= -8:
            return "Down multiple scores - every second counts"
        elif score_diff >= 8:
            return "Up multiple scores - save for emergencies"
        elif time <= 30:
            return "Final 30 seconds - use if needed for drive"
        else:
            return "Manage clock, preserve options"

    def get_card(self, card_type: str, **kwargs) -> Dict:
        """Get pre-computed card."""
        if card_type == 'fourth_down':
            key = f"4th_{kwargs.get('distance', 1)}_{kwargs.get('field_pos', 25)}"
        elif card_type == 'timeout':
            key = f"timeout_{kwargs.get('time', 60)}_{kwargs.get('score_diff', 0)}"
        else:
            return {'error': 'Unknown card type'}

        return self.cards.get(key, {'error': 'Card not found'})

Results

System Performance

SIDELINE SYSTEM METRICS (2024 SEASON)
=====================================

Reliability:
- Uptime: 99.97% (2 total incidents)
- Average Response Time: 847ms
- Offline Mode Activations: 3 games

Usage:
- Fourth Down Analyses: 234
- Timeout Recommendations: 156
- Tendency Queries: 1,892
- Total Interactions: 4,521

Decision Outcomes:
- Fourth Down Recommendations Followed: 78%
- Outcome When Followed: 67% success rate
- Outcome When Not Followed: 42% success rate

Coaching Adoption

COACH SURVEY RESULTS (N=12)
===========================

"System improved my decision-making": 92% agree
"Information was timely": 83% agree
"Would recommend to other programs": 100% agree

Most Valuable Features:
1. Fourth-down analysis: 92%
2. Opponent tendency tracking: 75%
3. Real-time win probability: 67%
4. Timeout recommendations: 50%

Areas for Improvement:
- Faster updates (17%)
- More play suggestions (25%)
- Better integration with video (33%)

Competitive Impact

DECISION QUALITY IMPROVEMENT
============================

Fourth Down Decisions:
- Pre-system conservative rate: 68%
- Post-system conservative rate: 41%
- Expected WP gained from better decisions: +0.8 per game

Timeout Usage:
- Pre-system wasted timeouts: 1.2 per game
- Post-system wasted timeouts: 0.4 per game

Overall:
- Estimated wins added: 0.5-1.0 per season

Lessons Learned

  1. Simplicity Wins: Coaches want one-tap answers, not detailed analysis
  2. Trust Takes Time: Required half a season for full adoption
  3. Offline Critical: Stadium WiFi fails more often than expected
  4. Context Matters: Same data means different things to different coaches
  5. Speed Over Perfection: A good answer now beats a perfect answer later

Recommendations

  1. Pre-Compute Common Situations: Quick decision cards for standard scenarios
  2. Role-Based Views: Different interfaces for HC, OC, DC
  3. Practice Integration: Let coaches use system during practice
  4. Post-Game Review: Show where recommendations helped/hurt
  5. Continuous Feedback: Track decisions and outcomes for improvement