Spaces:
Sleeping
Sleeping
File size: 33,532 Bytes
29ca14e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 | """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)
|