BakoAI / analytics_engine /decision_quality.py
Okidi Norbert
Deployment fix: clean backend only
c6abe34
"""
Decision Quality Engine - Analyzes shot decision quality.
Compares shooter's contested distance vs. teammate openness to evaluate
whether the shot was the best available option.
"""
from typing import Dict, Any, List
import numpy as np
from .base import BaseAnalyticsModule
class DecisionQualityEngine(BaseAnalyticsModule):
"""Analyzes shot decision quality based on player openness."""
def __init__(
self,
open_threshold_m: float = 2.5,
contested_threshold_m: float = 1.5,
):
"""
Initialize decision quality engine.
Args:
open_threshold_m: Distance to defender for "open" classification
contested_threshold_m: Distance to defender for "contested" classification
"""
super().__init__("decision_quality")
self.open_threshold = open_threshold_m
self.contested_threshold = contested_threshold_m
def process(
self,
video_frames: List[Any],
player_tracks: List[Dict],
ball_tracks: List[Dict],
tactical_positions: List[Dict],
player_assignment: List[Dict],
ball_possession: List[int],
events: List[Dict],
shots: List[Dict],
court_keypoints: List[Dict],
speeds: List[Dict],
video_path: str,
fps: float,
**kwargs
) -> Dict[str, Any]:
"""
Analyze decision quality for all shots.
Returns:
Dictionary with decision analysis for each shot
"""
decision_analyses = []
for shot in shots:
frame = shot.get("start_frame", shot.get("frame", 0))
if frame >= len(player_assignment) or frame >= len(tactical_positions):
continue
assignment = player_assignment[frame]
tactical_pos = tactical_positions[frame]
shooter_id = shot.get("player_id")
if not shooter_id or shooter_id not in assignment:
continue
offense_team = assignment[shooter_id]
defense_team = 1 if offense_team == 2 else 2
shooter_pos = tactical_pos.get(shooter_id)
if not shooter_pos:
continue
# Find nearest defender to shooter
shooter_contested_distance = float('inf')
for player_id, team_id in assignment.items():
if team_id == defense_team and player_id in tactical_pos:
defender_pos = tactical_pos[player_id]
dist = self._euclidean_distance(shooter_pos, defender_pos)
if dist < shooter_contested_distance:
shooter_contested_distance = dist
# Analyze teammates' openness
open_teammates = 0
best_teammate_openness = 0.0
teammate_data = []
for player_id, team_id in assignment.items():
if team_id == offense_team and player_id != shooter_id and player_id in tactical_pos:
teammate_pos = tactical_pos[player_id]
# Find nearest defender to this teammate
min_defender_dist = float('inf')
for def_id, def_team in assignment.items():
if def_team == defense_team and def_id in tactical_pos:
def_pos = tactical_pos[def_id]
dist = self._euclidean_distance(teammate_pos, def_pos)
if dist < min_defender_dist:
min_defender_dist = dist
teammate_data.append({
"player_id": player_id,
"defender_distance": float(min_defender_dist)
})
if min_defender_dist > self.open_threshold:
open_teammates += 1
if min_defender_dist > best_teammate_openness:
best_teammate_openness = min_defender_dist
# Determine decision quality
shooter_is_contested = shooter_contested_distance < self.contested_threshold
has_open_teammate = open_teammates > 0
if shooter_is_contested and has_open_teammate:
# Bad decision: shooter contested but open teammate available
decision_quality = "low_expected_value"
elif not shooter_is_contested:
# Good decision: shooter is open
decision_quality = "high_expected_value"
else:
# Acceptable: shooter contested but no better options
decision_quality = "acceptable"
decision_analyses.append({
"event_id": f"shot_{frame}",
"shot_frame": frame,
"shooter_track_id": shooter_id,
"shooter_contested_distance": float(shooter_contested_distance),
"open_teammates": open_teammates,
"best_teammate_openness": float(best_teammate_openness) if best_teammate_openness > 0 else None,
"decision_quality": decision_quality,
"teammate_positions": teammate_data,
"shot_outcome": shot.get("outcome", "unknown")
})
# Calculate summary statistics
if decision_analyses:
quality_counts = {
"high_expected_value": 0,
"acceptable": 0,
"low_expected_value": 0
}
for analysis in decision_analyses:
quality_counts[analysis["decision_quality"]] += 1
total = len(decision_analyses)
summary = {
"total_shots_analyzed": total,
"high_ev_shots": quality_counts["high_expected_value"],
"acceptable_shots": quality_counts["acceptable"],
"low_ev_shots": quality_counts["low_expected_value"],
"low_ev_rate": (quality_counts["low_expected_value"] / total * 100) if total > 0 else 0,
"avg_shooter_contested_distance": float(np.mean([
a["shooter_contested_distance"] for a in decision_analyses
])),
}
else:
summary = {
"total_shots_analyzed": 0,
"high_ev_shots": 0,
"acceptable_shots": 0,
"low_ev_shots": 0,
"low_ev_rate": 0,
"avg_shooter_contested_distance": 0,
}
return {
"decision_analyses": decision_analyses,
"summary": summary,
"status": "success"
}