I have extensive experience delivering engineering and security solutions in fast-paced highly-interactive environments. This is my open learning and data exploration archive. Contact: LinkedIn info@jimmieyoo.com
F1 RACE RESULT VISUALIZATION USING PYTHON, POLARS, AND ‘GREAT TABLES’: 2024 AUSTRALIAN GP
Programmatically create an F1 race analysis table using the FastF1 API, the Great Tables library, and Polars
Published
February 15, 2025
Screenshot of 2024 F1 Australian Grand Prix Data Table
_
Background
As an F1 fan, I always like to prepare for an upcoming race by looking up results and stats about the circuit from the previous year or earlier. I’ve always found this process to be a bit cumbersome as I would usually only find the final results but no other meaningful data, at least not all in one place. I decided to tackle this in a way that could be repeatable for future races. I had recently stumbled upon the Great Tables project which I thought would be a nice compact way to summarize the results & stats that I care about. I also know most of the raw data is available via the awesome FastF1 project. Finally this would be an opportunity for me to test out the pandas alternative, Polars, which many have been praising for its ease of use and speed. Let’s go.
FastF1 API (Python Library providing historical F1 race and telemetry data)
Polars (Pandas alternative Dataframe library built in Rust designed for speeeeed)
Great Tables (Python based library to make great looking display tables)
Importing modules and basic setup
import polars as plimport fastf1# set max rows to display to 50pl.Config.set_tbl_rows(50)# Find the specific racesesh = fastf1.get_session(2024, 'Melbourne', 'R')# Load the race data into local cachesesh.load()
req WARNING DEFAULT CACHE ENABLED! (275.4 MB) /Users/foo/Library/Caches/fastf1
core INFO Loading data for Australian Grand Prix - Race [v3.4.4]
req INFO Using cached data for session_info
req INFO Using cached data for driver_info
req INFO Using cached data for session_status_data
req INFO Using cached data for lap_count
req INFO Using cached data for track_status_data
req INFO Using cached data for _extended_timing_data
req INFO Using cached data for timing_app_data
core INFO Processing timing data...
req INFO Using cached data for car_data
req INFO Using cached data for position_data
req INFO Using cached data for weather_data
req INFO Using cached data for race_control_messages
core INFO Finished loading data for 19 drivers: ['55', '16', '4', '81', '11', '18', '22', '14', '27', '20', '23', '3', '10', '77', '24', '31', '63', '44', '1']
For reference, a slice of the session results table
Get each driver’s median and top speed from car_data telemetry
# Fetch the data using a list comprehensionspeed = [ [i[0], int(i[1]['Speed'].median()), int(i[1]['Speed'].max())] for i in sesh.car_data.items() ]# Just FYI how to get the flat list of all speeds from telemetry# pl.DataFrame([list(i[1]['Speed']) for i in sesh.car_data.items()]).unpivot()# Load it into a Polars Dataframe reorienting it so each driver get his own rowspeed_df = pl.DataFrame(speed, schema=['DriverNumber','Median Speed','Top Speed'], orient='row')speed_df
laps = pl.DataFrame( [{'Team':i[1]['Team'],'Driver':i[1]['Driver'],'LapNumber':int(i[1]['LapNumber']),'LapTime':i[1]['LapTime'].seconds*1.0+ i[1]['LapTime'].microseconds/1000000 } for i in sesh.laps.iterlaps() ], strict=False)# Drop rows with any NaNslaps = laps.fill_nan(None).drop_nulls()laps
shape: (995, 4)
Team
Driver
LapNumber
LapTime
str
str
i64
f64
"Red Bull Racing"
"VER"
1
87.458
"Red Bull Racing"
"VER"
2
84.099
"Red Bull Racing"
"VER"
3
83.115
"Alpine"
"GAS"
1
97.304
"Alpine"
"GAS"
2
84.649
"Alpine"
"GAS"
3
83.851
"Alpine"
"GAS"
4
83.7
"Alpine"
"GAS"
5
84.526
"Alpine"
"GAS"
6
83.912
"Alpine"
"GAS"
7
83.445
"Alpine"
"GAS"
8
83.879
"Alpine"
"GAS"
9
83.829
"Alpine"
"GAS"
10
83.856
"Alpine"
"GAS"
11
85.397
"Alpine"
"GAS"
12
84.625
"Alpine"
"GAS"
13
84.049
"Alpine"
"GAS"
14
84.104
"Alpine"
"GAS"
15
84.1
"Alpine"
"GAS"
16
85.367
"Alpine"
"GAS"
17
117.788
"Alpine"
"GAS"
18
89.888
"Alpine"
"GAS"
19
83.922
"Alpine"
"GAS"
20
83.662
"Alpine"
"GAS"
21
83.241
"Alpine"
"GAS"
22
83.045
…
…
…
…
"McLaren"
"PIA"
34
82.238
"McLaren"
"PIA"
35
82.059
"McLaren"
"PIA"
36
82.047
"McLaren"
"PIA"
37
81.89
"McLaren"
"PIA"
38
85.203
"McLaren"
"PIA"
39
96.781
"McLaren"
"PIA"
40
86.93
"McLaren"
"PIA"
41
81.424
"McLaren"
"PIA"
42
81.08
"McLaren"
"PIA"
43
80.485
"McLaren"
"PIA"
44
80.395
"McLaren"
"PIA"
45
80.223
"McLaren"
"PIA"
46
80.615
"McLaren"
"PIA"
47
80.682
"McLaren"
"PIA"
48
80.588
"McLaren"
"PIA"
49
80.352
"McLaren"
"PIA"
50
80.496
"McLaren"
"PIA"
51
80.718
"McLaren"
"PIA"
52
80.295
"McLaren"
"PIA"
53
80.308
"McLaren"
"PIA"
54
80.199
"McLaren"
"PIA"
55
80.754
"McLaren"
"PIA"
56
80.357
"McLaren"
"PIA"
57
85.255
"McLaren"
"PIA"
58
124.363
Calculate aggregate lap stats per driver into a separate dataframe using group_by
results = pl.DataFrame(sesh.results).select(['DriverNumber','Abbreviation','Status','Position']).rename({'Abbreviation':'Driver'})# Convert position to Intresults = results.cast({'Position': pl.Int8})results
shape: (19, 4)
DriverNumber
Driver
Status
Position
str
str
str
i8
"55"
"SAI"
"Finished"
1
"16"
"LEC"
"Finished"
2
"4"
"NOR"
"Finished"
3
"81"
"PIA"
"Finished"
4
"11"
"PER"
"Finished"
5
"18"
"STR"
"Finished"
6
"22"
"TSU"
"Finished"
7
"14"
"ALO"
"Finished"
8
"27"
"HUL"
"Finished"
9
"20"
"MAG"
"+1 Lap"
10
"23"
"ALB"
"+1 Lap"
11
"3"
"RIC"
"+1 Lap"
12
"10"
"GAS"
"+1 Lap"
13
"77"
"BOT"
"+1 Lap"
14
"24"
"ZHO"
"+1 Lap"
15
"31"
"OCO"
"+1 Lap"
16
"63"
"RUS"
"Accident"
17
"44"
"HAM"
"Engine"
18
"1"
"VER"
"Brakes"
19
Join the above Speed, Lap, and results into a single table
final = speed_df.join(results, on='DriverNumber')final = stats.join(final, on='Driver')final
shape: (19, 10)
Team
Driver
Median
Std Dev
Laps
DriverNumber
Median Speed
Top Speed
Status
Position
str
str
f64
f64
i64
str
i64
i64
str
i8
"Ferrari"
"SAI"
81.578
5.638844
58
"55"
149
325
"Finished"
1
"Ferrari"
"LEC"
81.9085
5.240059
58
"16"
150
332
"Finished"
2
"McLaren"
"NOR"
81.803
5.37894
58
"4"
148
324
"Finished"
3
"McLaren"
"PIA"
82.072
6.62356
58
"81"
148
330
"Finished"
4
"Red Bull Racing"
"PER"
82.139
6.824686
58
"11"
151
339
"Finished"
5
"Aston Martin"
"STR"
83.061
6.743054
58
"18"
150
332
"Finished"
6
"RB"
"TSU"
83.04
6.360524
58
"22"
151
334
"Finished"
7
"Aston Martin"
"ALO"
82.7415
7.554687
58
"14"
147
331
"Finished"
8
"Haas F1 Team"
"HUL"
83.0565
6.98707
58
"27"
149
336
"Finished"
9
"Haas F1 Team"
"MAG"
83.315
5.773301
57
"20"
148
338
"+1 Lap"
10
"Williams"
"ALB"
83.066
5.535442
57
"23"
144
332
"+1 Lap"
11
"RB"
"RIC"
83.105
6.085147
57
"3"
147
334
"+1 Lap"
12
"Alpine"
"GAS"
83.245
7.519535
57
"10"
147
337
"+1 Lap"
13
"Kick Sauber"
"BOT"
82.976
8.316051
57
"77"
149
327
"+1 Lap"
14
"Kick Sauber"
"ZHO"
83.196
7.547661
57
"24"
144
332
"+1 Lap"
15
"Alpine"
"OCO"
82.989
7.817079
57
"31"
146
335
"+1 Lap"
16
"Mercedes"
"RUS"
82.7065
4.021696
56
"63"
147
331
"Accident"
17
"Mercedes"
"HAM"
83.436
4.340289
15
"44"
0
331
"Engine"
18
"Red Bull Racing"
"VER"
84.099
2.277161
3
"1"
0
320
"Brakes"
19
Populate graphics metadata to reference later
import globdef get_insig(team):if team.lower() =='rb':return'racing-bulls-logo'else:return team.lower().replace(' ','-').replace('-f1-team','')mugs = [ glob.glob(F'img/f1-drivers/F1-{name[0] + name[1:].lower()}*')[0] for name in final['Driver'] ]insigs = [ glob.glob(F'img/f1-teams/{get_insig(team)}*')[0] for team in final['Team'] ]final.insert_column(0, pl.Series('insigs', insigs))final.insert_column(2, pl.Series('mugs', mugs))
shape: (19, 12)
insigs
Team
mugs
Driver
Median
Std Dev
Laps
DriverNumber
Median Speed
Top Speed
Status
Position
str
str
str
str
f64
f64
i64
str
i64
i64
str
i8
"img/f1-teams/ferrari-logo.png"
"Ferrari"
"img/f1-drivers/F1-Sainz.png"
"SAI"
81.578
5.638844
58
"55"
149
325
"Finished"
1
"img/f1-teams/ferrari-logo.png"
"Ferrari"
"img/f1-drivers/F1-Leclerc.png"
"LEC"
81.9085
5.240059
58
"16"
150
332
"Finished"
2
"img/f1-teams/mclaren-logo.png"
"McLaren"
"img/f1-drivers/F1-Norris.png"
"NOR"
81.803
5.37894
58
"4"
148
324
"Finished"
3
"img/f1-teams/mclaren-logo.png"
"McLaren"
"img/f1-drivers/F1-Piastri.png"
"PIA"
82.072
6.62356
58
"81"
148
330
"Finished"
4
"img/f1-teams/red-bull-racing-l…
"Red Bull Racing"
"img/f1-drivers/F1-Perez.png"
"PER"
82.139
6.824686
58
"11"
151
339
"Finished"
5
"img/f1-teams/aston-martin-logo…
"Aston Martin"
"img/f1-drivers/F1-Stroll.png"
"STR"
83.061
6.743054
58
"18"
150
332
"Finished"
6
"img/f1-teams/racing-bulls-logo…
"RB"
"img/f1-drivers/F1-Tsunoda.png"
"TSU"
83.04
6.360524
58
"22"
151
334
"Finished"
7
"img/f1-teams/aston-martin-logo…
"Aston Martin"
"img/f1-drivers/F1-Alonso.png"
"ALO"
82.7415
7.554687
58
"14"
147
331
"Finished"
8
"img/f1-teams/haas-logo.png"
"Haas F1 Team"
"img/f1-drivers/F1-Hulkenberg.p…
"HUL"
83.0565
6.98707
58
"27"
149
336
"Finished"
9
"img/f1-teams/haas-logo.png"
"Haas F1 Team"
"img/f1-drivers/F1-Magnussen.pn…
"MAG"
83.315
5.773301
57
"20"
148
338
"+1 Lap"
10
"img/f1-teams/williams-logo.png"
"Williams"
"img/f1-drivers/F1-Albon.png"
"ALB"
83.066
5.535442
57
"23"
144
332
"+1 Lap"
11
"img/f1-teams/racing-bulls-logo…
"RB"
"img/f1-drivers/F1-Ricciardo.pn…
"RIC"
83.105
6.085147
57
"3"
147
334
"+1 Lap"
12
"img/f1-teams/alpine-logo.png"
"Alpine"
"img/f1-drivers/F1-Gasly.png"
"GAS"
83.245
7.519535
57
"10"
147
337
"+1 Lap"
13
"img/f1-teams/kick-sauber-logo.…
"Kick Sauber"
"img/f1-drivers/F1-Bottas.png"
"BOT"
82.976
8.316051
57
"77"
149
327
"+1 Lap"
14
"img/f1-teams/kick-sauber-logo.…
"Kick Sauber"
"img/f1-drivers/F1-Zhou.png"
"ZHO"
83.196
7.547661
57
"24"
144
332
"+1 Lap"
15
"img/f1-teams/alpine-logo.png"
"Alpine"
"img/f1-drivers/F1-Ocon.png"
"OCO"
82.989
7.817079
57
"31"
146
335
"+1 Lap"
16
"img/f1-teams/mercedes-logo.png"
"Mercedes"
"img/f1-drivers/F1-Russell.png"
"RUS"
82.7065
4.021696
56
"63"
147
331
"Accident"
17
"img/f1-teams/mercedes-logo.png"
"Mercedes"
"img/f1-drivers/F1-Hamilton.png"
"HAM"
83.436
4.340289
15
"44"
0
331
"Engine"
18
"img/f1-teams/red-bull-racing-l…
"Red Bull Racing"
"img/f1-drivers/F1-Verstappen.p…
"VER"
84.099
2.277161
3
"1"
0
320
"Brakes"
19
Generate Lap summary row
# Calculate univeral median and standard deviation for all lap timesrace_median = laps['LapTime'].median()race_stddev = laps['LapTime'].std()# Add above summary data to the main dataframe# Need to look into a more efficient way to do this using *align* or similarsummary = pl.DataFrame({'insigs':[None,],'Team': ['SUMMARY',],'mugs':[None,],'Driver': [None,],'Median': [race_median,],'Std Dev': [race_stddev,],'Laps': [None,],'DriverNumber': [None,],'Median Speed': [None,],'Top Speed': [None,],'Status': ['Finished',],'Position': [None,]})final = pl.concat([final, summary])final
shape: (20, 12)
insigs
Team
mugs
Driver
Median
Std Dev
Laps
DriverNumber
Median Speed
Top Speed
Status
Position
str
str
str
str
f64
f64
i64
str
i64
i64
str
i8
"img/f1-teams/ferrari-logo.png"
"Ferrari"
"img/f1-drivers/F1-Sainz.png"
"SAI"
81.578
5.638844
58
"55"
149
325
"Finished"
1
"img/f1-teams/ferrari-logo.png"
"Ferrari"
"img/f1-drivers/F1-Leclerc.png"
"LEC"
81.9085
5.240059
58
"16"
150
332
"Finished"
2
"img/f1-teams/mclaren-logo.png"
"McLaren"
"img/f1-drivers/F1-Norris.png"
"NOR"
81.803
5.37894
58
"4"
148
324
"Finished"
3
"img/f1-teams/mclaren-logo.png"
"McLaren"
"img/f1-drivers/F1-Piastri.png"
"PIA"
82.072
6.62356
58
"81"
148
330
"Finished"
4
"img/f1-teams/red-bull-racing-l…
"Red Bull Racing"
"img/f1-drivers/F1-Perez.png"
"PER"
82.139
6.824686
58
"11"
151
339
"Finished"
5
"img/f1-teams/aston-martin-logo…
"Aston Martin"
"img/f1-drivers/F1-Stroll.png"
"STR"
83.061
6.743054
58
"18"
150
332
"Finished"
6
"img/f1-teams/racing-bulls-logo…
"RB"
"img/f1-drivers/F1-Tsunoda.png"
"TSU"
83.04
6.360524
58
"22"
151
334
"Finished"
7
"img/f1-teams/aston-martin-logo…
"Aston Martin"
"img/f1-drivers/F1-Alonso.png"
"ALO"
82.7415
7.554687
58
"14"
147
331
"Finished"
8
"img/f1-teams/haas-logo.png"
"Haas F1 Team"
"img/f1-drivers/F1-Hulkenberg.p…
"HUL"
83.0565
6.98707
58
"27"
149
336
"Finished"
9
"img/f1-teams/haas-logo.png"
"Haas F1 Team"
"img/f1-drivers/F1-Magnussen.pn…
"MAG"
83.315
5.773301
57
"20"
148
338
"+1 Lap"
10
"img/f1-teams/williams-logo.png"
"Williams"
"img/f1-drivers/F1-Albon.png"
"ALB"
83.066
5.535442
57
"23"
144
332
"+1 Lap"
11
"img/f1-teams/racing-bulls-logo…
"RB"
"img/f1-drivers/F1-Ricciardo.pn…
"RIC"
83.105
6.085147
57
"3"
147
334
"+1 Lap"
12
"img/f1-teams/alpine-logo.png"
"Alpine"
"img/f1-drivers/F1-Gasly.png"
"GAS"
83.245
7.519535
57
"10"
147
337
"+1 Lap"
13
"img/f1-teams/kick-sauber-logo.…
"Kick Sauber"
"img/f1-drivers/F1-Bottas.png"
"BOT"
82.976
8.316051
57
"77"
149
327
"+1 Lap"
14
"img/f1-teams/kick-sauber-logo.…
"Kick Sauber"
"img/f1-drivers/F1-Zhou.png"
"ZHO"
83.196
7.547661
57
"24"
144
332
"+1 Lap"
15
"img/f1-teams/alpine-logo.png"
"Alpine"
"img/f1-drivers/F1-Ocon.png"
"OCO"
82.989
7.817079
57
"31"
146
335
"+1 Lap"
16
"img/f1-teams/mercedes-logo.png"
"Mercedes"
"img/f1-drivers/F1-Russell.png"
"RUS"
82.7065
4.021696
56
"63"
147
331
"Accident"
17
"img/f1-teams/mercedes-logo.png"
"Mercedes"
"img/f1-drivers/F1-Hamilton.png"
"HAM"
83.436
4.340289
15
"44"
0
331
"Engine"
18
"img/f1-teams/red-bull-racing-l…
"Red Bull Racing"
"img/f1-drivers/F1-Verstappen.p…
"VER"
84.099
2.277161
3
"1"
0
320
"Brakes"
19
null
"SUMMARY"
null
null
82.852
6.514701
null
null
null
null
"Finished"
null
Generate the final display table
There’s a lot to unpack here so I commented each layer of the display table object inline as I saw fit, which was facilitated by Polars and GT’s chaining style syntax. Honestly though most of this is very self-explanatory.
from great_tables import GT, md, html, loc, style# Polars expression function to determine if a driver did not DNFstatus_color = ( pl.when(pl.col("Status").str.contains('Finished|1 Lap')) .then(pl.lit("black")) .otherwise(pl.lit("grey")))# Create the Great Tables objectmelbourne_2024 = (# Pull out the Position column to use as a Row names (1,2,3 ..) GT(final, rowname_col ="Position") .cols_move_to_end( columns="Laps" ) .tab_header( title="F1 2024 Australian Grand Prix", subtitle="Results, Lap Time, and Speed Statistics", ) .tab_options( table_background_color="white" )# I'm not sure that this actually works .opt_row_striping(row_striping=False)# Create a table border .opt_table_outline()# Set decimal place limits for certain columns .fmt_number( columns=["Median", "Std Dev"], decimals=3 )# Handle image alignments .fmt_image("insigs").cols_align(align="left") .fmt_image("mugs").cols_align(align="left")# Suppress image column labels .cols_label( insigs="", mugs="" ).# Redo some column labels cols_label({'Median Speed':'Median','Top Speed':'Top','Laps':html('Laps<br>done') })# Create groupings of related columns .tab_spanner( label="Lap Times", columns=["Median","Std Dev"] ) .tab_spanner( label="Speed", columns=["Median Speed","Top Speed"] )# Suppress columns that were only used for internal reference .cols_hide( columns=["Status","DriverNumber"] )# Fix Summary Row Name .sub_missing( columns=["Position"], missing_text="-" )# Replace any missing data with empty string .sub_missing(missing_text="")# Display driver's row text in grey if they did not finish the race (from function above) .tab_style( style=style.text(color=status_color), locations=loc.body() )# Highlight lowest median lap time .tab_style( style.text(weight ="bolder", color="purple", size="large"), loc.body( columns = ["Median"], rows = pl.col("Median") == pl.col("Median").filter(pl.col("Status").str.contains('Finished|1 Lap')).min() ) )# Highlight top speed .tab_style( style.text(weight ="bolder", color="purple", size="large"), loc.body( columns = ["Top Speed"], rows = pl.col("Top Speed") == pl.col("Top Speed").filter(pl.col("Status").str.contains('Finished|1 Lap')).max() ) )# Highlight lowest standard deviation lap time (most consistent) .tab_style( style.text(weight ="bolder", color="purple", size="large"), loc.body( columns = ["Std Dev"], rows = pl.col("Std Dev") == pl.col("Std Dev").filter(pl.col("Status").str.contains('Finished|1 Lap')).min() ) ) .tab_source_note(source_note="Source data from FastF1"))melbourne_2024.show(target='notebook')