Prathamesh Bhamare commited on
Commit ·
46c1c8b
1
Parent(s): 3209e63
Added crash detection feature and full 2014-2026 prerace dataset
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- agents/compare_agent.py +86 -0
- agents/critique_agent.py +12 -2
- agents/data_agent.py +4 -1
- agents/synthesis_agent.py +29 -4
- api/main.py +239 -12
- api/schemas.py +35 -1
- data/__init__.py +108 -0
- data/fastf1_pipeline.py +8 -2
- data_output/fastf1_races.parquet +2 -2
- data_output/prerace/prerace_70th_anniversary_gra_2020.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2014.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2015.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2016.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2017.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2018.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2019.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2020.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2021.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2022.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2023.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2024.parquet +3 -0
- data_output/prerace/prerace_abu_dhabi_grand_prix_2025.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2014.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2015.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2016.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2017.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2018.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2019.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2022.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2023.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2024.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2025.parquet +3 -0
- data_output/prerace/prerace_australian_grand_pri_2026.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2014.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2015.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2016.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2017.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2018.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2019.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2020.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2021.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2022.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2023.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2024.parquet +3 -0
- data_output/prerace/prerace_austrian_grand_prix_2025.parquet +3 -0
- data_output/prerace/prerace_azerbaijan_grand_pri_2017.parquet +3 -0
- data_output/prerace/prerace_azerbaijan_grand_pri_2018.parquet +3 -0
- data_output/prerace/prerace_azerbaijan_grand_pri_2019.parquet +3 -0
- data_output/prerace/prerace_azerbaijan_grand_pri_2021.parquet +3 -0
- data_output/prerace/prerace_azerbaijan_grand_pri_2022.parquet +3 -0
agents/compare_agent.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KRONECTOR - Compare Agent
|
| 3 |
+
|
| 4 |
+
Uses Groq's Llama3 to analyze the mathematical SHAP Delta between two drivers
|
| 5 |
+
and synthesize a "Tale of the Tape" style narrative.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from groq import Groq
|
| 10 |
+
|
| 11 |
+
def compare_agent(
|
| 12 |
+
driver1_name: str,
|
| 13 |
+
driver1_prob: float,
|
| 14 |
+
driver1_status: str,
|
| 15 |
+
driver2_name: str,
|
| 16 |
+
driver2_prob: float,
|
| 17 |
+
driver2_status: str,
|
| 18 |
+
shap_deltas: dict[str, float],
|
| 19 |
+
race_context: str
|
| 20 |
+
) -> str:
|
| 21 |
+
"""Uses Llama3 to write a head-to-head comparison."""
|
| 22 |
+
|
| 23 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 24 |
+
if not api_key:
|
| 25 |
+
raise RuntimeError("GROQ_API_KEY environment variable is required.")
|
| 26 |
+
|
| 27 |
+
client = Groq(api_key=api_key)
|
| 28 |
+
|
| 29 |
+
# Sort deltas to find biggest advantages for driver 1 (positive) and driver 2 (negative)
|
| 30 |
+
sorted_deltas = sorted(shap_deltas.items(), key=lambda item: item[1], reverse=True)
|
| 31 |
+
d1_advantages = [f"{k}: +{v:.2f}" for k, v in sorted_deltas[:3] if v > 0]
|
| 32 |
+
d2_advantages = [f"{k}: +{abs(v):.2f}" for k, v in reversed(sorted_deltas) if v < 0][:3]
|
| 33 |
+
|
| 34 |
+
system_prompt = f"""
|
| 35 |
+
You are KRONECTOR, an elite Formula 1 Race Strategy AI.
|
| 36 |
+
You have been asked to compare two drivers head-to-head for an upcoming race.
|
| 37 |
+
You have a Machine Learning model that calculates 'SHAP Deltas' — exactly where one driver gains or loses mathematical advantage over the other.
|
| 38 |
+
|
| 39 |
+
YOUR JOB:
|
| 40 |
+
Write a "Tale of the Tape" style boxing-match breakdown (1-2 paragraphs max).
|
| 41 |
+
Explain who is the overall favorite based on Win Probability, but highlight the specific areas where the underdog might have an edge.
|
| 42 |
+
Make it sound like an expert F1 engineer breaking down a matchup.
|
| 43 |
+
|
| 44 |
+
IMPORTANT CONCEPTS:
|
| 45 |
+
- 'grid_position' or 'pole_conversion_rate' means track position/qualifying pace.
|
| 46 |
+
- 'career_race_starts' means veteran experience vs rookie volatility.
|
| 47 |
+
- 'driver_form_last3' means recent momentum.
|
| 48 |
+
- 'sector_1_time', etc. means raw track speed.
|
| 49 |
+
- CRASH DETECTION: You are provided with the 'Qualifying Status' for both drivers. If a driver's status indicates an Accident, Collision, or Crash, you MUST dramatically mention it as a major disadvantage or factor! (Note: a driver can still have a good grid position if they crashed in Q3. If so, highlight this loophole!)
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
user_prompt = f"""
|
| 53 |
+
RACE CONTEXT: {race_context}
|
| 54 |
+
|
| 55 |
+
MATCHUP:
|
| 56 |
+
{driver1_name} (Win Probability: {driver1_prob:.1f}%) | Quali Status: {driver1_status}
|
| 57 |
+
vs
|
| 58 |
+
{driver2_name} (Win Probability: {driver2_prob:.1f}%) | Quali Status: {driver2_status}
|
| 59 |
+
|
| 60 |
+
MATHEMATICAL ADVANTAGES (SHAP Deltas):
|
| 61 |
+
Biggest edges for {driver1_name}:
|
| 62 |
+
{', '.join(d1_advantages) if d1_advantages else "None"}
|
| 63 |
+
|
| 64 |
+
Biggest edges for {driver2_name}:
|
| 65 |
+
{', '.join(d2_advantages) if d2_advantages else "None"}
|
| 66 |
+
|
| 67 |
+
Write the Head-to-Head analysis:
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
chat_completion = client.chat.completions.create(
|
| 71 |
+
messages=[
|
| 72 |
+
{
|
| 73 |
+
"role": "system",
|
| 74 |
+
"content": system_prompt.strip(),
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"role": "user",
|
| 78 |
+
"content": user_prompt.strip(),
|
| 79 |
+
}
|
| 80 |
+
],
|
| 81 |
+
model="llama-3.3-70b-versatile",
|
| 82 |
+
temperature=0.7,
|
| 83 |
+
max_tokens=500,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return chat_completion.choices[0].message.content
|
agents/critique_agent.py
CHANGED
|
@@ -17,16 +17,26 @@ def critique_agent(prediction: PredictionOutput) -> CritiqueOutput:
|
|
| 17 |
"""Critiques the prediction mathematically to safeguard the LLM."""
|
| 18 |
prob = prediction["probability"]
|
| 19 |
shap_values = prediction["shap_values"]
|
|
|
|
| 20 |
|
| 21 |
approved = True
|
| 22 |
confidence_rating = "Normal"
|
| 23 |
critique_notes = ""
|
| 24 |
|
| 25 |
# 1. Evaluate Probability Thresholds
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
approved = False
|
| 28 |
confidence_rating = "Low"
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
elif prob > 0.95:
|
| 31 |
confidence_rating = "Overconfident"
|
| 32 |
critique_notes += f"FLAGGED: Win probability is unusually high ({prob:.1%}). In modern F1, >95% confidence may indicate data leakage or a hyper-dominant driver in the dataset. Proceed with caution.\n\n"
|
|
|
|
| 17 |
"""Critiques the prediction mathematically to safeguard the LLM."""
|
| 18 |
prob = prediction["probability"]
|
| 19 |
shap_values = prediction["shap_values"]
|
| 20 |
+
is_prerace = prediction.get("is_prerace", False)
|
| 21 |
|
| 22 |
approved = True
|
| 23 |
confidence_rating = "Normal"
|
| 24 |
critique_notes = ""
|
| 25 |
|
| 26 |
# 1. Evaluate Probability Thresholds
|
| 27 |
+
# Pre-race predictions use a lower threshold (5% = true random chance in a 22-car field)
|
| 28 |
+
# because features like tires/weather are missing and probabilities are naturally spread thin.
|
| 29 |
+
# Post-race/historical queries use the stricter 20% threshold.
|
| 30 |
+
reject_threshold = 0.05 if is_prerace else 0.20
|
| 31 |
+
|
| 32 |
+
if prob < reject_threshold:
|
| 33 |
approved = False
|
| 34 |
confidence_rating = "Low"
|
| 35 |
+
context = "pre-race estimate" if is_prerace else "historical query"
|
| 36 |
+
critique_notes += f"REJECTED: Win probability is {prob:.1%}, below the {reject_threshold:.0%} threshold for a {context}. The model is not confident.\n\n"
|
| 37 |
+
elif is_prerace and prob < 0.20:
|
| 38 |
+
confidence_rating = "PreRace"
|
| 39 |
+
critique_notes += f"PRE-RACE ESTIMATE: Win probability is {prob:.1%}. This is a pre-race prediction using qualifying data only — tires, weather, and race pace data are unavailable. Rankings are meaningful but absolute probabilities are lower than post-race queries.\n\n"
|
| 40 |
elif prob > 0.95:
|
| 41 |
confidence_rating = "Overconfident"
|
| 42 |
critique_notes += f"FLAGGED: Win probability is unusually high ({prob:.1%}). In modern F1, >95% confidence may indicate data leakage or a hyper-dominant driver in the dataset. Proceed with caution.\n\n"
|
agents/data_agent.py
CHANGED
|
@@ -159,7 +159,7 @@ def _load_feature_data(data_path: str | Path) -> pd.DataFrame:
|
|
| 159 |
return pd.read_parquet(path)
|
| 160 |
|
| 161 |
|
| 162 |
-
def _filter_by_intent(df: pd.DataFrame, intent: QueryIntent) -> pd.DataFrame:
|
| 163 |
rows = df[df["season"] == intent["season"]].copy()
|
| 164 |
|
| 165 |
round_num = intent.get("round")
|
|
@@ -188,6 +188,9 @@ def _filter_by_intent(df: pd.DataFrame, intent: QueryIntent) -> pd.DataFrame:
|
|
| 188 |
f"No rows found for season={intent['season']} round={round_num} grand_prix={grand_prix}"
|
| 189 |
)
|
| 190 |
|
|
|
|
|
|
|
|
|
|
| 191 |
driver_id = _normalize_driver_id(intent.get("driver_id"))
|
| 192 |
driver_name = intent.get("driver_name")
|
| 193 |
|
|
|
|
| 159 |
return pd.read_parquet(path)
|
| 160 |
|
| 161 |
|
| 162 |
+
def _filter_by_intent(df: pd.DataFrame, intent: QueryIntent, filter_driver: bool = True) -> pd.DataFrame:
|
| 163 |
rows = df[df["season"] == intent["season"]].copy()
|
| 164 |
|
| 165 |
round_num = intent.get("round")
|
|
|
|
| 188 |
f"No rows found for season={intent['season']} round={round_num} grand_prix={grand_prix}"
|
| 189 |
)
|
| 190 |
|
| 191 |
+
if not filter_driver:
|
| 192 |
+
return rows.reset_index(drop=True)
|
| 193 |
+
|
| 194 |
driver_id = _normalize_driver_id(intent.get("driver_id"))
|
| 195 |
driver_name = intent.get("driver_name")
|
| 196 |
|
agents/synthesis_agent.py
CHANGED
|
@@ -18,12 +18,16 @@ class SynthesisOutput(TypedDict):
|
|
| 18 |
def synthesis_agent(
|
| 19 |
query: str,
|
| 20 |
prediction: PredictionOutput,
|
| 21 |
-
critique: CritiqueOutput
|
|
|
|
| 22 |
) -> SynthesisOutput:
|
| 23 |
"""Uses Llama3 to write the final explanation for the user."""
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
return {
|
| 28 |
"final_response": (
|
| 29 |
"I apologize, but my underlying machine learning model is not confident enough "
|
|
@@ -48,20 +52,41 @@ YOUR JOB:
|
|
| 48 |
Synthesize this mathematical data into a clear, confident, and professional answer for the user.
|
| 49 |
Speak like an F1 Race Engineer on the pit wall.
|
| 50 |
Do NOT hallucinate reasons. Only use the features provided in the Critique Notes.
|
| 51 |
-
Explain what the features mean in a natural way
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
Keep it concise (1-2 paragraphs).
|
| 54 |
"""
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
user_prompt = f"""
|
| 57 |
USER QUERY:
|
| 58 |
"{query}"
|
| 59 |
|
| 60 |
PREDICTED DRIVER: {prediction.get('driver_name', 'Unknown Driver')}
|
|
|
|
| 61 |
|
| 62 |
ML PREDICTION:
|
| 63 |
Win Probability: {prediction['probability']:.1%}
|
| 64 |
Confidence Rating: {critique['confidence_rating']}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
CRITIQUE AGENT NOTES (SHAP ANALYSIS):
|
| 67 |
{critique['critique_notes']}
|
|
|
|
| 18 |
def synthesis_agent(
|
| 19 |
query: str,
|
| 20 |
prediction: PredictionOutput,
|
| 21 |
+
critique: CritiqueOutput,
|
| 22 |
+
top_contenders: str = ""
|
| 23 |
) -> SynthesisOutput:
|
| 24 |
"""Uses Llama3 to write the final explanation for the user."""
|
| 25 |
|
| 26 |
+
is_prerace = prediction.get("is_prerace", False)
|
| 27 |
+
|
| 28 |
+
# If mathematically rejected AND it's not a pre-race query → hard reject
|
| 29 |
+
# For pre-race, always call the LLM because the ranking is still meaningful
|
| 30 |
+
if not critique["approved"] and not is_prerace:
|
| 31 |
return {
|
| 32 |
"final_response": (
|
| 33 |
"I apologize, but my underlying machine learning model is not confident enough "
|
|
|
|
| 52 |
Synthesize this mathematical data into a clear, confident, and professional answer for the user.
|
| 53 |
Speak like an F1 Race Engineer on the pit wall.
|
| 54 |
Do NOT hallucinate reasons. Only use the features provided in the Critique Notes.
|
| 55 |
+
Explain what the features mean in a natural way.
|
| 56 |
+
Specifically:
|
| 57 |
+
- If 'grid_position' or 'pole_conversion_rate' has a strong impact, explain that qualifying position is historically crucial at this specific circuit. Note that our model uses a smoothed historical average, so even newer tracks are grounded in global F1 reality.
|
| 58 |
+
- If 'career_race_starts' or 'driver_form_last3' heavily impact a prediction, explain the "experience factor". The model heavily trusts the recent form of proven veterans (100+ starts) but correctly discounts the form of rookies who have a very small sample size of races.
|
| 59 |
+
- IMPORTANT: If the user asks about new engine components, fresh power units, or new car parts/upgrades, you MUST acknowledge that our predictive model operates purely on historical telemetry and lap data, and DOES NOT have access to internal team data regarding unannounced parts upgrades or new engines.
|
| 60 |
+
- You have been provided the top contenders for this race. If the predicted driver is NOT one of the top favorites, you MUST mention the actual favorites to provide accurate context (e.g., "While Antonelli has a 5% chance, keep in mind Verstappen and Hamilton are the heavy favorites").
|
| 61 |
+
- CRASH DETECTION: You will be provided the driver's 'Qualifying Status'. If it says something like 'Accident', 'Collision', 'Spun off', or anything indicating a crash, you MUST dramatically mention that they crashed in qualifying. Note that a driver can still have a good grid position (e.g. they set a fast lap in Q3 and THEN crashed, which is a known loophole). If they crashed but have a good grid position, explicitly point out this loophole!
|
| 62 |
+
|
| 63 |
+
IMPORTANT — If Confidence Rating is "PreRace":
|
| 64 |
+
This is a PRE-RACE prediction using only qualifying data. Tire strategy, weather, and pit-stop data are not yet available.
|
| 65 |
+
The model's absolute probabilities are compressed across 22 drivers, so the RANKING matters more than the exact number.
|
| 66 |
+
Frame your response as a pre-race assessment, not a definitive prediction. Mention the key qualifying factors (sector times, grid position, driver form).
|
| 67 |
|
| 68 |
Keep it concise (1-2 paragraphs).
|
| 69 |
"""
|
| 70 |
|
| 71 |
+
# For pre-race rejected predictions (below 5% threshold), explain it's a long-shot
|
| 72 |
+
prerace_note = ""
|
| 73 |
+
if not critique["approved"] and is_prerace:
|
| 74 |
+
prerace_note = f"\nNOTE: This driver's win probability ({prediction['probability']:.1%}) is very low — they are considered a long-shot for this race based on qualifying data."
|
| 75 |
+
|
| 76 |
user_prompt = f"""
|
| 77 |
USER QUERY:
|
| 78 |
"{query}"
|
| 79 |
|
| 80 |
PREDICTED DRIVER: {prediction.get('driver_name', 'Unknown Driver')}
|
| 81 |
+
Qualifying Status: {prediction.get('quali_status', 'Finished')}
|
| 82 |
|
| 83 |
ML PREDICTION:
|
| 84 |
Win Probability: {prediction['probability']:.1%}
|
| 85 |
Confidence Rating: {critique['confidence_rating']}
|
| 86 |
+
{prerace_note}
|
| 87 |
+
|
| 88 |
+
OVERALL RACE FAVORITES:
|
| 89 |
+
{top_contenders if top_contenders else "Unknown"}
|
| 90 |
|
| 91 |
CRITIQUE AGENT NOTES (SHAP ANALYSIS):
|
| 92 |
{critique['critique_notes']}
|
api/main.py
CHANGED
|
@@ -26,6 +26,9 @@ from fastapi import FastAPI, HTTPException, Query
|
|
| 26 |
from fastapi.middleware.cors import CORSMiddleware
|
| 27 |
|
| 28 |
from agents.data_agent import data_agent, QueryIntent
|
|
|
|
|
|
|
|
|
|
| 29 |
from api.schemas import (
|
| 30 |
DriverInfo,
|
| 31 |
ErrorResponse,
|
|
@@ -34,6 +37,8 @@ from api.schemas import (
|
|
| 34 |
PredictionRequest,
|
| 35 |
PredictionResponse,
|
| 36 |
RaceInfo,
|
|
|
|
|
|
|
| 37 |
)
|
| 38 |
from ml.predict import load_model_and_encoders, predict_dataframe
|
| 39 |
|
|
@@ -49,6 +54,7 @@ _model = None
|
|
| 49 |
_encoders = None
|
| 50 |
_races_data: pd.DataFrame | None = None
|
| 51 |
_drivers_set: set[str] = set()
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def patch_mlruns_paths():
|
|
@@ -139,6 +145,22 @@ async def lifespan(app: FastAPI):
|
|
| 139 |
else:
|
| 140 |
logger.warning(f"Data not found: {data_path}")
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
except Exception as e:
|
| 143 |
logger.error(f"Startup error: {e}")
|
| 144 |
|
|
@@ -215,28 +237,78 @@ async def predict_f1(request: PredictionRequest) -> PredictionResponse:
|
|
| 215 |
detail="Model not loaded. Set KRONECTOR_MODEL_RUN_ID.",
|
| 216 |
)
|
| 217 |
|
| 218 |
-
if _races_data is None:
|
| 219 |
raise HTTPException(
|
| 220 |
status_code=503,
|
| 221 |
detail="Race data not loaded. Check data_output/fastf1_races.parquet",
|
| 222 |
)
|
| 223 |
|
| 224 |
try:
|
| 225 |
-
# Parse query → intent
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
raise HTTPException(
|
| 232 |
status_code=400,
|
| 233 |
-
detail=
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
)
|
| 236 |
|
| 237 |
# Generate predictions
|
| 238 |
predictions = predict_dataframe(df, _model, _encoders, explain=True)
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
# If the user asked "Who will win?" and matched multiple drivers,
|
| 241 |
# sort by probability to find the most likely winner!
|
| 242 |
if len(predictions) > 1:
|
|
@@ -244,6 +316,14 @@ async def predict_f1(request: PredictionRequest) -> PredictionResponse:
|
|
| 244 |
|
| 245 |
pred_row = predictions.iloc[0]
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
# 1. Format raw prediction output
|
| 248 |
raw_prob = float(pred_row["win_probability"])
|
| 249 |
shap_values = pred_row.get("shap_values", {})
|
|
@@ -254,24 +334,26 @@ async def predict_f1(request: PredictionRequest) -> PredictionResponse:
|
|
| 254 |
"feature_names": list(shap_values.keys()) if shap_values else [],
|
| 255 |
"model_version": "latest",
|
| 256 |
"run_id": "api",
|
| 257 |
-
"driver_name": str(pred_row["driver_name"])
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
|
| 260 |
# 2. Mathematical Critique
|
| 261 |
critique = critique_agent(prediction_output)
|
| 262 |
|
| 263 |
# 3. LLM Synthesis
|
| 264 |
-
synthesis = synthesis_agent(request.query, prediction_output, critique)
|
| 265 |
|
| 266 |
return PredictionResponse(
|
| 267 |
-
win_probability=raw_prob,
|
| 268 |
metadata=PredictionMetadata(
|
| 269 |
season=int(pred_row["season"]),
|
| 270 |
round=int(pred_row["round"]),
|
| 271 |
driver_id=str(pred_row["driver_id"]),
|
| 272 |
driver_name=str(pred_row["driver_name"]),
|
| 273 |
team=str(pred_row["team"]),
|
| 274 |
-
grid_position=
|
| 275 |
),
|
| 276 |
shap_values=shap_values,
|
| 277 |
llm_explanation=synthesis["final_response"],
|
|
@@ -289,6 +371,151 @@ async def predict_f1(request: PredictionRequest) -> PredictionResponse:
|
|
| 289 |
) from e
|
| 290 |
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
@app.get("/drivers", response_model=list[DriverInfo])
|
| 293 |
async def list_drivers(
|
| 294 |
season: Optional[int] = Query(None, description="Filter by season"),
|
|
|
|
| 26 |
from fastapi.middleware.cors import CORSMiddleware
|
| 27 |
|
| 28 |
from agents.data_agent import data_agent, QueryIntent
|
| 29 |
+
from agents.critique_agent import critique_agent
|
| 30 |
+
from agents.synthesis_agent import synthesis_agent
|
| 31 |
+
from agents.compare_agent import compare_agent
|
| 32 |
from api.schemas import (
|
| 33 |
DriverInfo,
|
| 34 |
ErrorResponse,
|
|
|
|
| 37 |
PredictionRequest,
|
| 38 |
PredictionResponse,
|
| 39 |
RaceInfo,
|
| 40 |
+
CompareRequest,
|
| 41 |
+
CompareResponse
|
| 42 |
)
|
| 43 |
from ml.predict import load_model_and_encoders, predict_dataframe
|
| 44 |
|
|
|
|
| 54 |
_encoders = None
|
| 55 |
_races_data: pd.DataFrame | None = None
|
| 56 |
_drivers_set: set[str] = set()
|
| 57 |
+
_prerace_data: dict[str, pd.DataFrame] = {} # keyed by "season_round" e.g. "2026_7"
|
| 58 |
|
| 59 |
|
| 60 |
def patch_mlruns_paths():
|
|
|
|
| 145 |
else:
|
| 146 |
logger.warning(f"Data not found: {data_path}")
|
| 147 |
|
| 148 |
+
# Load any pre-race parquet files from data_output/prerace/
|
| 149 |
+
prerace_dir = Path("data_output/prerace")
|
| 150 |
+
if prerace_dir.exists():
|
| 151 |
+
for pf in prerace_dir.glob("*.parquet"):
|
| 152 |
+
try:
|
| 153 |
+
prerace_df = pd.read_parquet(pf)
|
| 154 |
+
if not prerace_df.empty:
|
| 155 |
+
season = int(prerace_df["season"].iloc[0])
|
| 156 |
+
round_num = int(prerace_df["round"].iloc[0])
|
| 157 |
+
key = f"{season}_{round_num}"
|
| 158 |
+
_prerace_data[key] = prerace_df
|
| 159 |
+
circuit = prerace_df["circuit_id"].iloc[0]
|
| 160 |
+
logger.info(f"✓ Pre-race data loaded: {key} ({circuit}) — {len(prerace_df)} drivers")
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.warning(f"Could not load pre-race file {pf}: {e}")
|
| 163 |
+
|
| 164 |
except Exception as e:
|
| 165 |
logger.error(f"Startup error: {e}")
|
| 166 |
|
|
|
|
| 237 |
detail="Model not loaded. Set KRONECTOR_MODEL_RUN_ID.",
|
| 238 |
)
|
| 239 |
|
| 240 |
+
if _races_data is None and not _prerace_data:
|
| 241 |
raise HTTPException(
|
| 242 |
status_code=503,
|
| 243 |
detail="Race data not loaded. Check data_output/fastf1_races.parquet",
|
| 244 |
)
|
| 245 |
|
| 246 |
try:
|
| 247 |
+
# Parse query → intent using the main historical parquet
|
| 248 |
+
# If the round isn't in the main parquet, fall back to pre-race data
|
| 249 |
+
from agents.data_agent import parse_query_with_groq, _filter_by_intent
|
| 250 |
+
intent = parse_query_with_groq(request.query)
|
| 251 |
+
|
| 252 |
+
df = pd.DataFrame()
|
| 253 |
+
is_prerace = False
|
| 254 |
|
| 255 |
+
# 1. Try the main historical parquet first
|
| 256 |
+
if _races_data is not None:
|
| 257 |
+
try:
|
| 258 |
+
df = _filter_by_intent(_races_data, intent, filter_driver=False)
|
| 259 |
+
except (ValueError, KeyError):
|
| 260 |
+
df = pd.DataFrame()
|
| 261 |
+
|
| 262 |
+
# 2. If not found in historical data, try pre-race data
|
| 263 |
+
if df.empty and _prerace_data:
|
| 264 |
+
logger.info(f"Round not in historical data — searching pre-race store for intent: {intent}")
|
| 265 |
+
for key, prerace_df in _prerace_data.items():
|
| 266 |
+
try:
|
| 267 |
+
df = _filter_by_intent(prerace_df, intent, filter_driver=False)
|
| 268 |
+
if not df.empty:
|
| 269 |
+
is_prerace = True
|
| 270 |
+
logger.info(f"✓ Found match in pre-race data: {key}")
|
| 271 |
+
break
|
| 272 |
+
except (ValueError, KeyError):
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
if df.empty:
|
| 276 |
raise HTTPException(
|
| 277 |
status_code=400,
|
| 278 |
+
detail=(
|
| 279 |
+
f"No matching data for query. Season={intent.get('season')}, "
|
| 280 |
+
f"GP='{intent.get('grand_prix')}'. "
|
| 281 |
+
"If this is an upcoming race, run: "
|
| 282 |
+
"python -m scripts.build_prerace_rows --season <year> --round <num>"
|
| 283 |
+
),
|
| 284 |
)
|
| 285 |
|
| 286 |
# Generate predictions
|
| 287 |
predictions = predict_dataframe(df, _model, _encoders, explain=True)
|
| 288 |
|
| 289 |
+
# Get top 3 contenders for context before filtering
|
| 290 |
+
top_df = predictions.sort_values(by="win_probability", ascending=False).head(3)
|
| 291 |
+
top_contenders = ", ".join(
|
| 292 |
+
[f"{r['driver_name']} ({r['win_probability']*100:.1f}%)" for _, r in top_df.iterrows()]
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
# Now filter down to the requested driver if specified
|
| 296 |
+
driver_id = intent.get("driver_id")
|
| 297 |
+
driver_name = intent.get("driver_name")
|
| 298 |
+
|
| 299 |
+
import re
|
| 300 |
+
if driver_id:
|
| 301 |
+
d_id = str(driver_id).strip().upper()
|
| 302 |
+
predictions = predictions[predictions["driver_id"].astype(str).str.upper() == d_id]
|
| 303 |
+
elif driver_name:
|
| 304 |
+
d_name = str(driver_name).strip().lower()
|
| 305 |
+
predictions = predictions[
|
| 306 |
+
predictions["driver_name"].astype(str).str.lower().str.contains(re.escape(d_name), na=False)
|
| 307 |
+
]
|
| 308 |
+
|
| 309 |
+
if predictions.empty:
|
| 310 |
+
raise HTTPException(status_code=400, detail=f"No predictions matched driver intent: {intent}")
|
| 311 |
+
|
| 312 |
# If the user asked "Who will win?" and matched multiple drivers,
|
| 313 |
# sort by probability to find the most likely winner!
|
| 314 |
if len(predictions) > 1:
|
|
|
|
| 316 |
|
| 317 |
pred_row = predictions.iloc[0]
|
| 318 |
|
| 319 |
+
# grid_position is stripped from metadata by predict_dataframe — look it up
|
| 320 |
+
# directly from the source df using the matched driver_id.
|
| 321 |
+
matched_driver_id = str(pred_row["driver_id"])
|
| 322 |
+
grid_pos_series = df.loc[
|
| 323 |
+
df["driver_id"].astype(str) == matched_driver_id, "grid_position"
|
| 324 |
+
]
|
| 325 |
+
grid_pos = float(grid_pos_series.iloc[0]) if not grid_pos_series.empty else 0.0
|
| 326 |
+
|
| 327 |
# 1. Format raw prediction output
|
| 328 |
raw_prob = float(pred_row["win_probability"])
|
| 329 |
shap_values = pred_row.get("shap_values", {})
|
|
|
|
| 334 |
"feature_names": list(shap_values.keys()) if shap_values else [],
|
| 335 |
"model_version": "latest",
|
| 336 |
"run_id": "api",
|
| 337 |
+
"driver_name": str(pred_row["driver_name"]),
|
| 338 |
+
"is_prerace": is_prerace,
|
| 339 |
+
"quali_status": "Crash" if getattr(request, "crashed_in_quali", False) else str(pred_row.get("quali_status", "Finished")),
|
| 340 |
}
|
| 341 |
|
| 342 |
# 2. Mathematical Critique
|
| 343 |
critique = critique_agent(prediction_output)
|
| 344 |
|
| 345 |
# 3. LLM Synthesis
|
| 346 |
+
synthesis = synthesis_agent(request.query, prediction_output, critique, top_contenders=top_contenders)
|
| 347 |
|
| 348 |
return PredictionResponse(
|
| 349 |
+
win_probability=round(raw_prob * 100, 2),
|
| 350 |
metadata=PredictionMetadata(
|
| 351 |
season=int(pred_row["season"]),
|
| 352 |
round=int(pred_row["round"]),
|
| 353 |
driver_id=str(pred_row["driver_id"]),
|
| 354 |
driver_name=str(pred_row["driver_name"]),
|
| 355 |
team=str(pred_row["team"]),
|
| 356 |
+
grid_position=grid_pos,
|
| 357 |
),
|
| 358 |
shap_values=shap_values,
|
| 359 |
llm_explanation=synthesis["final_response"],
|
|
|
|
| 371 |
) from e
|
| 372 |
|
| 373 |
|
| 374 |
+
@app.post(
|
| 375 |
+
"/predict/compare",
|
| 376 |
+
response_model=CompareResponse,
|
| 377 |
+
description="Compare two drivers head-to-head using mathematical SHAP Deltas",
|
| 378 |
+
tags=["Prediction"]
|
| 379 |
+
)
|
| 380 |
+
def compare_drivers(request: CompareRequest):
|
| 381 |
+
"""Head-to-head driver matchup endpoint."""
|
| 382 |
+
if _model is None or _encoders is None:
|
| 383 |
+
raise HTTPException(
|
| 384 |
+
status_code=503, detail="Model not loaded. Set KRONECTOR_MODEL_RUN_ID."
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
try:
|
| 388 |
+
import re
|
| 389 |
+
import json
|
| 390 |
+
is_prerace = False
|
| 391 |
+
df = pd.DataFrame()
|
| 392 |
+
race_name = "Unknown Race"
|
| 393 |
+
|
| 394 |
+
# 1. Season and round specified?
|
| 395 |
+
if request.season and request.round:
|
| 396 |
+
prerace_file = Path(f"data_output/prerace/prerace_*_{request.season}.parquet")
|
| 397 |
+
import glob
|
| 398 |
+
files = glob.glob(str(prerace_file))
|
| 399 |
+
|
| 400 |
+
found = False
|
| 401 |
+
for f in files:
|
| 402 |
+
pdf = pd.read_parquet(f)
|
| 403 |
+
if not pdf.empty and int(pdf["season"].iloc[0]) == request.season and int(pdf["round"].iloc[0]) == request.round:
|
| 404 |
+
df = pdf
|
| 405 |
+
is_prerace = True
|
| 406 |
+
race_name = f"{request.season} Round {request.round}"
|
| 407 |
+
found = True
|
| 408 |
+
break
|
| 409 |
+
|
| 410 |
+
if not found:
|
| 411 |
+
global _races_data
|
| 412 |
+
if _races_data is not None and not _races_data.empty:
|
| 413 |
+
df = _races_data[
|
| 414 |
+
(_races_data["season"] == request.season) &
|
| 415 |
+
(_races_data["round"] == request.round)
|
| 416 |
+
]
|
| 417 |
+
race_name = f"{request.season} Round {request.round}"
|
| 418 |
+
|
| 419 |
+
# 2. No season/round specified, use latest
|
| 420 |
+
if df.empty:
|
| 421 |
+
prerace_dir = Path("data_output/prerace")
|
| 422 |
+
if prerace_dir.exists():
|
| 423 |
+
files = list(prerace_dir.glob("*.parquet"))
|
| 424 |
+
if files:
|
| 425 |
+
df = pd.read_parquet(files[0])
|
| 426 |
+
is_prerace = True
|
| 427 |
+
s = int(df["season"].iloc[0])
|
| 428 |
+
r = int(df["round"].iloc[0])
|
| 429 |
+
race_name = f"{s} Round {r}"
|
| 430 |
+
|
| 431 |
+
if df.empty and _races_data is not None and not _races_data.empty:
|
| 432 |
+
latest_season = _races_data["season"].max()
|
| 433 |
+
latest_round = _races_data[_races_data["season"] == latest_season]["round"].max()
|
| 434 |
+
df = _races_data[
|
| 435 |
+
(_races_data["season"] == latest_season) &
|
| 436 |
+
(_races_data["round"] == latest_round)
|
| 437 |
+
]
|
| 438 |
+
race_name = f"{latest_season} Round {latest_round}"
|
| 439 |
+
|
| 440 |
+
if df.empty:
|
| 441 |
+
raise HTTPException(status_code=400, detail="Could not find race data for comparison.")
|
| 442 |
+
|
| 443 |
+
predictions = predict_dataframe(df, _model, _encoders, explain=True)
|
| 444 |
+
|
| 445 |
+
d1_str = request.driver1.strip().lower()
|
| 446 |
+
d1_df = predictions[
|
| 447 |
+
(predictions["driver_id"].str.lower() == d1_str) |
|
| 448 |
+
(predictions["driver_name"].str.lower().str.contains(re.escape(d1_str), na=False))
|
| 449 |
+
]
|
| 450 |
+
if d1_df.empty:
|
| 451 |
+
raise HTTPException(status_code=400, detail=f"Driver '{request.driver1}' not found in race {race_name}.")
|
| 452 |
+
d1_row = d1_df.iloc[0]
|
| 453 |
+
|
| 454 |
+
d2_str = request.driver2.strip().lower()
|
| 455 |
+
d2_df = predictions[
|
| 456 |
+
(predictions["driver_id"].str.lower() == d2_str) |
|
| 457 |
+
(predictions["driver_name"].str.lower().str.contains(re.escape(d2_str), na=False))
|
| 458 |
+
]
|
| 459 |
+
if d2_df.empty:
|
| 460 |
+
raise HTTPException(status_code=400, detail=f"Driver '{request.driver2}' not found in race {race_name}.")
|
| 461 |
+
d2_row = d2_df.iloc[0]
|
| 462 |
+
|
| 463 |
+
d1_prob = float(d1_row["win_probability"]) * 100
|
| 464 |
+
d2_prob = float(d2_row["win_probability"]) * 100
|
| 465 |
+
|
| 466 |
+
d1_shap = d1_row["shap_values"]
|
| 467 |
+
d2_shap = d2_row["shap_values"]
|
| 468 |
+
if isinstance(d1_shap, str):
|
| 469 |
+
d1_shap = json.loads(d1_shap)
|
| 470 |
+
if isinstance(d2_shap, str):
|
| 471 |
+
d2_shap = json.loads(d2_shap)
|
| 472 |
+
|
| 473 |
+
deltas = {}
|
| 474 |
+
for feature in d1_shap.keys():
|
| 475 |
+
if feature in d2_shap:
|
| 476 |
+
deltas[feature] = d1_shap[feature] - d2_shap[feature]
|
| 477 |
+
|
| 478 |
+
llm_analysis = compare_agent(
|
| 479 |
+
driver1_name=str(d1_row["driver_name"]),
|
| 480 |
+
driver1_prob=d1_prob,
|
| 481 |
+
driver1_status=str(d1_row.get("quali_status", "Finished")),
|
| 482 |
+
driver2_name=str(d2_row["driver_name"]),
|
| 483 |
+
driver2_prob=d2_prob,
|
| 484 |
+
driver2_status=str(d2_row.get("quali_status", "Finished")),
|
| 485 |
+
shap_deltas=deltas,
|
| 486 |
+
race_context=f"{race_name} {'(Pre-Race)' if is_prerace else '(Historical)'}"
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
return CompareResponse(
|
| 490 |
+
driver1=PredictionMetadata(
|
| 491 |
+
season=int(d1_row["season"]),
|
| 492 |
+
round=int(d1_row["round"]),
|
| 493 |
+
driver_id=str(d1_row["driver_id"]),
|
| 494 |
+
driver_name=str(d1_row["driver_name"]),
|
| 495 |
+
team=str(d1_row["team"]),
|
| 496 |
+
grid_position=float(d1_row.get("grid_position", 0.0)),
|
| 497 |
+
),
|
| 498 |
+
driver2=PredictionMetadata(
|
| 499 |
+
season=int(d2_row["season"]),
|
| 500 |
+
round=int(d2_row["round"]),
|
| 501 |
+
driver_id=str(d2_row["driver_id"]),
|
| 502 |
+
driver_name=str(d2_row["driver_name"]),
|
| 503 |
+
team=str(d2_row["team"]),
|
| 504 |
+
grid_position=float(d2_row.get("grid_position", 0.0)),
|
| 505 |
+
),
|
| 506 |
+
driver1_win_probability=round(d1_prob, 2),
|
| 507 |
+
driver2_win_probability=round(d2_prob, 2),
|
| 508 |
+
shap_deltas=deltas,
|
| 509 |
+
llm_analysis=llm_analysis
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
except HTTPException:
|
| 513 |
+
raise
|
| 514 |
+
except Exception as e:
|
| 515 |
+
logger.error(f"Compare prediction error: {e}", exc_info=True)
|
| 516 |
+
raise HTTPException(status_code=500, detail=f"Comparison failed: {e}")
|
| 517 |
+
|
| 518 |
+
|
| 519 |
@app.get("/drivers", response_model=list[DriverInfo])
|
| 520 |
async def list_drivers(
|
| 521 |
season: Optional[int] = Query(None, description="Filter by season"),
|
api/schemas.py
CHANGED
|
@@ -16,6 +16,9 @@ class PredictionRequest(BaseModel):
|
|
| 16 |
max_length=500,
|
| 17 |
examples=["What's Max's win probability at Monaco 2023?"],
|
| 18 |
)
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
class PredictionMetadata(BaseModel):
|
|
@@ -33,7 +36,7 @@ class PredictionResponse(BaseModel):
|
|
| 33 |
"""Win probability prediction with SHAP explanations."""
|
| 34 |
|
| 35 |
win_probability: float = Field(
|
| 36 |
-
..., ge=0, le=
|
| 37 |
)
|
| 38 |
metadata: PredictionMetadata
|
| 39 |
shap_values: Optional[dict[str, float]] = Field(
|
|
@@ -77,3 +80,34 @@ class ErrorResponse(BaseModel):
|
|
| 77 |
|
| 78 |
detail: str
|
| 79 |
error_code: str = Field(..., examples=["INVALID_QUERY", "MODEL_NOT_LOADED"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
max_length=500,
|
| 17 |
examples=["What's Max's win probability at Monaco 2023?"],
|
| 18 |
)
|
| 19 |
+
crashed_in_quali: bool = Field(
|
| 20 |
+
False, description="Manually flag if the driver crashed in qualifying"
|
| 21 |
+
)
|
| 22 |
|
| 23 |
|
| 24 |
class PredictionMetadata(BaseModel):
|
|
|
|
| 36 |
"""Win probability prediction with SHAP explanations."""
|
| 37 |
|
| 38 |
win_probability: float = Field(
|
| 39 |
+
..., ge=0, le=100, description="Win probability as a percentage (0-100)"
|
| 40 |
)
|
| 41 |
metadata: PredictionMetadata
|
| 42 |
shap_values: Optional[dict[str, float]] = Field(
|
|
|
|
| 80 |
|
| 81 |
detail: str
|
| 82 |
error_code: str = Field(..., examples=["INVALID_QUERY", "MODEL_NOT_LOADED"])
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class CompareRequest(BaseModel):
|
| 86 |
+
"""Request for Head-to-Head Driver Comparison."""
|
| 87 |
+
|
| 88 |
+
driver1: str = Field(
|
| 89 |
+
...,
|
| 90 |
+
examples=["Max Verstappen", "VER"],
|
| 91 |
+
description="First driver to compare (name or 3-letter code)"
|
| 92 |
+
)
|
| 93 |
+
driver2: str = Field(
|
| 94 |
+
...,
|
| 95 |
+
examples=["Kimi Antonelli", "ANT"],
|
| 96 |
+
description="Second driver to compare (name or 3-letter code)"
|
| 97 |
+
)
|
| 98 |
+
season: Optional[int] = Field(None, description="Optional year (defaults to latest available)")
|
| 99 |
+
round: Optional[int] = Field(None, description="Optional round number (defaults to latest available)")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class CompareResponse(BaseModel):
|
| 103 |
+
"""Head-to-Head Comparison Response."""
|
| 104 |
+
|
| 105 |
+
driver1: PredictionMetadata
|
| 106 |
+
driver2: PredictionMetadata
|
| 107 |
+
driver1_win_probability: float
|
| 108 |
+
driver2_win_probability: float
|
| 109 |
+
shap_deltas: dict[str, float] = Field(
|
| 110 |
+
...,
|
| 111 |
+
description="Mathematical difference between driver 1 and driver 2 SHAP values (positive means driver 1 has the edge, negative means driver 2 has the edge)"
|
| 112 |
+
)
|
| 113 |
+
llm_analysis: str = Field(..., description="Groq's Head-to-Head tale of the tape")
|
data/__init__.py
CHANGED
|
@@ -61,6 +61,85 @@ def _get_track_type(circuit_id: str) -> str:
|
|
| 61 |
return "permanent"
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# ---------------------------------------------------------------------------
|
| 65 |
# Safety car probability computation (Correction 2)
|
| 66 |
# ---------------------------------------------------------------------------
|
|
@@ -298,6 +377,35 @@ def merge_datasets(
|
|
| 298 |
"No lap data — safety_car_probability set to 0 for all rows"
|
| 299 |
)
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
# -------------------------------------------------------------------
|
| 302 |
# Step 9: Add target variable
|
| 303 |
# -------------------------------------------------------------------
|
|
|
|
| 61 |
return "permanent"
|
| 62 |
|
| 63 |
|
| 64 |
+
def compute_pole_conversion_rate(df: pd.DataFrame) -> pd.DataFrame:
|
| 65 |
+
"""
|
| 66 |
+
Compute historical pole-to-win conversion rate per circuit.
|
| 67 |
+
|
| 68 |
+
For each circuit, calculates what % of races the pole-sitter went on to win.
|
| 69 |
+
This directly encodes "qualifying matters at this track" — e.g.:
|
| 70 |
+
- Monaco ~75-80% (almost impossible to overtake)
|
| 71 |
+
- Monza ~40-45% (long straights, slipstream, DRS)
|
| 72 |
+
|
| 73 |
+
Uses only COMPLETED races (requires finish_position data).
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
df: Historical race DataFrame with circuit_id, grid_position, finish_position
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
DataFrame with columns (circuit_id, pole_conversion_rate)
|
| 80 |
+
"""
|
| 81 |
+
if df.empty or "finish_position" not in df.columns:
|
| 82 |
+
return pd.DataFrame(columns=["circuit_id", "pole_conversion_rate"])
|
| 83 |
+
|
| 84 |
+
# Only use rows where we have actual race results
|
| 85 |
+
completed = df.dropna(subset=["finish_position"]).copy()
|
| 86 |
+
|
| 87 |
+
# Get pole-sitters (grid_position == 1) for each race
|
| 88 |
+
poles = completed[completed["grid_position"] == 1].copy()
|
| 89 |
+
|
| 90 |
+
if poles.empty:
|
| 91 |
+
return pd.DataFrame(columns=["circuit_id", "pole_conversion_rate"])
|
| 92 |
+
|
| 93 |
+
poles["pole_won"] = (poles["finish_position"] == 1).astype(int)
|
| 94 |
+
|
| 95 |
+
global_mean = poles["pole_won"].mean()
|
| 96 |
+
C = 3.0 # Confidence weight (pseudo-observations)
|
| 97 |
+
|
| 98 |
+
# Calculate wins and total poles per circuit
|
| 99 |
+
stats = poles.groupby("circuit_id").agg(
|
| 100 |
+
wins=("pole_won", "sum"),
|
| 101 |
+
total=("pole_won", "count")
|
| 102 |
+
).reset_index()
|
| 103 |
+
|
| 104 |
+
# Apply Bayesian smoothing: pulls low-N circuits towards the global average
|
| 105 |
+
stats["pole_conversion_rate"] = (stats["wins"] + C * global_mean) / (stats["total"] + C)
|
| 106 |
+
|
| 107 |
+
conversion = stats[["circuit_id", "pole_conversion_rate"]]
|
| 108 |
+
|
| 109 |
+
logger.info(
|
| 110 |
+
f"Computed pole_conversion_rate for {len(conversion)} circuits. "
|
| 111 |
+
f"Range: {conversion['pole_conversion_rate'].min():.1%} – "
|
| 112 |
+
f"{conversion['pole_conversion_rate'].max():.1%}"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
return conversion
|
| 116 |
+
|
| 117 |
+
return conversion
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Career Race Starts computation
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
def compute_career_race_starts(df: pd.DataFrame) -> pd.DataFrame:
|
| 125 |
+
"""
|
| 126 |
+
Compute cumulative career race starts for each driver before each race.
|
| 127 |
+
This provides an 'experience' feature so the model knows if driver form
|
| 128 |
+
is based on a veteran's long track record or a rookie's small sample size.
|
| 129 |
+
"""
|
| 130 |
+
if df.empty or "driver_id" not in df.columns:
|
| 131 |
+
return pd.DataFrame(columns=["season", "round", "driver_id", "career_race_starts"])
|
| 132 |
+
|
| 133 |
+
# Sort chronologically
|
| 134 |
+
df_sorted = df.sort_values(by=["driver_id", "season", "round"]).copy()
|
| 135 |
+
|
| 136 |
+
# cumcount() gives 0 for the 1st race, 1 for the 2nd, etc.
|
| 137 |
+
# This exactly represents 'starts prior to this race'
|
| 138 |
+
df_sorted["career_race_starts"] = df_sorted.groupby("driver_id").cumcount()
|
| 139 |
+
|
| 140 |
+
return df_sorted[["season", "round", "driver_id", "career_race_starts"]]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
# ---------------------------------------------------------------------------
|
| 144 |
# Safety car probability computation (Correction 2)
|
| 145 |
# ---------------------------------------------------------------------------
|
|
|
|
| 377 |
"No lap data — safety_car_probability set to 0 for all rows"
|
| 378 |
)
|
| 379 |
|
| 380 |
+
# -------------------------------------------------------------------
|
| 381 |
+
# Step 8.3: Compute career_race_starts per driver
|
| 382 |
+
# -------------------------------------------------------------------
|
| 383 |
+
starts_df = compute_career_race_starts(merged)
|
| 384 |
+
if not starts_df.empty:
|
| 385 |
+
if "career_race_starts" in merged.columns:
|
| 386 |
+
merged = merged.drop(columns=["career_race_starts"])
|
| 387 |
+
merged = merged.merge(starts_df, on=["season", "round", "driver_id"], how="left")
|
| 388 |
+
merged["career_race_starts"] = merged["career_race_starts"].fillna(0)
|
| 389 |
+
logger.info("Joined career_race_starts from cumulative history")
|
| 390 |
+
else:
|
| 391 |
+
merged["career_race_starts"] = 0
|
| 392 |
+
logger.warning("Could not compute career_race_starts — defaulting to 0")
|
| 393 |
+
|
| 394 |
+
# -------------------------------------------------------------------
|
| 395 |
+
# Step 8.5: Compute pole_conversion_rate per circuit
|
| 396 |
+
# -------------------------------------------------------------------
|
| 397 |
+
pcr = compute_pole_conversion_rate(merged)
|
| 398 |
+
if not pcr.empty:
|
| 399 |
+
if "pole_conversion_rate" in merged.columns:
|
| 400 |
+
merged = merged.drop(columns=["pole_conversion_rate"])
|
| 401 |
+
merged = merged.merge(pcr, on="circuit_id", how="left")
|
| 402 |
+
# Circuits with no data default to 50% (neutral)
|
| 403 |
+
merged["pole_conversion_rate"] = merged["pole_conversion_rate"].fillna(0.5)
|
| 404 |
+
logger.info("Joined pole_conversion_rate from historical data")
|
| 405 |
+
else:
|
| 406 |
+
merged["pole_conversion_rate"] = 0.5
|
| 407 |
+
logger.warning("Could not compute pole_conversion_rate — defaulting to 0.5")
|
| 408 |
+
|
| 409 |
# -------------------------------------------------------------------
|
| 410 |
# Step 9: Add target variable
|
| 411 |
# -------------------------------------------------------------------
|
data/fastf1_pipeline.py
CHANGED
|
@@ -54,7 +54,9 @@ def enable_cache(cache_dir: Optional[str] = None) -> None:
|
|
| 54 |
# ---------------------------------------------------------------------------
|
| 55 |
|
| 56 |
def _load_session(
|
| 57 |
-
season: int, round_num: int, session_type: str
|
|
|
|
|
|
|
| 58 |
) -> Optional[fastf1.core.Session]:
|
| 59 |
"""
|
| 60 |
Load a FastF1 session with error handling.
|
|
@@ -63,13 +65,17 @@ def _load_session(
|
|
| 63 |
season: F1 season year (2018–2026)
|
| 64 |
round_num: Race round number within season
|
| 65 |
session_type: One of 'R' (Race), 'Q' (Qualifying), 'FP2', 'FP3'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
Returns:
|
| 68 |
Loaded FastF1 Session object, or None if unavailable.
|
| 69 |
"""
|
| 70 |
try:
|
| 71 |
session = fastf1.get_session(season, round_num, session_type)
|
| 72 |
-
session.load()
|
| 73 |
return session
|
| 74 |
except Exception as e:
|
| 75 |
logger.warning(
|
|
|
|
| 54 |
# ---------------------------------------------------------------------------
|
| 55 |
|
| 56 |
def _load_session(
|
| 57 |
+
season: int, round_num: int, session_type: str,
|
| 58 |
+
laps: bool = True, telemetry: bool = False,
|
| 59 |
+
weather: bool = True, messages: bool = False,
|
| 60 |
) -> Optional[fastf1.core.Session]:
|
| 61 |
"""
|
| 62 |
Load a FastF1 session with error handling.
|
|
|
|
| 65 |
season: F1 season year (2018–2026)
|
| 66 |
round_num: Race round number within season
|
| 67 |
session_type: One of 'R' (Race), 'Q' (Qualifying), 'FP2', 'FP3'
|
| 68 |
+
laps: Load lap timing data (default True)
|
| 69 |
+
telemetry: Load car telemetry (default False — not needed for features)
|
| 70 |
+
weather: Load weather data (default True)
|
| 71 |
+
messages: Load race control messages (default False)
|
| 72 |
|
| 73 |
Returns:
|
| 74 |
Loaded FastF1 Session object, or None if unavailable.
|
| 75 |
"""
|
| 76 |
try:
|
| 77 |
session = fastf1.get_session(season, round_num, session_type)
|
| 78 |
+
session.load(laps=laps, telemetry=telemetry, weather=weather, messages=messages)
|
| 79 |
return session
|
| 80 |
except Exception as e:
|
| 81 |
logger.warning(
|
data_output/fastf1_races.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2849f1eec94e44f124f04f2df23970214fb004aa5ce64080ed5520b3ec666b88
|
| 3 |
+
size 111244
|
data_output/prerace/prerace_70th_anniversary_gra_2020.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5cd269f7255addee293be9f2edb819a9aee26b922dc13445a7858386fc4797fc
|
| 3 |
+
size 17243
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2014.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b4edb066e72f51c97af2b827a0372b9537bab50931f1d1a41350203110f0427a
|
| 3 |
+
size 16024
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2015.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1f968dac8035d2a6b98fdd2cb948a2919f67afb82c803e1e8fc00a652c1fdc7e
|
| 3 |
+
size 16086
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2016.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1a5601f3056795c2ece6ef7df44574b6af73ff62585112a432387f4fc1de2695
|
| 3 |
+
size 16184
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2017.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:502d00fd14943e14cbf616fadb5bc1a425255eee7b4aa5e3bca31f2366ab4491
|
| 3 |
+
size 16121
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2018.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fee82cb6eb4323b71fec2694d9af9dbd6c83278230d8b46a17fc62e374424fc0
|
| 3 |
+
size 17219
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2019.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a4297d21fe4c3c53af0efa99d4629cb1712cb617f4d7f3fb9fe4b6a697a3b53
|
| 3 |
+
size 17171
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2020.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c8f2097399aeca4cc13c1196013b819ca82644a3de9060dd091558e04ad9b0fe
|
| 3 |
+
size 17197
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2021.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fd73c940a2039393c25e4375c3a95d55a81a7337bb2dccd0c96112be8c54804f
|
| 3 |
+
size 17224
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2022.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1b81b44cce7074d8280e7d3cdddf67178e86429d679b8481bb9052e625b95d8a
|
| 3 |
+
size 17173
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2023.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:808cbab2d0ebf3fd4cdbfc3dff58ed68f98f5ee804041664ca3f530ee40dc283
|
| 3 |
+
size 17197
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2024.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3385b06f5db6ac54b06d75f87f275865612a678c2a0e72c716031707ed5e6b81
|
| 3 |
+
size 17170
|
data_output/prerace/prerace_abu_dhabi_grand_prix_2025.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1c6b778ca45dfdd67dfb532cd258910c1b97ee0f894de5484ffbd0854c6df252
|
| 3 |
+
size 17116
|
data_output/prerace/prerace_australian_grand_pri_2014.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:375f0e17bc001fb25ca6d713b4d60dcb97e4ed836465a53f79fd000f70da8199
|
| 3 |
+
size 15799
|
data_output/prerace/prerace_australian_grand_pri_2015.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5b5892ff5cdf24d49855631396fdc65c5ab89ff8ec0bc8e028dda87ad4041145
|
| 3 |
+
size 16020
|
data_output/prerace/prerace_australian_grand_pri_2016.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:163b6ac086ec568316728d6bfa44616e66f8fdccc89022cda5d2790492f7d313
|
| 3 |
+
size 16179
|
data_output/prerace/prerace_australian_grand_pri_2017.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a3bff47910334b98b4785fa55cdcef4ea30936fa63f9c38e9e6ed14fcc05fe97
|
| 3 |
+
size 16120
|
data_output/prerace/prerace_australian_grand_pri_2018.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b763f2c4b3e5db0d1e732df1598d0452f0ef723352a503fd2538ad35d94e8482
|
| 3 |
+
size 17141
|
data_output/prerace/prerace_australian_grand_pri_2019.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:933f9ccd3555a89525d451e6988dd5f138fb5b5d908f4ed203a0bd04250e7a41
|
| 3 |
+
size 17176
|
data_output/prerace/prerace_australian_grand_pri_2022.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eb2e8fdeb0e7c937b02ba882d0e289f086010c5f40ada27b69df26b021160ab4
|
| 3 |
+
size 17157
|
data_output/prerace/prerace_australian_grand_pri_2023.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7d7e04018dfe44b4c19881f2699a5e5aa0534de11b3a4e1a5ff0647447c23841
|
| 3 |
+
size 17158
|
data_output/prerace/prerace_australian_grand_pri_2024.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:93a9ab6e87502fb12b4e0d74c8d8642f8d9364afb18c1cb1e1afd3153da552d2
|
| 3 |
+
size 17098
|
data_output/prerace/prerace_australian_grand_pri_2025.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fe70b5ae3c1e4e3a4ae1a7183d3f05b01dcc9202595a1f998be88883ec7fe98e
|
| 3 |
+
size 17084
|
data_output/prerace/prerace_australian_grand_pri_2026.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:89ab900ac7feea4a1eef01372034f778bb3093f3ac425e0c2897eff4cd07dfd4
|
| 3 |
+
size 17193
|
data_output/prerace/prerace_austrian_grand_prix_2014.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2b89fd196fde105cdc5c7434932b847c4e72ff30ef1e816a1b87410b8a339b42
|
| 3 |
+
size 16208
|
data_output/prerace/prerace_austrian_grand_prix_2015.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7c780f2f2d9dc96e289b12b829044bb1f4a0a1c9684aac51711ff9e38f8f60c8
|
| 3 |
+
size 16090
|
data_output/prerace/prerace_austrian_grand_prix_2016.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d17a96cc7d923b4072d9aaadc452087acc00f040fe61cb4428b55c8fd75e7b36
|
| 3 |
+
size 16198
|
data_output/prerace/prerace_austrian_grand_prix_2017.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e3671471e0cecbdb8d7d2a8d76f7367ad1fd766e44f91119ba1913f04d98d8dd
|
| 3 |
+
size 16113
|
data_output/prerace/prerace_austrian_grand_prix_2018.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3ec2fba62b991af17cbc38847515e3196dc62f9126c168bbae2a2c7a3eae1713
|
| 3 |
+
size 17193
|
data_output/prerace/prerace_austrian_grand_prix_2019.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5275dcf00c1580e9c965a018db62d37c2d302505317756c56d6de7d34ad113c2
|
| 3 |
+
size 17180
|
data_output/prerace/prerace_austrian_grand_prix_2020.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aed36837f04ba4f69a63f0fcdd1e7faa34e1810cc5202048f6dd3381cea1cfd1
|
| 3 |
+
size 17186
|
data_output/prerace/prerace_austrian_grand_prix_2021.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d890d04b763101f954125f24f77b4650224d5a618b4244ae3689c47834ed1ed0
|
| 3 |
+
size 17194
|
data_output/prerace/prerace_austrian_grand_prix_2022.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a276394021e03e59b10fcd03c0280df22b3b947d03ccebfd809f95e60f68968d
|
| 3 |
+
size 17180
|
data_output/prerace/prerace_austrian_grand_prix_2023.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0235cc3b5754c9ad95c8bbc0b14005b463670f416ca85fa52905319c2498e9fb
|
| 3 |
+
size 16910
|
data_output/prerace/prerace_austrian_grand_prix_2024.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:21ddd483cdd6160159986b984a80336aae7162cb4bed35812fe9dedc827ccbd1
|
| 3 |
+
size 16906
|
data_output/prerace/prerace_austrian_grand_prix_2025.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b880dbc20428d8c62bf4f2742e8834e7196e73b3f0c677c478c0e8f84d58eca6
|
| 3 |
+
size 17108
|
data_output/prerace/prerace_azerbaijan_grand_pri_2017.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3991df986866d3b237f2510da2537346ca7b75b86636d0886191aee461a5549b
|
| 3 |
+
size 16079
|
data_output/prerace/prerace_azerbaijan_grand_pri_2018.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aea98ac4c994c98e4818e105bffdd81a798451ffd2db8bfc53f6dad5a61e215f
|
| 3 |
+
size 17205
|
data_output/prerace/prerace_azerbaijan_grand_pri_2019.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fdb2c0d9ae8af4fe0afbe35e0fa00b4495d301cc8744cceb8c2b71298cfc2c83
|
| 3 |
+
size 17210
|
data_output/prerace/prerace_azerbaijan_grand_pri_2021.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:63e61f2415e99646c7d9360479ff0456dcbff83d0efcd530ff74261dd466fd7a
|
| 3 |
+
size 17246
|
data_output/prerace/prerace_azerbaijan_grand_pri_2022.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5529cc4110424f30674d2c4323a63c273e48b0140d128d1100493d135b4bb919
|
| 3 |
+
size 17180
|