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
- Simplicity Wins: Coaches want one-tap answers, not detailed analysis
- Trust Takes Time: Required half a season for full adoption
- Offline Critical: Stadium WiFi fails more often than expected
- Context Matters: Same data means different things to different coaches
- Speed Over Perfection: A good answer now beats a perfect answer later
Recommendations
- Pre-Compute Common Situations: Quick decision cards for standard scenarios
- Role-Based Views: Different interfaces for HC, OC, DC
- Practice Integration: Let coaches use system during practice
- Post-Game Review: Show where recommendations helped/hurt
- Continuous Feedback: Track decisions and outcomes for improvement