Skip to content

Commit 4f2a196

Browse files
authored
Merge pull request #11 from Samarth2001/claude/phase5-6-01ABwdavjJNECrDVtSsXVUWZ
Phase 5 & 6: FastAPI REST API, Streamlit dashboard, GitHub Actions CI
2 parents 06b6008 + eb0dfb9 commit 4f2a196

6 files changed

Lines changed: 899 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ["main", "main-*", "claude/*"]
6+
pull_request:
7+
branches: ["main", "main-*"]
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
lint:
15+
name: Lint
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.11"
23+
cache: pip
24+
25+
- name: Install lint tools
26+
run: pip install black==24.4.2 isort==5.13.2
27+
28+
- name: black (check)
29+
run: black --check --diff src/ scripts/ tests/
30+
31+
- name: isort (check)
32+
run: isort --check-only --diff src/ scripts/ tests/
33+
34+
test:
35+
name: Tests (Python ${{ matrix.python-version }})
36+
runs-on: ubuntu-latest
37+
strategy:
38+
fail-fast: false
39+
matrix:
40+
python-version: ["3.10", "3.11", "3.12"]
41+
42+
steps:
43+
- uses: actions/checkout@v4
44+
45+
- uses: actions/setup-python@v5
46+
with:
47+
python-version: ${{ matrix.python-version }}
48+
cache: pip
49+
50+
- name: Install dependencies
51+
run: |
52+
python -m pip install --upgrade pip
53+
pip install -r requirements.txt
54+
55+
- name: Run unit tests
56+
run: |
57+
pytest tests/unit/ -v --tb=short \
58+
--cov=src/f1_predictor \
59+
--cov-report=term-missing \
60+
--cov-report=xml:coverage.xml \
61+
-q
62+
63+
- name: Run integration tests
64+
run: |
65+
pytest tests/integration/ -v --tb=short -q
66+
67+
- name: Run system tests
68+
run: |
69+
pytest tests/system/ -v --tb=short -q
70+
71+
- name: Upload coverage report
72+
if: matrix.python-version == '3.11'
73+
uses: actions/upload-artifact@v4
74+
with:
75+
name: coverage-report
76+
path: coverage.xml
77+
retention-days: 7
78+
79+
api-smoke:
80+
name: API smoke test
81+
runs-on: ubuntu-latest
82+
needs: test
83+
steps:
84+
- uses: actions/checkout@v4
85+
86+
- uses: actions/setup-python@v5
87+
with:
88+
python-version: "3.11"
89+
cache: pip
90+
91+
- name: Install dependencies
92+
run: |
93+
python -m pip install --upgrade pip
94+
pip install -r requirements.txt
95+
96+
- name: Start API server in background
97+
run: |
98+
python -m uvicorn src.f1_predictor.api:app --host 127.0.0.1 --port 8000 &
99+
sleep 3
100+
101+
- name: Health check
102+
run: |
103+
curl -f http://127.0.0.1:8000/health
104+
105+
- name: Docs endpoint
106+
run: |
107+
curl -f http://127.0.0.1:8000/openapi.json | python -c "import sys,json; d=json.load(sys.stdin); print(d['info']['title'])"

dashboard/app.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"""F1 Prediction Dashboard — Streamlit application.
2+
3+
Run from the repository root:
4+
streamlit run dashboard/app.py
5+
6+
The dashboard calls the local FastAPI server. Start it separately with:
7+
uvicorn src.f1_predictor.api:app --host 127.0.0.1 --port 8000
8+
9+
Or configure API_BASE_URL in the sidebar to point at a remote deployment.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
from datetime import datetime
16+
from typing import Optional
17+
18+
import pandas as pd
19+
import plotly.express as px
20+
import plotly.graph_objects as go
21+
import requests
22+
import streamlit as st
23+
24+
# ---------------------------------------------------------------------------
25+
# Page config
26+
# ---------------------------------------------------------------------------
27+
28+
st.set_page_config(
29+
page_title="F1 Prediction Dashboard",
30+
page_icon="🏎",
31+
layout="wide",
32+
initial_sidebar_state="expanded",
33+
)
34+
35+
# ---------------------------------------------------------------------------
36+
# Sidebar — configuration
37+
# ---------------------------------------------------------------------------
38+
39+
with st.sidebar:
40+
st.title("⚙️ Settings")
41+
api_base = st.text_input(
42+
"API base URL",
43+
value="http://127.0.0.1:8000",
44+
help="Base URL of the running FastAPI server.",
45+
)
46+
current_year = datetime.now().year
47+
year = st.number_input(
48+
"Season year", min_value=2018, max_value=2030, value=current_year, step=1
49+
)
50+
n_sims = st.slider(
51+
"Simulations (Monte Carlo)", min_value=200, max_value=5000, value=1000, step=200
52+
)
53+
sc_prob = st.slider("Safety-car probability", 0.0, 1.0, 0.30, 0.05)
54+
55+
# ---------------------------------------------------------------------------
56+
# Helper — API calls
57+
# ---------------------------------------------------------------------------
58+
59+
60+
def _get(path: str) -> Optional[dict]:
61+
try:
62+
r = requests.get(f"{api_base}{path}", timeout=30)
63+
r.raise_for_status()
64+
return r.json()
65+
except requests.ConnectionError:
66+
st.error(f"Cannot connect to API at **{api_base}**. Is the server running?")
67+
return None
68+
except Exception as exc:
69+
st.error(f"API error: {exc}")
70+
return None
71+
72+
73+
def _post(path: str, payload: dict) -> Optional[dict]:
74+
try:
75+
r = requests.post(f"{api_base}{path}", json=payload, timeout=120)
76+
r.raise_for_status()
77+
return r.json()
78+
except requests.ConnectionError:
79+
st.error(f"Cannot connect to API at **{api_base}**. Is the server running?")
80+
return None
81+
except Exception as exc:
82+
st.error(f"API error ({r.status_code}): {r.text[:300]}") # type: ignore[possibly-undefined]
83+
return None
84+
85+
86+
# ---------------------------------------------------------------------------
87+
# Helpers — charts
88+
# ---------------------------------------------------------------------------
89+
90+
91+
def _bar_predictions(df: pd.DataFrame, pos_col: str, title: str) -> go.Figure:
92+
"""Horizontal bar chart of predicted positions (lower = better)."""
93+
df = df.sort_values(pos_col)
94+
fig = px.bar(
95+
df,
96+
x=pos_col,
97+
y="Driver",
98+
orientation="h",
99+
color="Team",
100+
title=title,
101+
labels={pos_col: "Predicted position", "Driver": ""},
102+
height=max(400, len(df) * 28),
103+
)
104+
fig.update_layout(yaxis={"categoryorder": "total ascending"}, showlegend=True)
105+
return fig
106+
107+
108+
def _podium_bar(sim_df: pd.DataFrame) -> go.Figure:
109+
"""Grouped bar chart: win / podium / top-10 % per driver."""
110+
df = sim_df.sort_values("Win_Pct", ascending=False).head(20).copy()
111+
fig = go.Figure()
112+
for col, label, colour in [
113+
("Win_Pct", "Win %", "#FFD700"),
114+
("Podium_Pct", "Podium %", "#C0C0C0"),
115+
("Top10_Pct", "Top-10 %", "#CD7F32"),
116+
]:
117+
if col in df.columns:
118+
fig.add_trace(
119+
go.Bar(
120+
name=label,
121+
x=df["Driver"],
122+
y=(df[col] * 100).round(1),
123+
marker_color=colour,
124+
)
125+
)
126+
fig.update_layout(
127+
barmode="group",
128+
title="Win / Podium / Top-10 probability (%)",
129+
yaxis_title="Probability (%)",
130+
xaxis_title="",
131+
height=450,
132+
)
133+
return fig
134+
135+
136+
def _position_heatmap(pos_matrix_data: dict, drivers: list[str]) -> go.Figure:
137+
"""Heatmap of finishing-position distributions."""
138+
df = pd.DataFrame.from_dict(pos_matrix_data, orient="tight" if "index" in pos_matrix_data else "dict")
139+
if "data" in pos_matrix_data:
140+
df = pd.DataFrame(
141+
pos_matrix_data["data"],
142+
index=pos_matrix_data.get("index", drivers),
143+
columns=pos_matrix_data.get("columns", list(range(1, 21))),
144+
)
145+
# Sort drivers by median finishing position
146+
median_pos = (df * df.columns.astype(float)).sum(axis=1)
147+
df = df.loc[median_pos.sort_values().index]
148+
fig = px.imshow(
149+
df * 100,
150+
labels={"x": "Finishing position", "y": "Driver", "color": "Probability (%)"},
151+
title="Finishing-position distribution (% of simulations)",
152+
color_continuous_scale="Blues",
153+
aspect="auto",
154+
height=max(400, len(df) * 28),
155+
)
156+
return fig
157+
158+
159+
# ---------------------------------------------------------------------------
160+
# Main — health banner
161+
# ---------------------------------------------------------------------------
162+
163+
st.title("🏎 F1 Prediction Dashboard")
164+
165+
health = _get("/health")
166+
if health:
167+
st.success(f"API online — version {health.get('version', '?')} | {health.get('timestamp', '')}")
168+
else:
169+
st.warning("API offline. Start the server and refresh this page.")
170+
171+
# ---------------------------------------------------------------------------
172+
# Race selector
173+
# ---------------------------------------------------------------------------
174+
175+
sched_data = _get(f"/schedule/{year}")
176+
race_names: list[str] = []
177+
if sched_data and sched_data.get("schedule"):
178+
race_names = [r["EventName"] for r in sched_data["schedule"]]
179+
180+
race = st.selectbox(
181+
"Select race",
182+
options=race_names or ["(no schedule loaded)"],
183+
help="Races pulled from FastF1 via the API.",
184+
)
185+
186+
# ---------------------------------------------------------------------------
187+
# Tabs
188+
# ---------------------------------------------------------------------------
189+
190+
tab_race, tab_quali, tab_sim = st.tabs(["🏁 Race prediction", "⏱ Qualifying prediction", "🎲 Simulation"])
191+
192+
# ── Race prediction ──────────────────────────────────────────────────────────
193+
with tab_race:
194+
mode = st.selectbox(
195+
"Prediction mode",
196+
["auto", "pre_weekend", "pre_quali", "post_quali"],
197+
index=0,
198+
help="'auto' lets the model decide based on available data.",
199+
)
200+
if st.button("Predict race", key="btn_race", disabled=not race_names):
201+
with st.spinner("Running race prediction…"):
202+
data = _post("/predict/race", {"year": int(year), "race": race, "mode": mode})
203+
if data and data.get("predictions"):
204+
df = pd.DataFrame(data["predictions"])
205+
st.dataframe(df, use_container_width=True)
206+
pos_col = next(
207+
(c for c in ["Predicted_Race_Pos", "Predicted_Pos", "Position"] if c in df.columns),
208+
df.columns[0],
209+
)
210+
st.plotly_chart(_bar_predictions(df, pos_col, f"{year} {race} — Race prediction"), use_container_width=True)
211+
else:
212+
st.info("No predictions available. Ensure models are trained (`python scripts/predict.py train`).")
213+
214+
# ── Qualifying prediction ────────────────────────────────────────────────────
215+
with tab_quali:
216+
if st.button("Predict qualifying", key="btn_quali", disabled=not race_names):
217+
with st.spinner("Running qualifying prediction…"):
218+
data = _post("/predict/qualifying", {"year": int(year), "race": race})
219+
if data and data.get("predictions"):
220+
df = pd.DataFrame(data["predictions"])
221+
st.dataframe(df, use_container_width=True)
222+
pos_col = next(
223+
(c for c in ["Predicted_Quali_Pos", "Predicted_Pos", "Quali_Pos"] if c in df.columns),
224+
df.columns[0],
225+
)
226+
st.plotly_chart(
227+
_bar_predictions(df, pos_col, f"{year} {race} — Qualifying prediction"),
228+
use_container_width=True,
229+
)
230+
else:
231+
st.info("No qualifying predictions available. Ensure qualifying model is trained.")
232+
233+
# ── Simulation ───────────────────────────────────────────────────────────────
234+
with tab_sim:
235+
st.markdown(
236+
f"Run **{n_sims:,}** Monte Carlo simulations with a **{sc_prob:.0%}** safety-car probability."
237+
)
238+
if st.button("Run simulation", key="btn_sim", disabled=not race_names):
239+
with st.spinner(f"Simulating {n_sims} races…"):
240+
data = _post(
241+
"/simulate",
242+
{
243+
"year": int(year),
244+
"race": race,
245+
"n_simulations": n_sims,
246+
"sc_probability": sc_prob,
247+
},
248+
)
249+
if data and data.get("summary"):
250+
sim_df = pd.DataFrame(data["summary"])
251+
st.subheader("Summary")
252+
st.dataframe(sim_df, use_container_width=True)
253+
254+
col1, col2 = st.columns(2)
255+
with col1:
256+
st.plotly_chart(_podium_bar(sim_df), use_container_width=True)
257+
with col2:
258+
if data.get("position_matrix"):
259+
drivers = sim_df["Driver"].tolist() if "Driver" in sim_df.columns else []
260+
try:
261+
st.plotly_chart(
262+
_position_heatmap(data["position_matrix"], drivers),
263+
use_container_width=True,
264+
)
265+
except Exception:
266+
st.info("Position matrix chart unavailable.")
267+
else:
268+
st.info("Simulation returned no results. Ensure models are trained.")

requirements.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ matplotlib==3.8.4
3434
seaborn==0.13.2
3535
plotly==5.22.0
3636

37+
# REST API
38+
fastapi==0.115.0
39+
uvicorn[standard]==0.30.6
40+
41+
# Dashboard
42+
streamlit==1.36.0
43+
3744
# Configuration Management
3845
pyyaml==6.0.2
3946

0 commit comments

Comments
 (0)