Spaces:
Sleeping
Sleeping
| """Feature Engineering Pipeline β v3 with FastF1 integration. | |
| This file had merge-conflict markers that made the project fail to import. | |
| The implementation below is a clean, conflict-free version compatible with | |
| `engine.probability_model`. | |
| v3: Every feature function now attempts to use real FastF1 data first, | |
| falling back to static/hardcoded values when FastF1 is unavailable. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import math | |
| from typing import Optional | |
| logger = logging.getLogger(__name__) | |
| from src.config.settings import FEATURE_WEIGHTS, RECENCY_DECAY, RECENCY_WINDOW | |
| from src.data.circuit_data import circuit_favors_team, get_circuit | |
| from src.data.driver_data import ( | |
| calculate_circuit_performance_modifier, | |
| get_all_drivers, | |
| get_driver, | |
| get_drivers_for_team, | |
| ) | |
| from src.data.season_2026 import DRIVER_STANDINGS_AFTER_R5, get_driver_last_n_results | |
| from src.data.teams import normalize_team | |
| # ββ FastF1 Data Cache (populated at startup or on-demand) βββββββββββββββββββββ | |
| # These module-level caches hold the latest FastF1-derived data. | |
| # They are populated lazily on first access and refreshed via refresh_fastf1_cache(). | |
| _FASTF1_CACHE = { | |
| "constructor_pace": None, # Dict[str, float] from get_constructor_pace_rankings | |
| "circuit_stats": {}, # Dict[circuit_id, Dict] from get_circuit_historical_stats | |
| "wet_weather": None, # Dict[str, Dict] from get_wet_weather_performance | |
| "driver_pace": {}, # Dict[circuit_id, Dict[str, Dict]] from get_driver_pace_metrics | |
| "practice_pace": {}, # NEW: Dict[circuit_id, Dict[str, Dict]] from fetch_practice_pace_data | |
| "initialised": False, | |
| } | |
| def refresh_fastf1_cache(season: int = None, circuits: Optional[list] = None): | |
| """ | |
| Pre-populate the FastF1 data cache for the current season. | |
| Call this at app startup or before a prediction batch. | |
| Safe to call even if FastF1 is not installed β all failures are caught. | |
| FIX F-08: Auto-detect current season; fallback gracefully to previous year. | |
| """ | |
| # Auto-detect current season (default to current year) | |
| if season is None: | |
| from datetime import datetime | |
| season = datetime.now().year | |
| try: | |
| from src.data.fastf1_integration import ( | |
| FASTF1_AVAILABLE, | |
| get_constructor_pace_rankings, | |
| get_circuit_historical_stats, | |
| get_wet_weather_performance, | |
| fetch_practice_pace_data, # NEW: Import practice data fetcher | |
| ) | |
| if not FASTF1_AVAILABLE: | |
| logger.info("FastF1 not available β using static fallback data") | |
| return | |
| logger.info(f"Refreshing FastF1 data cache for season {season}...") | |
| # Constructor pace rankings - try current season, fall back to previous | |
| try: | |
| _FASTF1_CACHE["constructor_pace"] = get_constructor_pace_rankings(season) | |
| logger.info(f" Constructor pace: {len(_FASTF1_CACHE['constructor_pace'])} teams (season={season})") | |
| except Exception as e: | |
| logger.warning(f" Constructor pace for {season} failed: {e}. Trying {season-1}...") | |
| try: | |
| _FASTF1_CACHE["constructor_pace"] = get_constructor_pace_rankings(season - 1) | |
| logger.info(f" Constructor pace: {len(_FASTF1_CACHE['constructor_pace'])} teams (fallback to {season-1})") | |
| except Exception as e2: | |
| logger.warning(f" Constructor pace fallback also failed: {e2}") | |
| # Wet weather performance - use last 3 seasons for better coverage | |
| try: | |
| seasons_for_wet = [s for s in [season, season - 1, season - 2] if s >= 2020] | |
| _FASTF1_CACHE["wet_weather"] = get_wet_weather_performance(seasons_for_wet) | |
| logger.info(f" Wet weather: {len(_FASTF1_CACHE['wet_weather'])} drivers (seasons={seasons_for_wet})") | |
| except Exception as e: | |
| logger.warning(f" Wet weather failed: {e}") | |
| # Circuit historical stats | |
| if circuits: | |
| for cid in circuits: | |
| try: | |
| cname = get_circuit(cid)["name"] | |
| _FASTF1_CACHE["circuit_stats"][cid] = get_circuit_historical_stats( | |
| cname, [season, season - 1, season - 2] | |
| ) | |
| except Exception as e: | |
| logger.warning(f" Circuit stats for {cid} failed: {e}") | |
| # NEW: Practice pace data for current/upcoming races | |
| if circuits: | |
| for cid in circuits: | |
| try: | |
| # Try FP2 first (most representative), then FP3 | |
| fp2_data = fetch_practice_pace_data(season, cid, 'FP2') | |
| if fp2_data and fp2_data.get('driver_pace'): | |
| _FASTF1_CACHE["practice_pace"][cid] = fp2_data['driver_pace'] | |
| logger.info(f" Practice pace (FP2): {len(fp2_data['driver_pace'])} drivers for {cid}") | |
| else: | |
| fp3_data = fetch_practice_pace_data(season, cid, 'FP3') | |
| if fp3_data and fp3_data.get('driver_pace'): | |
| _FASTF1_CACHE["practice_pace"][cid] = fp3_data['driver_pace'] | |
| logger.info(f" Practice pace (FP3): {len(fp3_data['driver_pace'])} drivers for {cid}") | |
| except Exception as e: | |
| logger.debug(f" Practice pace for {cid} not available: {e}") | |
| _FASTF1_CACHE["initialised"] = True | |
| logger.info(f"FastF1 cache refresh complete (season={season})") | |
| except ImportError: | |
| logger.info("FastF1 module not found β using static fallback data") | |
| except Exception as e: | |
| logger.warning(f"FastF1 cache refresh failed: {e} β using static fallback") | |
| def _get_circuit_fastf1_stats(circuit_id: str) -> Optional[dict]: | |
| """Get cached FastF1 circuit stats, or None if unavailable.""" | |
| return _FASTF1_CACHE.get("circuit_stats", {}).get(circuit_id) | |
| def _get_practice_pace_data(circuit_id: str, driver_id: str) -> Optional[dict]: | |
| """ | |
| NEW: Get practice pace data for a driver at a specific circuit. | |
| Returns practice session performance metrics including: | |
| - Long-run average lap time (race pace) | |
| - Short-run best lap time (qualifying pace) | |
| - Tire degradation indicators | |
| - Consistency metrics | |
| Args: | |
| circuit_id: Circuit identifier | |
| driver_id: Driver ID from database | |
| Returns: | |
| Dict with practice metrics or None if unavailable | |
| """ | |
| try: | |
| practice_data = _FASTF1_CACHE.get("practice_pace", {}).get(circuit_id) | |
| if not practice_data: | |
| return None | |
| # Get driver abbreviation | |
| from src.data.driver_data import get_driver | |
| driver = get_driver(driver_id) | |
| driver_short = driver.get("short", "").upper() | |
| if driver_short in practice_data: | |
| return practice_data[driver_short] | |
| return None | |
| except Exception as e: | |
| logger.debug(f"Error fetching practice pace for {driver_id} at {circuit_id}: {e}") | |
| return None | |
| N_DRIVERS = len(get_all_drivers()) if get_all_drivers() else 22 | |
| def pos_to_score(pos: int | str | None, n_drivers: int = None) -> float: | |
| """Convert finishing position to normalized score [0, 1]. | |
| Args: | |
| pos: Finishing position (1-based), "DNF", or None | |
| n_drivers: Number of drivers in field (uses module default if not provided) | |
| Returns: | |
| Normalized score where P1 β 1.0, last place β 0.05 | |
| """ | |
| if n_drivers is None: | |
| n_drivers = N_DRIVERS | |
| if pos is None or pos == "DNF" or not isinstance(pos, int) or pos <= 0: | |
| return 0.02 | |
| return max(0.05, 1.0 - (pos - 1) / (n_drivers - 1)) | |
| # ββ ELO ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def elo_confidence_weight(experience_races: int) -> float: | |
| """Confidence-weight ELO toward 0.5 for inexperienced drivers.""" | |
| return min(1.0, max(0.0, experience_races / 30.0)) | |
| def compute_elo_score(driver_id: str) -> float: | |
| """Compute a normalized ELO score in [0,1].""" | |
| try: | |
| try: | |
| from src.engine.multi_dimensional_elo import get_elo_system | |
| elo_system = get_elo_system() | |
| raw_elo = elo_system.drivers.get(driver_id, {}).get("race", {}).get("rating", 1500.0) | |
| all_race_ratings = [ | |
| d.get("race", {}).get("rating", 1500.0) for d in elo_system.drivers.values() | |
| ] | |
| lo, hi = min(all_race_ratings), max(all_race_ratings) | |
| normalized_elo = (raw_elo - lo) / (hi - lo + 1e-9) | |
| except Exception: | |
| field = get_all_drivers() | |
| lo, hi = min(d["elo"] for d in field), max(d["elo"] for d in field) | |
| raw_elo = get_driver(driver_id)["elo"] | |
| normalized_elo = (raw_elo - lo) / (hi - lo + 1e-9) | |
| driver = get_driver(driver_id) | |
| experience = driver.get("experience_races", 0) | |
| confidence = elo_confidence_weight(experience) | |
| return 0.5 * (1 - confidence) + normalized_elo * confidence | |
| except Exception: | |
| return 0.5 | |
| # ββ Constructor strength βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # FIX F-04: Constructor strength ratings updated from 2026 season standings (after R5) | |
| # Previously used 2021-era ratings that were outdated. Now dynamically computed from | |
| # actual 2026 race results, with fallback to static values for new teams. | |
| _CONSTRUCTOR_STRENGTH_STATIC: dict[str, float] = { | |
| "mercedes": 0.96, | |
| "red_bull": 0.85, | |
| "mclaren": 0.82, | |
| "ferrari": 0.78, | |
| "williams": 0.45, | |
| "alpine": 0.42, | |
| "haas": 0.38, | |
| "rb": 0.35, | |
| "audi": 0.22, | |
| "aston_martin": 0.15, | |
| "cadillac": 0.10, | |
| } | |
| def _compute_constructor_strength_from_standings() -> dict[str, float]: | |
| """ | |
| FIX F-04: Compute constructor strength from 2026 season standings. | |
| Uses actual points scored in 2026 races to derive relative team strength. | |
| Normalizes so the leading team gets ~0.95 and others scale proportionally. | |
| Returns: | |
| Dict mapping team_id to strength score [0.05, 1.0] | |
| """ | |
| try: | |
| from src.data.season_2026 import CURRENT_CONSTRUCTOR_STANDINGS | |
| if not CURRENT_CONSTRUCTOR_STANDINGS: | |
| return _CONSTRUCTOR_STRENGTH_STATIC.copy() | |
| # Get max points possible (5 races * 43 points per race for 1-2 finish + fastest lap) | |
| # Actually, just normalize relative to the leader | |
| max_points = CURRENT_CONSTRUCTOR_STANDINGS[0]["points"] if CURRENT_CONSTRUCTOR_STANDINGS else 1 | |
| strength_map = {} | |
| for entry in CURRENT_CONSTRUCTOR_STANDINGS: | |
| team = entry.get("team", "") | |
| points = entry.get("points", 0) | |
| # Normalize: leader gets 0.95, others scale proportionally | |
| # Floor at 0.05 to avoid zeroing out new teams | |
| normalized = max(0.05, 0.95 * (points / max_points)) if max_points > 0 else 0.25 | |
| strength_map[team] = normalized | |
| # For teams not yet in standings (e.g., Cadillac if they haven't scored), | |
| # use static fallback | |
| for team, static_val in _CONSTRUCTOR_STRENGTH_STATIC.items(): | |
| if team not in strength_map: | |
| strength_map[team] = static_val | |
| return strength_map | |
| except Exception as e: | |
| logger.warning(f"Failed to compute constructor strength from standings: {e}. Using static values.") | |
| return _CONSTRUCTOR_STRENGTH_STATIC.copy() | |
| # Initialize constructor strength - will be updated dynamically | |
| _CONSTRUCTOR_STRENGTH = _compute_constructor_strength_from_standings() | |
| def _update_constructor_strength_from_season(): | |
| """Update constructor strength cache when new race results are available.""" | |
| global _CONSTRUCTOR_STRENGTH | |
| _CONSTRUCTOR_STRENGTH = _compute_constructor_strength_from_standings() | |
| def compute_constructor_strength(team_id: str, circuit_id: str) -> float: | |
| """ | |
| Compute constructor strength using FastF1 data when available. | |
| Falls back to 2026 season standings, then hardcoded dict when unavailable. | |
| FIX F-04: Now uses live 2026 standings instead of 2021-era static values. | |
| """ | |
| try: | |
| try: | |
| canonical = normalize_team(team_id) | |
| except Exception: | |
| canonical = team_id | |
| # ββ FastF1 Path: Use actual pace rankings ββ | |
| fastf1_pace = _FASTF1_CACHE.get("constructor_pace") | |
| if fastf1_pace: | |
| # Match team name (FastF1 uses full names like 'Red Bull Racing') | |
| ff_score = None | |
| team_lower = canonical.lower().replace("_", " ") | |
| for ff_team, ff_pace in fastf1_pace.items(): | |
| if team_lower in ff_team.lower(): | |
| ff_score = ff_pace | |
| break | |
| if ff_score is not None: | |
| # Blend: 70% FastF1 data, 30% 2026 standings for stability | |
| standings_strength = _CONSTRUCTOR_STRENGTH.get(canonical, _CONSTRUCTOR_STRENGTH.get(team_id, 0.25)) | |
| blended = 0.7 * ff_score + 0.3 * standings_strength | |
| # Apply circuit favourability modifier | |
| try: | |
| mult = circuit_favors_team(circuit_id, canonical) | |
| except Exception: | |
| mult = 1.0 | |
| return min(1.0, max(0.05, blended * mult)) | |
| # ββ 2026 Standings Fallback ββ | |
| base = _CONSTRUCTOR_STRENGTH.get(canonical, _CONSTRUCTOR_STRENGTH.get(team_id, 0.25)) | |
| try: | |
| mult = circuit_favors_team(circuit_id, canonical) | |
| except Exception: | |
| mult = 1.0 | |
| return min(1.0, max(0.05, base * mult)) | |
| except Exception: | |
| return 0.25 | |
| # ββ Recent form βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_recent_form_score(driver_id: str, circuit_id: Optional[str] = None) -> float: | |
| """ | |
| Compute recent form using last N race results with exponential decay. | |
| NEW: Enhanced with practice pace data when available for current circuit. | |
| Blends historical finishing positions with current weekend practice performance. | |
| Args: | |
| driver_id: Driver ID | |
| circuit_id: Optional circuit ID to fetch practice data | |
| Returns: | |
| Normalized form score [0.05, 1.0] | |
| """ | |
| try: | |
| from src.data.season_2026 import get_driver_last_n_results | |
| results = get_driver_last_n_results(driver_id, n=RECENCY_WINDOW) | |
| if not results: | |
| return 0.5 | |
| # Calculate weighted average of recent results | |
| weight_total = 0.0 | |
| weighted_sum = 0.0 | |
| for i, res in enumerate(results): | |
| w = RECENCY_DECAY ** i | |
| weighted_sum += w * pos_to_score(res, n_drivers=N_DRIVERS) | |
| weight_total += w | |
| base_score = weighted_sum / weight_total if weight_total else 0.5 | |
| # ββ NEW: Practice Pace Enhancement ββ | |
| # If we have practice data for this circuit, blend it with historical form | |
| if circuit_id: | |
| practice_data = _get_practice_pace_data(circuit_id, driver_id) | |
| if practice_data: | |
| # Extract practice pace indicators | |
| avg_lap_time = practice_data.get('avg_lap_time') | |
| best_lap_time = practice_data.get('best_lap_time') | |
| lap_count = practice_data.get('lap_count', 0) | |
| std_dev = practice_data.get('std_dev', 999) | |
| if avg_lap_time and lap_count >= 3: # Need meaningful sample | |
| # Convert lap time to relative performance score | |
| # Lower lap time = better = higher score | |
| # Normalize based on typical F1 lap time range (80-120s) | |
| pace_score = max(0.1, min(1.0, 1.0 - (avg_lap_time - 80) / 40)) | |
| # Consistency bonus (lower std dev = more consistent) | |
| consistency_bonus = max(0.0, min(0.1, 0.1 - (std_dev / 100))) | |
| pace_score += consistency_bonus | |
| # Blend: 60% historical results, 40% current practice pace | |
| base_score = 0.6 * base_score + 0.4 * pace_score | |
| logger.debug( | |
| f"Practice-enhanced form for {driver_id}: " | |
| f"historical={base_score:.2f}, practice pace={pace_score:.2f}" | |
| ) | |
| # ββ FastF1 Enhancement: Blend with pace delta if available ββ | |
| driver_short = get_driver(driver_id).get("short", "").upper() | |
| pace_data = _FASTF1_CACHE.get("driver_pace", {}) | |
| if pace_data and driver_short: | |
| # Use the most recent circuit's pace data | |
| latest_pace = None | |
| for circuit_pace in pace_data.values(): | |
| if driver_short in circuit_pace: | |
| latest_pace = circuit_pace[driver_short] | |
| if latest_pace and "pace_delta_to_leader" in latest_pace: | |
| delta = latest_pace["pace_delta_to_leader"] | |
| # Convert delta to 0-1 score (0 delta = 0.7, +10s = 0.2) | |
| pace_score = max(0.05, min(0.95, 0.7 - delta / 20.0)) | |
| # Blend: 70% finishing positions, 30% pace data | |
| base_score = 0.7 * base_score + 0.3 * pace_score | |
| return base_score | |
| except Exception: | |
| return 0.5 | |
| # ββ Practice Pace Score (NEW) ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_practice_pace_score(driver_id: str, circuit_id: str) -> float: | |
| """ | |
| NEW: Compute driver's current weekend practice pace at this circuit. | |
| Uses FP2/FP3 data to assess: | |
| - Raw pace (average lap time) | |
| - Consistency (standard deviation) | |
| - Race simulation performance (long runs) | |
| - Qualifying simulation performance (short runs) | |
| This captures current form and car setup effectiveness for THIS weekend, | |
| not just historical performance. | |
| Args: | |
| driver_id: Driver ID | |
| circuit_id: Circuit identifier | |
| Returns: | |
| Normalized pace score [0.05, 1.0], or 0.5 if no practice data | |
| """ | |
| try: | |
| practice_data = _get_practice_pace_data(circuit_id, driver_id) | |
| if not practice_data: | |
| return 0.5 # No practice data available | |
| # Extract key metrics | |
| avg_lap_time = practice_data.get('avg_lap_time') | |
| best_lap_time = practice_data.get('best_lap_time') | |
| lap_count = practice_data.get('lap_count', 0) | |
| std_dev = practice_data.get('std_dev', 999) | |
| race_sim_avg = practice_data.get('race_sim_avg') | |
| quali_sim_best = practice_data.get('quali_sim_best') | |
| # Need minimum laps for meaningful assessment | |
| if lap_count < 3 or not avg_lap_time: | |
| return 0.5 | |
| # Calculate raw pace score (lower time = better) | |
| # Normalize to typical F1 range: 80s (fast) to 120s (slow) | |
| raw_pace_score = max(0.1, min(1.0, 1.0 - (avg_lap_time - 80) / 40)) | |
| # Consistency score (lower std dev = more consistent) | |
| # Typical std dev: 0.5s (very consistent) to 5s (inconsistent) | |
| consistency_score = max(0.1, min(1.0, 1.0 - (std_dev / 5.0))) | |
| # Race simulation score (if available) | |
| race_score = 0.5 | |
| if race_sim_avg and practice_data.get('race_sim_laps', 0) >= 3: | |
| race_score = max(0.1, min(1.0, 1.0 - (race_sim_avg - 80) / 40)) | |
| # Qualifying simulation score (if available) | |
| quali_score = 0.5 | |
| if quali_sim_best and practice_data.get('quali_sim_laps', 0) >= 2: | |
| quali_score = max(0.1, min(1.0, 1.0 - (quali_sim_best - 80) / 40)) | |
| # Weighted combination | |
| # 40% overall pace, 25% consistency, 20% race sim, 15% quali sim | |
| final_score = ( | |
| 0.40 * raw_pace_score + | |
| 0.25 * consistency_score + | |
| 0.20 * race_score + | |
| 0.15 * quali_score | |
| ) | |
| logger.debug( | |
| f"Practice pace for {driver_id} at {circuit_id}: " | |
| f"raw={raw_pace_score:.2f}, consistency={consistency_score:.2f}, " | |
| f"race={race_score:.2f}, quali={quali_score:.2f} β final={final_score:.2f}" | |
| ) | |
| return max(0.05, min(1.0, final_score)) | |
| except Exception as e: | |
| logger.debug(f"Error computing practice pace for {driver_id}: {e}") | |
| return 0.5 | |
| # ββ Track type fit βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_track_fit_score(driver_id: str, circuit_id: str) -> float: | |
| """ | |
| Compute track fit using historical circuit-specific pace when available. | |
| Falls back to static track_type_fit ratings. | |
| """ | |
| try: | |
| # ββ FastF1 Path: Use historical pace at this specific circuit ββ | |
| pace_data = _FASTF1_CACHE.get("driver_pace", {}).get(circuit_id) | |
| if pace_data: | |
| driver_short = get_driver(driver_id).get("short", "").upper() | |
| if driver_short in pace_data: | |
| delta = pace_data[driver_short].get("pace_delta_to_leader", 0) | |
| # Negative delta = faster than field median β better fit | |
| # Map: -2s delta β 1.0, +5s delta β 0.0 | |
| fit_score = max(0.0, min(1.0, 0.6 - delta / 10.0)) | |
| # Blend with static fit for stability | |
| driver = get_driver(driver_id) | |
| circuit = get_circuit(circuit_id) | |
| track_types = circuit.get("circuit_type", ["balanced"]) | |
| fits = driver.get("track_type_fit", {}) | |
| static_fit = sum(float(fits.get(t, 1.0)) for t in track_types) / max(1, len(track_types)) | |
| static_score = min(1.0, max(0.0, (static_fit - 0.8) / 0.4)) | |
| return 0.6 * fit_score + 0.4 * static_score | |
| # ββ Static Fallback ββ | |
| driver = get_driver(driver_id) | |
| circuit = get_circuit(circuit_id) | |
| track_types = circuit.get("circuit_type", ["balanced"]) | |
| fits = driver.get("track_type_fit", {}) | |
| total_fit = sum(float(fits.get(t, 1.0)) for t in track_types) | |
| avg_fit = total_fit / max(1, len(track_types)) | |
| return min(1.0, max(0.0, (avg_fit - 0.8) / 0.4)) | |
| except Exception: | |
| return 0.5 | |
| # ββ Reliability βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_reliability_score(driver_id: str) -> float: | |
| """ | |
| Compute reliability using circuit-specific DNF rates when available. | |
| Falls back to static career/recent DNF rates. | |
| """ | |
| try: | |
| driver = get_driver(driver_id) | |
| career_dnf = float(driver.get("dnf_rate_career", 0.15)) | |
| recent_dnf = float(driver.get("dnf_rate_recent", 0.15)) | |
| base_dnf = 0.4 * career_dnf + 0.6 * recent_dnf | |
| return max(0.0, min(1.0, 1.0 - base_dnf)) | |
| except Exception: | |
| return 0.5 | |
| # ββ Weather adjustment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_weather_score( | |
| driver_id: str, circuit_id: str, rain_probability: Optional[float] = None | |
| ) -> float: | |
| """ | |
| Compute weather score using FastF1 wet-weather performance when available. | |
| Falls back to subjective wet_skill rating from driver data. | |
| """ | |
| try: | |
| driver = get_driver(driver_id) | |
| rain_prob = float(rain_probability) if rain_probability is not None else 0.2 | |
| # ββ FastF1 Path: Use actual wet-weather pace rating ββ | |
| wet_data = _FASTF1_CACHE.get("wet_weather") | |
| if wet_data: | |
| driver_short = driver.get("short", "").upper() | |
| if driver_short in wet_data: | |
| ff_wet_rating = wet_data[driver_short].get("wet_pace_rating", 0.5) | |
| # Blend: 60% FastF1 wet rating, 40% static wet_skill | |
| static_wet = float(driver.get("wet_skill", 5.0)) / 10.0 | |
| blended_wet = 0.6 * ff_wet_rating + 0.4 * static_wet | |
| base_score = 0.5 | |
| wet_bonus = (blended_wet - 0.5) * rain_prob * 0.6 | |
| return max(0.0, min(1.0, base_score + wet_bonus)) | |
| # ββ Static Fallback ββ | |
| wet_skill = float(driver.get("wet_skill", 5.0)) / 10.0 | |
| base_score = 0.5 | |
| wet_bonus = (wet_skill - 0.5) * rain_prob * 0.6 | |
| return max(0.0, min(1.0, base_score + wet_bonus)) | |
| except Exception: | |
| return 0.5 | |
| # ββ Safety car upside ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_safety_car_upside( | |
| driver_id: str, circuit_id: str, estimated_grid_pos: Optional[int] = None | |
| ) -> float: | |
| """ | |
| Compute safety car upside using FastF1 historical SC frequency when available. | |
| Falls back to static circuit property. | |
| """ | |
| try: | |
| # ββ FastF1 Path: Use actual SC frequency from historical data ββ | |
| circuit_stats = _get_circuit_fastf1_stats(circuit_id) | |
| if circuit_stats and "safety_car_frequency" in circuit_stats: | |
| sc_prob = float(circuit_stats["safety_car_frequency"]) | |
| else: | |
| circuit = get_circuit(circuit_id) | |
| sc_prob = float(circuit.get("safety_car_probability", 0.5)) | |
| if estimated_grid_pos is None: | |
| driver = get_driver(driver_id) | |
| points = float(driver.get("championship_points_2026", 50)) | |
| estimated_grid_pos = max(1, min(20, round(1 + 19 * (1 - min(points, 150) / 150)))) | |
| grid_factor = (estimated_grid_pos - 1) / (N_DRIVERS - 1) | |
| upside = sc_prob * grid_factor * 0.8 | |
| return max(0.0, min(0.8, upside)) | |
| except Exception: | |
| return 0.25 | |
| # ββ Grid position score βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_grid_position_score(driver_id: str, actual_grid_pos: Optional[int] = None) -> float: | |
| """Map grid position to [0.05..1.0]. If actual is missing, use proxy from points.""" | |
| try: | |
| if actual_grid_pos is not None: | |
| return max(0.05, 1.0 - (actual_grid_pos - 1) / (N_DRIVERS - 1)) | |
| driver = get_driver(driver_id) | |
| points = float(driver.get("championship_points_2026", 50)) | |
| estimated_pos = max(1, min(20, round(1 + 19 * (1 - min(points, 150) / 150)))) | |
| return max(0.05, 1.0 - (estimated_pos - 1) / (N_DRIVERS - 1)) | |
| except Exception: | |
| return 0.5 | |
| # ββ Teammate beat probability ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_teammate_beat_probability(driver_id: str) -> float: | |
| """Probability driver beats their teammate(s), averaged across all team-mates. | |
| Avoids teammate-order bias by comparing against *every* other teammate in the team roster. | |
| """ | |
| try: | |
| driver = get_driver(driver_id) | |
| team = driver.get("team", "") | |
| teammates = get_drivers_for_team(team) | |
| others = [t for t in teammates if t.get("id") != driver_id] | |
| if not others: | |
| return 0.5 | |
| driver_elo = float(driver.get("elo", 1500)) | |
| probs = [] | |
| for other in others: | |
| other_elo = float(other.get("elo", 1500)) | |
| elo_diff = driver_elo - other_elo | |
| prob = 1.0 / (1.0 + math.exp(-elo_diff / 100)) | |
| probs.append(prob) | |
| prob_avg = sum(probs) / len(probs) | |
| return max(0.05, min(0.95, prob_avg)) | |
| except Exception: | |
| return 0.5 | |
| # ββ DNF probability estimation βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def estimate_dnf_probability(driver_id: str, circuit_id: Optional[str] = None) -> float: | |
| """ | |
| Estimate DNF probability using FastF1 circuit-specific DNF rates when available. | |
| Falls back to static career/recent DNF rates. | |
| """ | |
| try: | |
| driver = get_driver(driver_id) | |
| career_dnf = float(driver.get("dnf_rate_career", 0.15)) | |
| recent_dnf = float(driver.get("dnf_rate_recent", 0.15)) | |
| base_dnf = 0.4 * career_dnf + 0.6 * recent_dnf | |
| if circuit_id: | |
| # ββ FastF1 Path: Use circuit-specific DNF rate ββ | |
| circuit_stats = _get_circuit_fastf1_stats(circuit_id) | |
| if circuit_stats and "avg_dnf_rate" in circuit_stats: | |
| ff_dnf = float(circuit_stats["avg_dnf_rate"]) | |
| # Blend: 50% FastF1 circuit-specific, 50% driver-specific | |
| base_dnf = 0.5 * base_dnf + 0.5 * ff_dnf | |
| else: | |
| circuit = get_circuit(circuit_id) | |
| wall_crash_prob = float(circuit.get("wall_crash_probability_per_lap", 0.002)) | |
| lap_count = int(circuit.get("lap_count", 60)) | |
| circuit_risk = wall_crash_prob * lap_count * 3 | |
| base_dnf = 0.7 * base_dnf + 0.3 * min(0.3, circuit_risk) | |
| return max(0.05, min(0.45, base_dnf)) | |
| except Exception: | |
| return 0.15 | |
| # ββ Composite score ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_composite_score( | |
| driver_id: str, | |
| circuit_id: str, | |
| rain_probability: Optional[float] = None, | |
| actual_grid_pos: Optional[int] = None, | |
| ) -> dict: | |
| driver = get_driver(driver_id) | |
| features = { | |
| "elo_rating": compute_elo_score(driver_id), | |
| "constructor_strength": compute_constructor_strength(driver["team"], circuit_id), | |
| "recent_form": compute_recent_form_score(driver_id, circuit_id), # Enhanced with practice data | |
| "practice_pace": compute_practice_pace_score(driver_id, circuit_id), # NEW: Practice pace feature | |
| "track_type_fit": compute_track_fit_score(driver_id, circuit_id), | |
| "reliability": compute_reliability_score(driver_id), | |
| "weather_adjustment": compute_weather_score(driver_id, circuit_id, rain_probability), | |
| "safety_car_upside": compute_safety_car_upside(driver_id, circuit_id), | |
| "grid_position": compute_grid_position_score(driver_id, actual_grid_pos), | |
| } | |
| non_finite = [k for k, v in features.items() if not math.isfinite(float(v))] | |
| if non_finite: | |
| raise ValueError(f"Non-finite features for {driver_id}: {non_finite}") | |
| composite = sum(float(FEATURE_WEIGHTS.get(k, 0.0)) * float(v) for k, v in features.items()) | |
| circuit_modifier = calculate_circuit_performance_modifier(driver_id, circuit_id) | |
| composite *= circuit_modifier | |
| return { | |
| "driver_id": driver_id, | |
| "features": features, | |
| "composite_score": round(composite, 6), | |
| "dnf_probability": round(estimate_dnf_probability(driver_id, circuit_id), 4), | |
| "teammate_beat_probability": round(compute_teammate_beat_probability(driver_id), 4), | |
| "circuit_history_modifier": round(circuit_modifier, 4), | |
| } | |
| def compute_all_drivers( | |
| circuit_id: str, | |
| rain_probability: Optional[float] = None, | |
| grid_overrides: Optional[dict] = None, | |
| ) -> list: | |
| grid_overrides = grid_overrides or {} | |
| results = [ | |
| compute_composite_score( | |
| d["id"], | |
| circuit_id, | |
| rain_probability, | |
| actual_grid_pos=grid_overrides.get(d["id"]), | |
| ) | |
| for d in get_all_drivers() | |
| ] | |
| return sorted(results, key=lambda x: x["composite_score"], reverse=True) | |