Spaces:
Running
Running
| """ | |
| Event prioritization for organic F1 commentary generation. | |
| This module implements the Event_Prioritizer component that assigns significance | |
| scores to race events and filters out low-priority events to focus commentary | |
| on important moments. | |
| """ | |
| from typing import Optional | |
| from reachy_f1_commentator.src.enhanced_models import ContextData, SignificanceScore | |
| from reachy_f1_commentator.src.models import EventType, RaceEvent | |
| class SignificanceCalculator: | |
| """ | |
| Calculates significance scores for race events. | |
| Assigns base scores based on event type and position, then applies | |
| context bonuses for championship contenders, narratives, close gaps, | |
| tire differentials, and other factors. | |
| """ | |
| def __init__(self): | |
| """Initialize the significance calculator.""" | |
| pass | |
| def calculate_significance( | |
| self, | |
| event: RaceEvent, | |
| context: ContextData | |
| ) -> SignificanceScore: | |
| """ | |
| Calculate significance score for an event with context. | |
| Args: | |
| event: The race event to score | |
| context: Enriched context data for the event | |
| Returns: | |
| SignificanceScore with base score, bonuses, and total | |
| """ | |
| # Calculate base score | |
| base_score = self._base_score_for_event(event, context) | |
| # Apply context bonuses | |
| context_bonus, reasons = self._apply_context_bonuses(context) | |
| # Calculate total (capped at 100) | |
| total_score = min(base_score + context_bonus, 100) | |
| # Build reasons list | |
| all_reasons = [f"Base score: {base_score}"] | |
| all_reasons.extend(reasons) | |
| return SignificanceScore( | |
| base_score=base_score, | |
| context_bonus=context_bonus, | |
| total_score=total_score, | |
| reasons=all_reasons | |
| ) | |
| def _base_score_for_event( | |
| self, | |
| event: RaceEvent, | |
| context: ContextData | |
| ) -> int: | |
| """ | |
| Calculate base significance score based on event type and position. | |
| Scoring rules: | |
| - Lead change: 100 | |
| - Overtake P1-P3: 90 | |
| - Overtake P4-P6: 70 | |
| - Overtake P7-P10: 50 | |
| - Overtake P11+: 30 | |
| - Pit stop (leader): 80 | |
| - Pit stop (P2-P5): 60 | |
| - Pit stop (P6-P10): 40 | |
| - Pit stop (P11+): 20 | |
| - Fastest lap (leader): 70 | |
| - Fastest lap (other): 50 | |
| - Incident: 95 | |
| - Safety car: 100 | |
| Args: | |
| event: The race event | |
| context: Context data with position information | |
| Returns: | |
| Base score (0-100) | |
| """ | |
| event_type = event.event_type | |
| # Lead change - highest priority | |
| if event_type == EventType.LEAD_CHANGE: | |
| return 100 | |
| # Safety car - highest priority | |
| if event_type == EventType.SAFETY_CAR: | |
| return 100 | |
| # Incident - very high priority | |
| if event_type == EventType.INCIDENT: | |
| return 95 | |
| # Overtake - score by position | |
| if event_type == EventType.OVERTAKE: | |
| position = context.position_after | |
| if position is None: | |
| # Fallback if position not available | |
| return 50 | |
| if position <= 3: | |
| return 90 | |
| elif position <= 6: | |
| return 70 | |
| elif position <= 10: | |
| return 50 | |
| else: | |
| return 30 | |
| # Pit stop - score by position | |
| if event_type == EventType.PIT_STOP: | |
| position = context.position_before | |
| if position is None: | |
| # Fallback if position not available | |
| return 40 | |
| if position == 1: | |
| return 80 | |
| elif position <= 5: | |
| return 60 | |
| elif position <= 10: | |
| return 40 | |
| else: | |
| return 20 | |
| # Fastest lap - score by whether it's the leader | |
| if event_type == EventType.FASTEST_LAP: | |
| position = context.position_after or context.position_before | |
| if position == 1: | |
| return 70 | |
| else: | |
| return 50 | |
| # Flag events - medium priority | |
| if event_type == EventType.FLAG: | |
| return 60 | |
| # Position update - low priority | |
| if event_type == EventType.POSITION_UPDATE: | |
| return 20 | |
| # Default for unknown event types | |
| return 30 | |
| def _apply_context_bonuses( | |
| self, | |
| context: ContextData | |
| ) -> tuple[int, list[str]]: | |
| """ | |
| Apply context bonuses to base score. | |
| Bonuses: | |
| - Championship contender (top 5): +20 | |
| - Active battle narrative: +15 | |
| - Active comeback narrative: +15 | |
| - Gap < 1s: +10 | |
| - Tire age differential > 5 laps: +10 | |
| - DRS available: +5 | |
| - Purple sector: +10 | |
| - Weather impact: +5 | |
| - First of session: +10 | |
| Args: | |
| context: Enriched context data | |
| Returns: | |
| Tuple of (total_bonus, list of reason strings) | |
| """ | |
| total_bonus = 0 | |
| reasons = [] | |
| # Championship contender bonus | |
| if context.is_championship_contender: | |
| total_bonus += 20 | |
| reasons.append("Championship contender: +20") | |
| # Battle narrative bonus | |
| if any("battle" in narrative.lower() for narrative in context.active_narratives): | |
| total_bonus += 15 | |
| reasons.append("Battle narrative: +15") | |
| # Comeback narrative bonus | |
| if any("comeback" in narrative.lower() for narrative in context.active_narratives): | |
| total_bonus += 15 | |
| reasons.append("Comeback narrative: +15") | |
| # Close gap bonus | |
| if context.gap_to_ahead is not None and context.gap_to_ahead < 1.0: | |
| total_bonus += 10 | |
| reasons.append("Gap < 1s: +10") | |
| # Tire age differential bonus | |
| if context.tire_age_differential is not None and context.tire_age_differential > 5: | |
| total_bonus += 10 | |
| reasons.append(f"Tire age diff > 5 laps: +10") | |
| # DRS bonus | |
| if context.drs_active: | |
| total_bonus += 5 | |
| reasons.append("DRS active: +5") | |
| # Purple sector bonus | |
| if (context.sector_1_status == "purple" or | |
| context.sector_2_status == "purple" or | |
| context.sector_3_status == "purple"): | |
| total_bonus += 10 | |
| reasons.append("Purple sector: +10") | |
| # Weather impact bonus | |
| if self._has_weather_impact(context): | |
| total_bonus += 5 | |
| reasons.append("Weather impact: +5") | |
| # First of session bonus (check pit_count for first pit) | |
| if context.pit_count == 1: | |
| total_bonus += 10 | |
| reasons.append("First pit stop: +10") | |
| return total_bonus, reasons | |
| def _has_weather_impact(self, context: ContextData) -> bool: | |
| """ | |
| Determine if weather conditions are impactful. | |
| Weather is considered impactful if: | |
| - Rainfall > 0 | |
| - Wind speed > 20 km/h | |
| - Track temperature change > 5°C (would need historical tracking) | |
| Args: | |
| context: Context data with weather information | |
| Returns: | |
| True if weather is impactful | |
| """ | |
| # Rainfall | |
| if context.rainfall is not None and context.rainfall > 0: | |
| return True | |
| # High wind | |
| if context.wind_speed is not None and context.wind_speed > 20: | |
| return True | |
| # Note: Temperature change tracking would require historical data | |
| # which is not available in the current context. This could be | |
| # added in the future by tracking temperature over time. | |
| return False | |
| class EventPrioritizer: | |
| """ | |
| Event prioritizer that filters events by significance. | |
| Determines which events warrant commentary based on significance scores, | |
| suppresses pit-cycle position changes, and selects the highest significance | |
| event when multiple events occur simultaneously. | |
| """ | |
| def __init__(self, config, race_state_tracker): | |
| """ | |
| Initialize the event prioritizer. | |
| Args: | |
| config: Configuration object with min_significance_threshold | |
| race_state_tracker: Race state tracker for historical position data | |
| """ | |
| self.config = config | |
| self.race_state_tracker = race_state_tracker | |
| self.significance_calculator = SignificanceCalculator() | |
| # Get threshold from config, default to 50 | |
| self.min_threshold = getattr( | |
| config, | |
| 'min_significance_threshold', | |
| 50 | |
| ) | |
| # Track recent pit stops for pit-cycle detection | |
| # Format: {driver_number: (lap_number, position_before_pit)} | |
| self.recent_pit_stops: dict[str, tuple[int, int]] = {} | |
| def should_commentate(self, significance: SignificanceScore) -> bool: | |
| """ | |
| Determine if an event meets the threshold for commentary. | |
| Args: | |
| significance: The significance score for the event | |
| Returns: | |
| True if the event should receive commentary | |
| """ | |
| return significance.total_score >= self.min_threshold | |
| def suppress_pit_cycle_changes( | |
| self, | |
| event: RaceEvent, | |
| context: ContextData | |
| ) -> bool: | |
| """ | |
| Determine if a position change should be suppressed as pit-cycle related. | |
| Pit-cycle position changes are temporary position changes that occur | |
| when a driver pits (drops positions) and then regains them as others pit. | |
| These are not interesting for commentary. | |
| Args: | |
| event: The race event | |
| context: Context data with position information | |
| Returns: | |
| True if the position change should be suppressed | |
| """ | |
| # Only applies to overtakes and position updates | |
| if event.event_type not in [EventType.OVERTAKE, EventType.POSITION_UPDATE]: | |
| return False | |
| # Check if this is a pit-cycle position change | |
| return self._is_pit_cycle_position_change(event, context) | |
| def _is_pit_cycle_position_change( | |
| self, | |
| event: RaceEvent, | |
| context: ContextData | |
| ) -> bool: | |
| """ | |
| Detect if a position change is due to pit cycle. | |
| A position change is pit-cycle related if: | |
| 1. The driver recently pitted (within last 5 laps) | |
| 2. The driver is regaining a position they held before pitting | |
| OR | |
| 1. Another driver recently pitted | |
| 2. This driver is gaining a position due to the other driver's pit | |
| Args: | |
| event: The race event | |
| context: Context data with position information | |
| Returns: | |
| True if this is a pit-cycle position change | |
| """ | |
| # Get current lap from race state | |
| current_lap = context.race_state.current_lap | |
| # Get driver involved in the position change | |
| driver = event.data.get('driver', event.data.get('driver_number', '')) | |
| if not driver: | |
| return False | |
| # Check if this driver recently pitted | |
| if driver in self.recent_pit_stops: | |
| pit_lap, position_before_pit = self.recent_pit_stops[driver] | |
| # If pit was within last 5 laps | |
| if current_lap - pit_lap <= 5: | |
| # Check if driver is regaining their pre-pit position | |
| if context.position_after is not None: | |
| # If current position is close to pre-pit position | |
| # (within 2 positions), likely pit-cycle related | |
| if abs(context.position_after - position_before_pit) <= 2: | |
| return True | |
| # Check if the driver being overtaken recently pitted | |
| # This would indicate the overtake is due to pit cycle | |
| overtaken_driver = event.data.get('overtaken_driver', '') | |
| if overtaken_driver: | |
| if overtaken_driver in self.recent_pit_stops: | |
| pit_lap, _ = self.recent_pit_stops[overtaken_driver] | |
| # If the overtaken driver pitted within last 2 laps, | |
| # this overtake is likely pit-cycle related | |
| if current_lap - pit_lap <= 2: | |
| return True | |
| return False | |
| def track_pit_stop( | |
| self, | |
| event: RaceEvent, | |
| context: ContextData | |
| ): | |
| """ | |
| Track a pit stop for pit-cycle detection. | |
| Should be called whenever a pit stop event occurs. | |
| Args: | |
| event: The pit stop event | |
| context: Context data with position information | |
| """ | |
| if event.event_type == EventType.PIT_STOP: | |
| driver = event.data.get('driver', event.data.get('driver_number', '')) | |
| if not driver: | |
| return | |
| current_lap = context.race_state.current_lap | |
| position_before = context.position_before or 0 | |
| # Store pit stop info | |
| self.recent_pit_stops[driver] = (current_lap, position_before) | |
| # Clean up old pit stops (older than 10 laps) | |
| drivers_to_remove = [] | |
| for d, (lap, _) in self.recent_pit_stops.items(): | |
| if current_lap - lap > 10: | |
| drivers_to_remove.append(d) | |
| for d in drivers_to_remove: | |
| del self.recent_pit_stops[d] | |
| def select_highest_significance( | |
| self, | |
| events_with_scores: list[tuple[RaceEvent, ContextData, SignificanceScore]] | |
| ) -> Optional[tuple[RaceEvent, ContextData, SignificanceScore]]: | |
| """ | |
| Select the highest significance event from simultaneous events. | |
| When multiple events occur at the same time, we want to commentate | |
| on the most significant one. | |
| Args: | |
| events_with_scores: List of (event, context, significance) tuples | |
| Returns: | |
| The (event, context, significance) tuple with highest score, | |
| or None if the list is empty | |
| """ | |
| if not events_with_scores: | |
| return None | |
| # Find the event with the highest total score | |
| return max( | |
| events_with_scores, | |
| key=lambda x: x[2].total_score | |
| ) | |