This notebook provides a case study in retrieving, cleaning, organising and processing data obtained from a third party website, specifically, timing and results data from the 2019 Dakar Rally.
Another way of thinking of it is as a series of marks created during an exploratory data analysis performance.
Or palaver.
Whatever.
Shall we begin?
#Use requests cache so we can keep an archive of the results HTML
import requests
import requests_cache
requests_cache.install_cache('dakar_cache', backend='sqlite')
import pandas as pd
Timing data is provided in two forms:
Ranking data for the stage and overall at end of stage is also available.
Timing and ranking data is available for:
STAGE = 8
Retrieve some basic information about a stage.
def get_stage_stats(stage):
stage_stats_url='https://gaps.dakar.com/2019/dakar/index_info.php?l=ukie&s={stage}&vh=a'
html = requests.get(stage_stats_url.format(stage=STAGE)).content
stage_stats_df=pd.read_html(html)[0]
return stage_stats_df.rename(columns=stage_stats_df.iloc[0]).drop(stage_stats_df.index[0])
get_stage_stats(STAGE)
Special | Moto | Quad | Car | SxS | Truck | Semi | |
---|---|---|---|---|---|---|---|
1 | Start | 07:15 | 09:07 | 07:24 | 10:11 | 08:15 | 10:38 |
2 | Liaison | 574km | 574km | 574km | 574km | 574km | 574km |
3 | Special | 361km | 361km | 361km | 361km | 361km | 361km |
4 | Number of participants | NaN | NaN | NaN | NaN | NaN | NaN |
5 | At start | 93 | 15 | 65 | 22 | 28 | 20 |
6 | Left | 92 | 15 | 65 | 21 | 15 | 18 |
7 | Arrived | 81 | 15 | 48 | 21 | 11 | 10 |
8 | Latest WP | ass | ass | ass | ass | ass | ass |
9 | Leader at latest WP | 001 WALKNER | 240 CAVIGLIASSO | 306 LOEB | 360 LOPEZ CONTARDO | 514 SOTNIKOV | 310 TERRANOVA |
10 | Nb at latest WP | 81 | 15 | 48 | 21 | 11 | 10 |
Typical of many rallies, the live timing pages return several sorts of data:
URL_PATTERN='https://gaps.dakar.com/2019/dakar/?s={stage}&c=aso&l=ukie&vi={tab}&sv={timerank}&vh={vtype}&sws=99'
#sws - selected waypoint?
#Vehicle types
VTYPE_ ={ 'car':'a','moto':'m','quad':'q','sxs':'s','truck':'c'}
#Screen / tab selection
TAB_ = {'timing':0,'news':1,'ranking':2}
#Options for timing data
TIMING_ = {'gap':0,'time':1}
#Options for ranking data
RANKING_ = {'stage':0, 'general':1}
Let's see what the data looks like...
Uncomment and run the following to preview / inspect the data that's avaliable.
#pd.read_html(URL_PATTERN.format(stage=STAGE,tab=TAB_['timing'],vtype=VTYPE_['car'],timerank='time'))
#pd.read_html(URL_PATTERN.format(stage=STAGE,tab=TAB_['ranking'],vtype=VTYPE_['car'],timerank='stage'))
By inspection, we note that:
['Pos','Bib','Crew','Brand']
);['View details','Select']
);TIMING = RANKING = 0
CREWTEAM = 1
BRANDS = 2
COUNTRIES = 3
We can define helper functions to pull back tables associated with the timing or ranking pages.
#Retrieve a page
def _data(stage,vtype='car',tab='timing', timerank='time'):
''' Retrieve timing or ranking HTML page and scrape HTML tables. '''
timerank = RANKING_[timerank] if tab=='ranking' else TIMING_[timerank]
url = URL_PATTERN.format(stage=stage,tab=TAB_[tab],vtype=VTYPE_[vtype],timerank=timerank)
html = requests.get(url).content
return pd.read_html(html, na_values=['-'])
def _fetch_timing_data(stage,vtype='car', timerank='time'):
''' Return data tables from timing page. '''
_tmp = _data(stage,vtype=vtype, tab='timing', timerank=timerank)
_tmp[TIMING].drop(columns=['View details','Select'], inplace=True)
return _tmp
def _fetch_ranking_data(stage,vtype='car', timerank='stage'):
''' Return data tables from ranking page. '''
rank_cols = ['Pos','Bib','Crew','Brand','Time','Gap','Penalty']
_tmp = _data(stage,vtype=vtype, tab='ranking', timerank=timerank)
_tmp[RANKING].drop(columns=['View details','Select'], inplace=True)
return _tmp
rdata = _fetch_ranking_data(STAGE, timerank='general')
rdata[RANKING].head()
Pos | Bib | Crew | Brand | Time | Gap | Penalty | |
---|---|---|---|---|---|---|---|
0 | 1 | 301 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 29:15:50 | 0:00:00 | 00:00:00 |
1 | 2 | 307 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 30:02:19 | 0:46:29 | 00:00:00 |
2 | 3 | 306 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 30:02:35 | 0:46:45 | 00:00:00 |
3 | 4 | 304 | S. PETERHANSEL D. CASTERA X-RAID MINI JCW TEAM | MINI | 30:09:20 | 0:53:30 | 00:00:00 |
4 | 5 | 308 | C. DESPRES JP. COTTRET X-RAID MINI JCW TEAM | MINI | 31:31:17 | 2:15:27 | 00:00:00 |
rdata[RANKING].dtypes
Pos int64 Bib int64 Crew object Brand object Time object Gap object Penalty object dtype: object
The basic retrieval returns a table with timing data as strings, and the Bib
identifier as an integer.
The Bib
identifer, We could also regard it as a string so that we aren't tempted to treat it as a number, inwhich case we should also ensure that any extraneous whitespace is stripped if the Bib
was already a string:
rdata[RANKING]['Bib'] = rdata[RANKING]['Bib'].astype(str).str.strip()
rdata[RANKING]['Bib'] = rdata[RANKING]['Bib'].astype(int)
Several of the datasets return times, as strings, in the form: HH:MM:SS
.
We can convert these times to timedeltas.
Timing related columns are all columns except those in ['Pos','Bib','Crew','Brand']
.
We can also prefix timing columns in the timing data screens so we can recreate the order they should appear in:
#Prefix each split designator with a split count
timingcols=['dss','wp1','wp2','ass']
{ x:'{}_{}'.format('{0:02d}'.format(i), x) for i, x in enumerate(timingcols, 0) }
{'dss': '00_dss', 'wp1': '01_wp1', 'wp2': '02_wp2', 'ass': '03_ass'}
One of the things we need to handle are timing columns where the timing data may be mixed with other sorts of data in the raw data table.
Routines for cleaner the data are included in the timing handler function but they were actually "backfilled" into the function after creating them (originally) later on in the notebook.
from pandas.api.types import is_string_dtype
#At first sight, this looks quite complicated, but a lot of it is backfilled
# to take into account some of the cleaning we need to do for the full (messy) timing data
def _get_timing(df, typ=TIMING, kind='simple'):
''' Convert times to time deltas and
prefix waypoint / timing columns with a two digit counter. '''
#Some of the exclusion column names are backfilled into this function
# from columns introduced later in the notebook
# What we're trying to do is identify columns that aren't timing related
timingcols = [c for c in df[typ].columns if c not in ['Pos','Bib','Crew','Brand', 'Refuel', 'Road Position'] ]
#Clean up the data in a timing column, then cast to timedelta
for col in timingcols:
#In the simple approach, we just grab the timing data and dump the mess
if kind=='simple':
df[typ][col] = df[typ][col].str.extract(r'(\d{2}:\d{2}:\d{2})')
else:
#The full on extractor - try to parse out all the data
# that has been munged into a timing column
if col==timingcols[-1]:
#There's an end effect:
# the last column in the timing dataset doesn't have position embedded in it
# In this case, just pull out the position gained/maintained/lost flag
df[typ][[col,col+'_gain']] = df[typ][col].str.extract(r'(\d{2}:\d{2}:\d{2})(.*)', expand=True)
else:
#In the main body of the table, position gain as well as waypoint rank position are available
df[typ][[col,col+'_gain',col+'_pos']] = df[typ][col].str.extract(r'(\d{2}:\d{2}:\d{2})(.*)\((\d*)\)', expand=True)
#Ideally, the pos cols would be of int type, but int doesn't support NA
df[typ][col+'_pos'] = df[typ][col+'_pos'].astype(float)
#Cast the time string to a timedelta
df[typ][col] = pd.to_timedelta( df[typ][col] )
#In timing screen, rename cols with a leading two digit index
#This allows us to report splits in order
#We only want to do this for the timing data columns, not the rank timing columns...
timingcols = [c for c in timingcols if c not in ['Time','Gap','Penalty']]
timingcols_map = { x:'{}_{}'.format('{0:02d}'.format(i), x) for i, x in enumerate(timingcols, 0) }
df[typ].rename(columns=timingcols_map, inplace=True)
return df
Normalise the times as timedeltas.
For timestrings of the form HH:MM:SS
, this is as simple as passing the timestring column to the pandas .to_timedelta()
function:
pd.to_timedelta( df[TIMESTRING_COLUMN] )
We just need to ensure we pass it the correct columns...
def get_ranking_data(stage,vtype='car', timerank='stage'):
''' Retrieve rank timing data and return it in a form we can work directly with. '''
df = _fetch_ranking_data(stage,vtype=vtype, timerank=timerank)
df[RANKING]['Bib'] = df[RANKING]['Bib'].astype(int)
return _get_timing(df, typ=RANKING)
ranking_data = get_ranking_data(STAGE)
ranking_data[RANKING].head()
Pos | Bib | Crew | Brand | Time | Gap | Penalty | |
---|---|---|---|---|---|---|---|
0 | 1 | 306 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 03:54:53 | NaT | 0 days |
1 | 2 | 301 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 04:02:20 | 00:07:27 | 0 days |
2 | 3 | 303 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 04:10:08 | 00:15:15 | 0 days |
3 | 4 | 302 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 04:10:48 | 00:15:55 | 0 days |
4 | 5 | 307 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 04:10:50 | 00:15:57 | 0 days |
The Crew
data is a bit of a mishmash. If we were to normalise this table, we'd have to split that data out...
For now, let's leave it...
...because sometimes, it can be handy to be able to pull out a chunk of unnormalised data as a simple string.
ranking_data[RANKING].dtypes
Pos int64 Bib int64 Crew object Brand object Time timedelta64[ns] Gap timedelta64[ns] Penalty timedelta64[ns] dtype: object
The timing data needs some processing:
data = _fetch_timing_data(STAGE)
data[TIMING][60:70]
Pos | Bib | Crew | Brand | dss | wp1 | wp2 | ass1 | wp4 | wp5 | wp6 | ass | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
60 | NaN | 422 | BA. MARTINS RJP. FERREIRA TEAM BBR | CAN - AM | 11:30:15= (52) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
61 | NaN | 405 | MA. ALVAREZ PINEDA R. MENDIOLA ALVAREZ | TOYOTA | 11:38:15= (55) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
62 | NaN | 404 | A. AGUIRREGAVIRIA J. COMALLONGA FN SPEED TEAM | TOYOTA | 11:38:45= (56) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
63 | NaN | 392 | D. WEBER JJ. PONCE AYLWIN DURO4X4.COM | TOYOTA | 11:39:15= (57) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
64 | NaN | 420 | N. FALLOUX F. GONZALEZ OVERDRIVE TOYOTA | TOYOTA | 11:56:45= (62) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
A full inspection of the time data shows that some additional metadata corresponding to whether in-stage refuelling is allowed may also be recorded in the Bib
column (for example, 403 ⛽
).
We can extract this information into a separate dataframe / table.
def get_refuel_status(df):
''' Parse the refuel status out of timing data Bib column.
Return extended dataframe with a clean Bib column and a new Refuel column. '''
#The .str.extract() function allows us to match separate groups using a regex
# and return the corresponding group data as distinct columns
#Force the Bin type to a str if it isn't created as such so we can regex it...
df[['Bib','_tmp']] = df['Bib'].astype(str).str.extract(r'(\d*)([^\d]*)', expand=True)
#Set the Refuel status as a Boolean
df.insert(2, 'Refuel', df['_tmp'])
df.drop('_tmp', axis=1, inplace=True)
df['Refuel'] = df['Refuel']!=''
#Set the Bib value as an int
df['Bib'] = df['Bib'].astype(int)
return df
data[TIMING] = get_refuel_status(data[TIMING])
data[TIMING][60:70]
Pos | Bib | Refuel | Crew | Brand | dss | wp1 | wp2 | ass1 | wp4 | wp5 | wp6 | ass | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
60 | NaN | 422 | False | BA. MARTINS RJP. FERREIRA TEAM BBR | CAN - AM | 11:30:15= (52) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
61 | NaN | 405 | False | MA. ALVAREZ PINEDA R. MENDIOLA ALVAREZ | TOYOTA | 11:38:15= (55) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
62 | NaN | 404 | False | A. AGUIRREGAVIRIA J. COMALLONGA FN SPEED TEAM | TOYOTA | 11:38:45= (56) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
63 | NaN | 392 | False | D. WEBER JJ. PONCE AYLWIN DURO4X4.COM | TOYOTA | 11:39:15= (57) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
64 | NaN | 420 | False | N. FALLOUX F. GONZALEZ OVERDRIVE TOYOTA | TOYOTA | 11:56:45= (62) | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
We also notice that the raw timing data includes information about split rank and how it compares to the rank at the previous split / waypoint, with the raw data taking the form 08:44:00= (11)
. Which is to say, HH:MM:DDx (NN?)
where x
is a comparator showing whether the rank at that waypoint improved (▲), remained the same as (=), or worsened (▼) compared to the previous waypoint.
Note that the final ass
column does not include the rank.
We can use a regular expression to separate the data out, with each regex group being expanded into a separate column:
data[TIMING]['dss'].str.extract(r'(\d{2}:\d{2}:\d{2})(.*)\((\d*)\)', expand=True).head()
0 | 1 | 2 | |
---|---|---|---|
0 | 10:09:30 | = | 11 |
1 | 07:54:00 | = | 4 |
2 | 08:03:00 | = | 7 |
3 | 08:00:00 | = | 6 |
4 | 07:36:00 | = | 2 |
We can backfill an expression of that form into the timing data handler function above...
Now we wrap several steps together into a function that gets us a clean set of timing data, with columns of an appropriate type:
def get_timing_data(stage,vtype='car', timerank='time', kind='simple'):
''' Get timing data in a form ready to use. '''
df = _fetch_timing_data(stage,vtype=vtype, timerank=timerank)
df[TIMING] = get_refuel_status(df[TIMING])
return _get_timing(df, typ=TIMING, kind=kind)
data = get_timing_data(STAGE, kind='full')
data[TIMING].head()
Pos | Bib | Refuel | Crew | Brand | 00_dss | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | ... | wp2_pos | ass1_gain | ass1_pos | wp4_gain | wp4_pos | wp5_gain | wp5_pos | wp6_gain | wp6_pos | ass_gain | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1.0 | 306 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 10:09:30 | 00:31:03 | 00:59:29 | 01:29:58 | NaT | ... | 6.0 | ▲ | 3.0 | NaN | NaN | ▲ | 1.0 | = | 1.0 | = |
1 | 2.0 | 301 | False | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 07:54:00 | 00:28:49 | 00:56:57 | 01:26:33 | 01:56:13 | ... | 3.0 | ▲ | 1.0 | = | 1.0 | ▼ | 2.0 | = | 2.0 | = |
2 | 3.0 | 303 | False | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 08:03:00 | 00:30:27 | 01:00:33 | 01:30:33 | 02:02:29 | ... | 8.0 | ▲ | 5.0 | ▲ | 3.0 | = | 3.0 | ▼ | 4.0 | ▲ |
3 | 4.0 | 302 | False | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 08:00:00 | 00:31:34 | 01:00:55 | 01:30:23 | 02:02:26 | ... | 9.0 | ▲ | 4.0 | ▲ | 2.0 | ▼ | 5.0 | ▲ | 3.0 | ▼ |
4 | 5.0 | 307 | False | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 07:36:00 | 00:29:48 | 00:58:13 | 01:32:02 | 02:03:26 | ... | 4.0 | ▼ | 6.0 | ▲ | 4.0 | = | 4.0 | ▼ | 5.0 | = |
5 rows × 28 columns
data[TIMING].dtypes
Pos float64 Bib int64 Refuel bool Crew object Brand object 00_dss timedelta64[ns] 01_wp1 timedelta64[ns] 02_wp2 timedelta64[ns] 03_ass1 timedelta64[ns] 04_wp4 timedelta64[ns] 05_wp5 timedelta64[ns] 06_wp6 timedelta64[ns] 07_ass timedelta64[ns] dss_gain object dss_pos float64 wp1_gain object wp1_pos float64 wp2_gain object wp2_pos float64 ass1_gain object ass1_pos float64 wp4_gain object wp4_pos float64 wp5_gain object wp5_pos float64 wp6_gain object wp6_pos float64 ass_gain object dtype: object
Some of the scraped tables are used to provide selection lists, but we might be able to use them as metadata tables.
For example, here's a pretty complete set, although mangled together, set of competititor names, nationalities, and team names:
data[ CREWTEAM ].head()
Highlight | Filter | Bib | Names | |
---|---|---|---|---|
0 | Highglight crew | Filter crew | 1 | M. WALKNER (Austria)RED BULL KTM FACTORY TEAM |
1 | Highglight crew | Filter crew | 2 | P. GONCALVES (Portugal)MONSTER ENERGY HONDA TE... |
2 | Highglight crew | Filter crew | 3 | T. PRICE (Australia)RED BULL KTM FACTORY TEAM |
3 | Highglight crew | Filter crew | 4 | A. VAN BEVEREN (France)YAMALUBE YAMAHA OFFICIA... |
4 | Highglight crew | Filter crew | 5 | J. BARREDA BORT (Spain)MONSTER ENERGY HONDA TE... |
It'll probably be convenient to have the unique Bib
values available as an index:
data[ CREWTEAM ] = data[ CREWTEAM ][['Bib', 'Names']].set_index('Bib')
data[ CREWTEAM ].head()
Names | |
---|---|
Bib | |
1 | M. WALKNER (Austria)RED BULL KTM FACTORY TEAM |
2 | P. GONCALVES (Portugal)MONSTER ENERGY HONDA TE... |
3 | T. PRICE (Australia)RED BULL KTM FACTORY TEAM |
4 | A. VAN BEVEREN (France)YAMALUBE YAMAHA OFFICIA... |
5 | J. BARREDA BORT (Spain)MONSTER ENERGY HONDA TE... |
The Names
may have several Name (Country)
values, followed by a team name. The original HTML uses <span>
tags to separate out values but the pandas .read_html()
function flattens cell contents.
Let's have a go at pulling out the team names, which appear at the end of the string. If we can split each name, and the team name, into separate columns, and then metl those columns into separate rows, grouped by Bib
number, we should be able to grab the last row, corrsponding to the team, in each group:
#Perhaps split on brackets?
# At least one team has brackets in the name at the end of the name
# So let's make that case, at least, a "not bracket" by setting a ) at the end to a :]:
# so we don't (mistakenly) split on it as if it were a country-associated bracket.
teams = data[ CREWTEAM ]['Names'].str.replace(r'\)$',':]:').str.split(')').apply(pd.Series).reset_index().melt(id_vars='Bib', var_name='Num').dropna()
#Find last item in each group, which is to say: the team
teamnames = teams.groupby('Bib').last()
#Defudge any brackets at the end back
teamnames = teamnames['value'].str.replace(':]:',')')
teamnames.head()
Bib 1 RED BULL KTM FACTORY TEAM 2 MONSTER ENERGY HONDA TEAM 2019 3 RED BULL KTM FACTORY TEAM 4 YAMALUBE YAMAHA OFFICIAL RALLY TEAM 5 MONSTER ENERGY HONDA TEAM 2019 Name: value, dtype: object
Now let's go after the competitors. These are all but the last row in each group:
#Remove last row in group i.e. the team
personnel = teams.groupby('Bib').apply(lambda x: x.iloc[:-1]).set_index('Bib').reset_index()
personnel[['Name','Country']] = personnel['value'].str.split('(').apply(pd.Series)
#Strip whitespace
for c in ['Name','Country']:
personnel[c] = personnel[c].str.strip()
personnel[['Bib','Num','Name','Country']].head()
Bib | Num | Name | Country | |
---|---|---|---|---|
0 | 1 | 0 | M. WALKNER | Austria |
1 | 2 | 0 | P. GONCALVES | Portugal |
2 | 3 | 0 | T. PRICE | Australia |
3 | 4 | 0 | A. VAN BEVEREN | France |
4 | 5 | 0 | J. BARREDA BORT | Spain |
For convenience, we might want to reshape this long form back to a wide form, with a single string containing all the competitor names associated with a particular Bib
identifier:
#Create a single name string for each vehicle
#For each Bib number, group the rows associated with that number
# and aggregate the names in those rows into a single, comma separated, joined string
# indexed by the corresponding Bib number
personnel.groupby('Bib')['Name'].agg(lambda col: ', '.join(col)).tail()
Bib 538 CG. RICKLER DEL MARE, DR. BURAN 539 F. SKROBANEK, P. LESAK, R. BACULIK 540 J. GINESTA, F. ESTER FERNANDEZ, M. DARDAILLON 541 A. BENBEKHTI, S. BENBEKHTI, R. OSMANI 542 S. BESNARD, F. DERONCE, S. LALICHE Name: Name, dtype: object
data[ BRANDS ].head()
Filter | Names | |
---|---|---|
0 | Filter brand | BETA |
1 | Filter brand | BMW |
2 | Filter brand | BORGWARD |
3 | Filter brand | BOSUER |
4 | Filter brand | BRP |
data[ COUNTRIES ].head()
Filter | Names | |
---|---|---|
0 | Filter country | Andorra (AND) |
1 | Filter country | United Arab Emirates (ARE) |
2 | Filter country | Argentina (ARG) |
3 | Filter country | Australia (AUS) |
4 | Filter country | Austria (AUT) |
data[ COUNTRIES ][['Country','CountryCode']] = data[ COUNTRIES ]['Names'].str.extract(r'(.*) \((.*)\)',expand=True)
data[ COUNTRIES ].head()
Filter | Names | Country | CountryCode | |
---|---|---|---|---|
0 | Filter country | Andorra (AND) | Andorra | AND |
1 | Filter country | United Arab Emirates (ARE) | United Arab Emirates | ARE |
2 | Filter country | Argentina (ARG) | Argentina | ARG |
3 | Filter country | Australia (AUS) | Australia | AUS |
4 | Filter country | Austria (AUT) | Austria | AUT |
def get_annotated_timing_data(stage,vtype='car', timerank='time'):
''' Return a timing dataset that's ready to use. '''
df = get_timing_data(stage, vtype, timerank)
df[TIMING].insert(2,'Road Position', df[TIMING].sort_values('00_dss',ascending=True)['00_dss'].rank())
return df
t_data = get_annotated_timing_data(STAGE,vtype='car', timerank='time')[TIMING]
t_data.head()
Pos | Bib | Road Position | Refuel | Crew | Brand | 00_dss | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | 07_ass | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 10:09:30 | 00:31:03 | 00:59:29 | 01:29:58 | NaT | 02:31:06 | 03:11:08 | 03:54:53 |
1 | 2.0 | 301 | 4.0 | False | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 07:54:00 | 00:28:49 | 00:56:57 | 01:26:33 | 01:56:13 | 02:35:08 | 03:16:42 | 04:02:20 |
2 | 3.0 | 303 | 7.0 | False | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 08:03:00 | 00:30:27 | 01:00:33 | 01:30:33 | 02:02:29 | 02:39:15 | 03:22:41 | 04:10:08 |
3 | 4.0 | 302 | 6.0 | False | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 08:00:00 | 00:31:34 | 01:00:55 | 01:30:23 | 02:02:26 | 02:39:30 | 03:22:28 | 04:10:48 |
4 | 5.0 | 307 | 2.0 | False | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 07:36:00 | 00:29:48 | 00:58:13 | 01:32:02 | 02:03:26 | 02:39:27 | 03:22:42 | 04:10:50 |
not_timing_cols = ['Pos','Road Position','Refuel','Bib','Crew','Brand']
driver_data = t_data[ not_timing_cols ]
driver_data.head()
Pos | Road Position | Refuel | Bib | Crew | Brand | |
---|---|---|---|---|---|---|
0 | 1.0 | 11.0 | False | 306 | S. LOEB D. ELENA PH-SPORT | PEUGEOT |
1 | 2.0 | 4.0 | False | 301 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA |
2 | 3.0 | 7.0 | False | 303 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI |
3 | 4.0 | 6.0 | False | 302 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA |
4 | 5.0 | 2.0 | False | 307 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI |
The number of waypoints differs across stages. If we cast the wide format waypoint data into a long form, we can more conveniently merge waypoint timing data from separate stages into the same dataframe.
pd.melt(t_data.head(),
id_vars=not_timing_cols,
var_name='Section', value_name='Time').head()
Pos | Road Position | Refuel | Bib | Crew | Brand | Section | Time | |
---|---|---|---|---|---|---|---|---|
0 | 1.0 | 11.0 | False | 306 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 00_dss | 10:09:30 |
1 | 2.0 | 4.0 | False | 301 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 00_dss | 07:54:00 |
2 | 3.0 | 7.0 | False | 303 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 00_dss | 08:03:00 |
3 | 4.0 | 6.0 | False | 302 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 00_dss | 08:00:00 |
4 | 5.0 | 2.0 | False | 307 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 00_dss | 07:36:00 |
def _timing_long(df, nodss=True):
''' Cast timing data to long data frame. '''
df = pd.melt(df,
id_vars=[c for c in df.columns if not any(_c in c for _c in ['dss','wp','ass'])],
var_name='Section', value_name='Time')
if nodss:
return df[df['Section']!='00_dss']
return df
t_data_long = _timing_long(t_data)
t_data_long.head()
Pos | Bib | Road Position | Refuel | Crew | Brand | Section | Time | |
---|---|---|---|---|---|---|---|---|
65 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 01_wp1 | 00:31:03 |
66 | 2.0 | 301 | 4.0 | False | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 01_wp1 | 00:28:49 |
67 | 3.0 | 303 | 7.0 | False | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 01_wp1 | 00:30:27 |
68 | 4.0 | 302 | 6.0 | False | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 01_wp1 | 00:31:34 |
69 | 5.0 | 307 | 2.0 | False | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 01_wp1 | 00:29:48 |
def get_long_annotated_timing_data(stage,vtype='car', timerank='time'):
''' Get annotated timing dataframe and convert it to long format. '''
_tmp = get_annotated_timing_data(stage,vtype, timerank)
_tmp[TIMING] = _timing_long(_tmp[TIMING])
#Find the total seconds for each split / waypoint duration
_tmp[TIMING]['TimeInS'] = _tmp[TIMING]['Time'].dt.total_seconds()
return _tmp
get_long_annotated_timing_data(STAGE)[TIMING].head()
Pos | Bib | Road Position | Refuel | Crew | Brand | Section | Time | TimeInS | |
---|---|---|---|---|---|---|---|---|---|
65 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 01_wp1 | 00:31:03 | 1863.0 |
66 | 2.0 | 301 | 4.0 | False | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 01_wp1 | 00:28:49 | 1729.0 |
67 | 3.0 | 303 | 7.0 | False | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 01_wp1 | 00:30:27 | 1827.0 |
68 | 4.0 | 302 | 6.0 | False | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 01_wp1 | 00:31:34 | 1894.0 |
69 | 5.0 | 307 | 2.0 | False | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 01_wp1 | 00:29:48 | 1788.0 |
The data can be saved to a database directly in an unnormalised form, or we can tidy it up a bit and save it in a higher normal form.
That is, the time taken to get from one waypoint to the next. If we think of waypoints as splits, this is eesentially a timeInSplit
value. If we know this information, we can work out how much time each competitor made, or lost, relative to every other competitor in the same class, going between each waypoint.
This means we may be able to work out which parts of the stage a particular competitor was pushing on, or had difficulties on.
def get_time_between_waypoints(timing_data_long):
''' Find time taken to go from one waypoint to the next for each vehicle. '''
#The timeInSplit is the time between waypoints.
#So find the diff between each consecutive waypoint time for each Crew
timing_data_long['timeInSplit'] = timing_data_long[['Crew','Time']].groupby('Crew').diff()
#Because we're using a diff(), the first row is set to NaN - there's nothing to diff to
#So use the time at the first split as the time from the start to the first waypoint.
timing_data_long.loc[timing_data_long.groupby('Crew')['timeInSplit'].head(1).index, 'timeInSplit'] = timing_data_long.loc[timing_data_long.groupby('Crew')['timeInSplit'].head(1).index,'Time']
#To finesse diff calculations on NaT, set diff with day!=0 to NaT
#This catches things where we get spurious times calculated as diff times against NaTs
timing_data_long.loc[timing_data_long['Time'].isna(),'timeInSplit'] = pd.NaT
timing_data_long.loc[timing_data_long['timeInSplit'].dt.days!=0,'timeInSplit'] = pd.NaT
#If there's been a reset, we can fill across
timing_data_long[['Time','timeInSplit']] = timing_data_long[['Time','timeInSplit']].fillna(method='ffill',axis=1)
#Find the total seconds for each split / waypoint duration
timing_data_long['splitS'] = timing_data_long['timeInSplit'].dt.total_seconds()
return timing_data_long
def get_timing_data_long_timeInSplit(stage):
''' For a stage, get the data in long form, including timeInSplit times. '''
timing_data_long = get_long_annotated_timing_data(stage)[TIMING]
timing_data_long = get_time_between_waypoints(timing_data_long)
return timing_data_long
#Preview some data
timing_data_long_insplit = get_timing_data_long_timeInSplit(STAGE)
timing_data_long_insplit[timing_data_long_insplit['Brand']=='PEUGEOT'].head()
Pos | Bib | Road Position | Refuel | Crew | Brand | Section | Time | TimeInS | timeInSplit | splitS | |
---|---|---|---|---|---|---|---|---|---|---|---|
65 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 01_wp1 | 00:31:03 | 1863.0 | 00:31:03 | 1863.0 |
73 | 9.0 | 325 | 12.0 | False | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | 01_wp1 | 00:35:03 | 2103.0 | 00:35:03 | 2103.0 |
83 | 19.0 | 364 | 63.0 | False | P. LAFAY S. DELAUNAY EASY RALLY | PEUGEOT | 01_wp1 | 00:37:02 | 2222.0 | 00:37:02 | 2222.0 |
130 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 02_wp2 | 00:59:29 | 3569.0 | 00:28:26 | 1706.0 |
138 | 9.0 | 325 | 12.0 | False | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | 02_wp2 | 01:09:48 | 4188.0 | 00:34:45 | 2085.0 |
timing_data_long = get_long_annotated_timing_data(STAGE)[TIMING]
timing_data_long
timing_data_long[timing_data_long['Brand']=='PEUGEOT'].head()
Pos | Bib | Road Position | Refuel | Crew | Brand | Section | Time | TimeInS | |
---|---|---|---|---|---|---|---|---|---|
65 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 01_wp1 | 00:31:03 | 1863.0 |
73 | 9.0 | 325 | 12.0 | False | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | 01_wp1 | 00:35:03 | 2103.0 |
83 | 19.0 | 364 | 63.0 | False | P. LAFAY S. DELAUNAY EASY RALLY | PEUGEOT | 01_wp1 | 00:37:02 | 2222.0 |
130 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 02_wp2 | 00:59:29 | 3569.0 |
138 | 9.0 | 325 | 12.0 | False | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | 02_wp2 | 01:09:48 | 4188.0 |
Rebasing means finding deltas relative to a specified driver. It lets us see the deltas a particular driver has to each other driver.
def rebaseTimes(times, bib=None, col='splitS'):
''' Rebase times relative to a particular competitor. '''
if bib is None: return deltas
bib = int(bib)
rebase = times[times['Bib']==bib][['Section',col]].set_index('Section').to_dict(orient='dict')[col]
times['rebased']=times[col]-times['Section'].map(rebase)
return times
That is, rebase the overall time in stage at each waypoint relative to a specified driver.
REBASER = 304
#loeb 306, AL-ATTIYAH 301, pertehansel
timing_data_long_min = rebaseTimes( timing_data_long , REBASER, 'TimeInS')
-timing_data_long_min.reset_index().pivot('Bib','Section','rebased').head()
Section | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | 07_ass |
---|---|---|---|---|---|---|---|
Bib | |||||||
300 | 22.0 | 46.0 | -2027.0 | -2696.0 | -3623.0 | -3351.0 | -3750.0 |
301 | 19.0 | -14.0 | 101.0 | 866.0 | 709.0 | 1413.0 | 1454.0 |
302 | -146.0 | -252.0 | -129.0 | 493.0 | 447.0 | 1067.0 | 946.0 |
303 | -79.0 | -230.0 | -139.0 | 490.0 | 462.0 | 1054.0 | 986.0 |
304 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 |
rebaseTimes(timing_data_long_insplit,REBASER).head()
Pos | Bib | Road Position | Refuel | Crew | Brand | Section | Time | TimeInS | timeInSplit | splitS | rebased | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
65 | 1.0 | 306 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT | 01_wp1 | 00:31:03 | 1863.0 | 00:31:03 | 1863.0 | 115.0 |
66 | 2.0 | 301 | 4.0 | False | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 01_wp1 | 00:28:49 | 1729.0 | 00:28:49 | 1729.0 | -19.0 |
67 | 3.0 | 303 | 7.0 | False | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | 01_wp1 | 00:30:27 | 1827.0 | 00:30:27 | 1827.0 | 79.0 |
68 | 4.0 | 302 | 6.0 | False | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | 01_wp1 | 00:31:34 | 1894.0 | 00:31:34 | 1894.0 | 146.0 |
69 | 5.0 | 307 | 2.0 | False | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | 01_wp1 | 00:29:48 | 1788.0 | 00:29:48 | 1788.0 | 40.0 |
def pivotRebasedSplits(rebasedSplits):
''' For each driver row, find the split. '''
#If there are no splits...
if rebasedSplits.empty:
return pd.DataFrame(columns=['Bib']).set_index('Bib')
rbp=-rebasedSplits.pivot('Bib','Section','rebased')
rbp.columns=['D{}'.format(c) for c in rbp.columns]
#The columns seem to be sorted? Need to sort in actual split order
rbp.sort_values(rbp.columns[-1],ascending =True)
return rbp
tmp = pivotRebasedSplits(rebaseTimes(timing_data_long_insplit,REBASER))
tmp.head()
D01_wp1 | D02_wp2 | D03_ass1 | D04_wp4 | D05_wp5 | D06_wp6 | D07_ass | |
---|---|---|---|---|---|---|---|
Bib | |||||||
300 | 22.0 | 24.0 | -2073.0 | -669.0 | -927.0 | 272.0 | -399.0 |
301 | 19.0 | -33.0 | 115.0 | 765.0 | -157.0 | 704.0 | 41.0 |
302 | -146.0 | -106.0 | 123.0 | 622.0 | -46.0 | 620.0 | -121.0 |
303 | -79.0 | -151.0 | 91.0 | 629.0 | -28.0 | 592.0 | -68.0 |
304 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 |
top10 = driver_data[driver_data['Pos']<=15]
top10.set_index('Bib', inplace=True)
top10
Pos | Road Position | Refuel | Crew | Brand | |
---|---|---|---|---|---|
Bib | |||||
306 | 1.0 | 11.0 | False | S. LOEB D. ELENA PH-SPORT | PEUGEOT |
301 | 2.0 | 4.0 | False | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA |
303 | 3.0 | 7.0 | False | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI |
302 | 4.0 | 6.0 | False | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA |
307 | 5.0 | 2.0 | False | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI |
314 | 6.0 | 13.0 | False | Y. AL RAJHI T. GOTTSCHALK X-RAID TEAM | MINI |
305 | 7.0 | 10.0 | False | M. PROKOP J. TOMANEK MP-SPORTS | FORD |
304 | 8.0 | 1.0 | False | S. PETERHANSEL D. CASTERA X-RAID MINI JCW TEAM | MINI |
325 | 9.0 | 12.0 | False | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT |
319 | 10.0 | 16.0 | False | R. CHABOT G. PILLOT OVERDRIVE TOYOTA | TOYOTA |
337 | 11.0 | 22.0 | False | V. ZALA S. JURGELENAS AGRORODEO | TOYOTA |
321 | 12.0 | 14.0 | False | B. GARAFULIC F. PALMEIRO X-RAID TEAM | MINI |
315 | 13.0 | 21.0 | False | A. JUKNEVICIUS D. VAICIULIS CRAFT BEARINGS | TOYOTA |
326 | 14.0 | 17.0 | False | P. GACHE S. PREVOT GEELY AUTO SHELL LUBRICANT ... | BUGGY |
353 | 15.0 | 19.0 | False | S. HENRARD G. DU BOIS HENRARD RACING TEAM | HENRARD RACING |
#UPDATE THIS FROM WRC NOTEBOOKS TO HANDLE EXCEPTIONS, DATATYPES ETC
from IPython.core.display import HTML
from numpy import NaN
from math import nan
def bg_color(s):
''' Set background colour sensitive to time gained or lost.
'''
attrs=[]
for _s in s:
if _s < 0:
attr = 'background-color: green; color: white'
elif _s > 0:
attr = 'background-color: red; color: white'
else:
attr = ''
attrs.append(attr)
return attrs
#https://pandas.pydata.org/pandas-docs/stable/style.html
def color_negative(val):
"""
Takes a scalar and returns a string with
the css property `'color: red'` for negative
strings, black otherwise.
"""
if isinstance(val, str) or pd.isnull(val): return ''
val = val.total_seconds() if isinstance(val,pd._libs.tslibs.timedeltas.Timedelta) else val
if val and (isinstance(val,int) or isinstance(val,float)):
color = 'green' if val < 0 else 'red' if val > 0 else 'black'
else:
color='white'
return 'color: %s' % color
def cleanDriverSplitReportBaseDataframe(rb2, ss):
''' Tidy up the driver split report dataframe, replacing 0 values with NaNs that can be hidden.
Check column names and data types. '''
#TO DO: set proper colnames
if rb2.empty: return rb2
rb2=rb2.replace(0,NaN)
#rb2=rb2.fillna('') #This casts columns containing NA to object type which means we can't use nan processing
#rb2['Road Position']=rb2['Road Position'].astype(float)
return rb2
def __styleDriverSplitReportBaseDataframe(rb2, ss):
''' Test if basic dataframe styling.
DEPRECATED. '''
cm=sns.light_palette((210, 90, 60), input="husl",as_cmap=True)
s=(rb2.fillna('').style
.applymap(color_negative, subset=[c for c in rb2.columns if c not in ['Pos','Road Position','Crew','Brand'] ])
.background_gradient(cmap=cm, subset=['Road Position'])
)
#data.style.applymap(highlight_cols, subset=pd.IndexSlice[:, ['B', 'C']])
s.set_caption("{}: running times and deltas between each checkpoint.".format(ss))
return s
rb2c = pivotRebasedSplits(rebaseTimes(timing_data_long_insplit,REBASER))
rb2c = cleanDriverSplitReportBaseDataframe(rb2c, STAGE)
#rb2cTop10 = rb2c[rb2c.index.isin(top10['Bib'])]
rb2cTop10 = pd.merge(top10, rb2c, how='left', left_index=True,right_index=True)
#Need processing on the below - also change column order
newColOrder = rb2cTop10.columns[1:].tolist()+[rb2cTop10.columns[0]]
rb2cTop10=rb2cTop10[newColOrder]
rb2cTop10 = pd.merge(rb2cTop10, -timing_data_long_min.reset_index().pivot('Bib','Section','rebased'), how='left', left_index=True,right_index=True)
#Cast s to timedelta
#for c in [c for c in rb2cTop10.columns if c.startswith('0')]:
# rb2cTop10[c]=rb2cTop10[c].apply(lambda x: pd.to_timedelta('{}00:00:{}'.format('-' if x<0 else '', '0' if pd.isnull(x) else abs(x))))
#Rename last column
rb2cTop10.rename(columns={rb2cTop10.columns[-1]:'Stage Overall'}, inplace=True)
#Drop refule column
rb2cTop10.drop('Refuel', axis=1, inplace=True)
rb2cTop10
Road Position | Crew | Brand | D01_wp1 | D02_wp2 | D03_ass1 | D04_wp4 | D05_wp5 | D06_wp6 | D07_ass | Pos | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | Stage Overall | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Bib | ||||||||||||||||||
306 | 11.0 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | -115.0 | -51.0 | 62.0 | NaN | -6888.0 | 796.0 | 154.0 | 1.0 | -115.0 | -166.0 | -104.0 | NaN | 951.0 | 1747.0 | 1901.0 |
301 | 4.0 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 19.0 | -33.0 | 115.0 | 765.0 | -157.0 | 704.0 | 41.0 | 2.0 | 19.0 | -14.0 | 101.0 | 866.0 | 709.0 | 1413.0 | 1454.0 |
303 | 7.0 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | -79.0 | -151.0 | 91.0 | 629.0 | -28.0 | 592.0 | -68.0 | 3.0 | -79.0 | -230.0 | -139.0 | 490.0 | 462.0 | 1054.0 | 986.0 |
302 | 6.0 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | -146.0 | -106.0 | 123.0 | 622.0 | -46.0 | 620.0 | -121.0 | 4.0 | -146.0 | -252.0 | -129.0 | 493.0 | 447.0 | 1067.0 | 946.0 |
307 | 2.0 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | -40.0 | -50.0 | -138.0 | 661.0 | 17.0 | 603.0 | -109.0 | 5.0 | -40.0 | -90.0 | -228.0 | 433.0 | 450.0 | 1053.0 | 944.0 |
314 | 13.0 | Y. AL RAJHI T. GOTTSCHALK X-RAID TEAM | MINI | -133.0 | -365.0 | -108.0 | 709.0 | -18.0 | 557.0 | -112.0 | 6.0 | -133.0 | -498.0 | -606.0 | 103.0 | 85.0 | 642.0 | 530.0 |
305 | 10.0 | M. PROKOP J. TOMANEK MP-SPORTS | FORD | -144.0 | -237.0 | 10.0 | 610.0 | -129.0 | 439.0 | -364.0 | 7.0 | -144.0 | -381.0 | -371.0 | 239.0 | 110.0 | 549.0 | 185.0 |
304 | 1.0 | S. PETERHANSEL D. CASTERA X-RAID MINI JCW TEAM | MINI | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 8.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 | -0.0 |
325 | 12.0 | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | -355.0 | -430.0 | -167.0 | 574.0 | -139.0 | 444.0 | -208.0 | 9.0 | -355.0 | -785.0 | -952.0 | -378.0 | -517.0 | -73.0 | -281.0 |
319 | 16.0 | R. CHABOT G. PILLOT OVERDRIVE TOYOTA | TOYOTA | -297.0 | -486.0 | -316.0 | 584.0 | -211.0 | 414.0 | -263.0 | 10.0 | -297.0 | -783.0 | -1099.0 | -515.0 | -726.0 | -312.0 | -575.0 |
337 | 22.0 | V. ZALA S. JURGELENAS AGRORODEO | TOYOTA | -405.0 | -475.0 | -467.0 | 631.0 | -795.0 | 484.0 | -63.0 | 11.0 | -405.0 | -880.0 | -1347.0 | -716.0 | -1511.0 | -1027.0 | -1090.0 |
321 | 14.0 | B. GARAFULIC F. PALMEIRO X-RAID TEAM | MINI | -204.0 | -396.0 | -196.0 | -284.0 | -154.0 | 325.0 | -304.0 | 12.0 | -204.0 | -600.0 | -796.0 | -1080.0 | -1234.0 | -909.0 | -1213.0 |
315 | 21.0 | A. JUKNEVICIUS D. VAICIULIS CRAFT BEARINGS | TOYOTA | -588.0 | -719.0 | -521.0 | 494.0 | -237.0 | 320.0 | -116.0 | 13.0 | -588.0 | -1307.0 | -1828.0 | -1334.0 | -1571.0 | -1251.0 | -1367.0 |
326 | 17.0 | P. GACHE S. PREVOT GEELY AUTO SHELL LUBRICANT ... | BUGGY | -182.0 | -325.0 | -89.0 | -643.0 | -365.0 | 430.0 | -316.0 | 14.0 | -182.0 | -507.0 | -596.0 | -1239.0 | -1604.0 | -1174.0 | -1490.0 |
353 | 19.0 | S. HENRARD G. DU BOIS HENRARD RACING TEAM | HENRARD RACING | -500.0 | -566.0 | -366.0 | 508.0 | -401.0 | 84.0 | -458.0 | 15.0 | -500.0 | -1066.0 | -1432.0 | -924.0 | -1325.0 | -1241.0 | -1699.0 |
s = __styleDriverSplitReportBaseDataframe(rb2cTop10, 'Stage {}'.format(STAGE))
html=s.render()
display(HTML(html))
Road Position | Crew | Brand | D01_wp1 | D02_wp2 | D03_ass1 | D04_wp4 | D05_wp5 | D06_wp6 | D07_ass | Pos | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | Stage Overall | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Bib | ||||||||||||||||||
306 | 11 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | -115 | -51 | 62 | -6888 | 796 | 154 | 1 | -115 | -166 | -104 | 951 | 1747 | 1901 | ||
301 | 4 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 19 | -33 | 115 | 765 | -157 | 704 | 41 | 2 | 19 | -14 | 101 | 866 | 709 | 1413 | 1454 |
303 | 7 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | -79 | -151 | 91 | 629 | -28 | 592 | -68 | 3 | -79 | -230 | -139 | 490 | 462 | 1054 | 986 |
302 | 6 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO RACING SA | TOYOTA | -146 | -106 | 123 | 622 | -46 | 620 | -121 | 4 | -146 | -252 | -129 | 493 | 447 | 1067 | 946 |
307 | 2 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | -40 | -50 | -138 | 661 | 17 | 603 | -109 | 5 | -40 | -90 | -228 | 433 | 450 | 1053 | 944 |
314 | 13 | Y. AL RAJHI T. GOTTSCHALK X-RAID TEAM | MINI | -133 | -365 | -108 | 709 | -18 | 557 | -112 | 6 | -133 | -498 | -606 | 103 | 85 | 642 | 530 |
305 | 10 | M. PROKOP J. TOMANEK MP-SPORTS | FORD | -144 | -237 | 10 | 610 | -129 | 439 | -364 | 7 | -144 | -381 | -371 | 239 | 110 | 549 | 185 |
304 | 1 | S. PETERHANSEL D. CASTERA X-RAID MINI JCW TEAM | MINI | 8 | -0 | -0 | -0 | -0 | -0 | -0 | -0 | |||||||
325 | 12 | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | -355 | -430 | -167 | 574 | -139 | 444 | -208 | 9 | -355 | -785 | -952 | -378 | -517 | -73 | -281 |
319 | 16 | R. CHABOT G. PILLOT OVERDRIVE TOYOTA | TOYOTA | -297 | -486 | -316 | 584 | -211 | 414 | -263 | 10 | -297 | -783 | -1099 | -515 | -726 | -312 | -575 |
337 | 22 | V. ZALA S. JURGELENAS AGRORODEO | TOYOTA | -405 | -475 | -467 | 631 | -795 | 484 | -63 | 11 | -405 | -880 | -1347 | -716 | -1511 | -1027 | -1090 |
321 | 14 | B. GARAFULIC F. PALMEIRO X-RAID TEAM | MINI | -204 | -396 | -196 | -284 | -154 | 325 | -304 | 12 | -204 | -600 | -796 | -1080 | -1234 | -909 | -1213 |
315 | 21 | A. JUKNEVICIUS D. VAICIULIS CRAFT BEARINGS | TOYOTA | -588 | -719 | -521 | 494 | -237 | 320 | -116 | 13 | -588 | -1307 | -1828 | -1334 | -1571 | -1251 | -1367 |
326 | 17 | P. GACHE S. PREVOT GEELY AUTO SHELL LUBRICANT COOPER TIRE T | BUGGY | -182 | -325 | -89 | -643 | -365 | 430 | -316 | 14 | -182 | -507 | -596 | -1239 | -1604 | -1174 | -1490 |
353 | 19 | S. HENRARD G. DU BOIS HENRARD RACING TEAM | HENRARD RACING | -500 | -566 | -366 | 508 | -401 | 84 | -458 | 15 | -500 | -1066 | -1432 | -924 | -1325 | -1241 | -1699 |
import seaborn as sns
#Changes from WRC
#applymap(color_negative, - change column identification
#final apply - add 'Stage Overall' to list
#.background_gradient Add Pos
def moreStyleDriverSplitReportBaseDataframe(rb2,ss, caption=None):
''' Style the driver split report dataframe. '''
if rb2.empty: return ''
def _subsetter(cols, items):
''' Generate a subset of valid columns from a list. '''
return [c for c in cols if c in items]
#https://community.modeanalytics.com/gallery/python_dataframe_styling/
# Set CSS properties for th elements in dataframe
th_props = [
('font-size', '11px'),
('text-align', 'center'),
('font-weight', 'bold'),
('color', '#6d6d6d'),
('background-color', '#f7f7f9')
]
# Set CSS properties for td elements in dataframe
td_props = [
('font-size', '11px'),
]
# Set table styles
styles = [
dict(selector="th", props=th_props),
dict(selector="td", props=td_props)
]
#Define colour palettes
#cmg = sns.light_palette("green", as_cmap=True)
#The blue palette helps us scale the Road Position column
# This may help us to help identify any obvious road position effect when sorting stage times by stage rank
cm=sns.light_palette((210, 90, 60), input="husl",as_cmap=True)
s2=(rb2.style
.background_gradient(cmap=cm, subset=_subsetter(rb2.columns, ['Road Position', 'Pos']))
.applymap(color_negative,
subset=[c for c in rb2.columns if rb2[c].dtype==float and (not c.startswith('D') and c not in ['Overall Position', 'Road Position', 'Pos'])])
.highlight_min(subset=_subsetter(rb2.columns, ['Overall Position']), color='lightgrey')
.highlight_max(subset=_subsetter(rb2.columns, ['Overall Time']), color='lightgrey')
.highlight_max(subset=_subsetter(rb2.columns, ['Previous']), color='lightgrey')
.apply(bg_color,subset=_subsetter(rb2.columns, ['{} Overall'.format(ss), 'Overall Time', 'Previous', 'Stage Overall']))
.bar(subset=[c for c in rb2.columns if str(c).startswith('D')], align='zero', color=[ '#5fba7d','#d65f5f'])
.set_table_styles(styles)
#.format({'total_amt_usd_pct_diff': "{:.2%}"})
)
if caption is not None:
s2.set_caption(caption)
#nan issue: https://github.com/pandas-dev/pandas/issues/21527
return s2.render().replace('nan','')
## Can we style the type in the red/green thing by using timedelta and formatter
# eg https://docs.python.org/3.4/library/datetime.html?highlight=weekday#datetime.date.__format__
#and https://stackoverflow.com/a/46370761/454773
#Maybe also add a sparkline? Need to set a common y axis on all charts?
s2 = moreStyleDriverSplitReportBaseDataframe(rb2cTop10, STAGE)
display(HTML(s2))
Road Position | Crew | Brand | D01_wp1 | D02_wp2 | D03_ass1 | D04_wp4 | D05_wp5 | D06_wp6 | D07_ass | Pos | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | Stage Overall | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Bib | ||||||||||||||||||
306 | 11 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | -115 | -51 | 62 | -6888 | 796 | 154 | 1 | -115 | -166 | -104 | 951 | 1747 | 1901 | ||
301 | 4 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 19 | -33 | 115 | 765 | -157 | 704 | 41 | 2 | 19 | -14 | 101 | 866 | 709 | 1413 | 1454 |
303 | 7 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | -79 | -151 | 91 | 629 | -28 | 592 | -68 | 3 | -79 | -230 | -139 | 490 | 462 | 1054 | 986 |
302 | 6 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO RACING SA | TOYOTA | -146 | -106 | 123 | 622 | -46 | 620 | -121 | 4 | -146 | -252 | -129 | 493 | 447 | 1067 | 946 |
307 | 2 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | -40 | -50 | -138 | 661 | 17 | 603 | -109 | 5 | -40 | -90 | -228 | 433 | 450 | 1053 | 944 |
314 | 13 | Y. AL RAJHI T. GOTTSCHALK X-RAID TEAM | MINI | -133 | -365 | -108 | 709 | -18 | 557 | -112 | 6 | -133 | -498 | -606 | 103 | 85 | 642 | 530 |
305 | 10 | M. PROKOP J. TOMANEK MP-SPORTS | FORD | -144 | -237 | 10 | 610 | -129 | 439 | -364 | 7 | -144 | -381 | -371 | 239 | 110 | 549 | 185 |
304 | 1 | S. PETERHANSEL D. CASTERA X-RAID MINI JCW TEAM | MINI | 8 | -0 | -0 | -0 | -0 | -0 | -0 | -0 | |||||||
325 | 12 | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | -355 | -430 | -167 | 574 | -139 | 444 | -208 | 9 | -355 | -785 | -952 | -378 | -517 | -73 | -281 |
319 | 16 | R. CHABOT G. PILLOT OVERDRIVE TOYOTA | TOYOTA | -297 | -486 | -316 | 584 | -211 | 414 | -263 | 10 | -297 | -783 | -1099 | -515 | -726 | -312 | -575 |
337 | 22 | V. ZALA S. JURGELENAS AGRORODEO | TOYOTA | -405 | -475 | -467 | 631 | -795 | 484 | -63 | 11 | -405 | -880 | -1347 | -716 | -1511 | -1027 | -1090 |
321 | 14 | B. GARAFULIC F. PALMEIRO X-RAID TEAM | MINI | -204 | -396 | -196 | -284 | -154 | 325 | -304 | 12 | -204 | -600 | -796 | -1080 | -1234 | -909 | -1213 |
315 | 21 | A. JUKNEVICIUS D. VAICIULIS CRAFT BEARINGS | TOYOTA | -588 | -719 | -521 | 494 | -237 | 320 | -116 | 13 | -588 | -1307 | -1828 | -1334 | -1571 | -1251 | -1367 |
326 | 17 | P. GACHE S. PREVOT GEELY AUTO SHELL LUBRICANT COOPER TIRE T | BUGGY | -182 | -325 | -89 | -643 | -365 | 430 | -316 | 14 | -182 | -507 | -596 | -1239 | -1604 | -1174 | -1490 |
353 | 19 | S. HENRARD G. DU BOIS HENRARD RACING TEAM | HENRARD RACING | -500 | -566 | -366 | 508 | -401 | 84 | -458 | 15 | -500 | -1066 | -1432 | -924 | -1325 | -1241 | -1699 |
Sparklines are small, "in-cell" charts that can be used to summarise trend behaviour across multiple columns in a single dtaa table row.
If we visualise the time gap relative to each other driver across checkpoints we can gain a better idea of how the overall stage gap is evolving.
A basic sparkline simply indicates trends in terms of gradient change, although we can annotate it to show whether the final value represents a positive or negative gain overall:
#https://github.com/iiSeymour/sparkline-nb/blob/master/sparkline-nb.ipynb
import matplotlib.pyplot as plt
from io import BytesIO
import urllib
import base64
def fig2inlinehtml(fig):
figfile = BytesIO()
fig.savefig(figfile, format='png')
figfile.seek(0)
figdata_png = base64.b64encode(figfile.getvalue())
#imgstr = '<img src="data:image/png;base64,{}" />'.format(figdata_png)
imgstr = '<img src="data:image/png;base64,{}" />'.format(urllib.parse.quote(figdata_png))
return imgstr
def sparkline(data, figsize=(4, 0.5), **kwags):
"""
Returns a HTML image tag containing a base64 encoded sparkline style plot
"""
data = list(data)
fig, ax = plt.subplots(1, 1, figsize=figsize, **kwags)
ax.plot(data)
for k,v in ax.spines.items():
v.set_visible(False)
ax.set_xticks([])
ax.set_yticks([])
dot = 'r.' if sign(data[len(data) - 1] )<0 else 'g.'
plt.plot(len(data) - 1, data[len(data) - 1], dot)
ax.fill_between(range(len(data)), data, len(data)*[min(data)], alpha=0.1)
return fig2inlinehtml(fig)
rb2cTop10['test']= rb2cTop10[[c for c in rb2cTop10.columns if c.startswith('0')]].values.tolist()
rb2cTop10['test'] = rb2cTop10['test'].apply(lambda x: [-y for y in x])
rb2cTop10.head()
Road Position | Crew | Brand | D01_wp1 | D02_wp2 | D03_ass1 | D04_wp4 | D05_wp5 | D06_wp6 | D07_ass | Pos | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | Stage Overall | Stage Gap | test | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Bib | ||||||||||||||||||||
306 | 11.0 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | -115.0 | -51.0 | 62.0 | NaN | -6888.0 | 796.0 | 154.0 | 1.0 | -115.0 | -166.0 | -104.0 | NaN | 951.0 | 1747.0 | 1901.0 | <img src="data:image/png;base64,iVBORw0KGgoAAA... | [115.00000000000023, 166.0, 104.0, nan, -951.0... |
301 | 4.0 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 19.0 | -33.0 | 115.0 | 765.0 | -157.0 | 704.0 | 41.0 | 2.0 | 19.0 | -14.0 | 101.0 | 866.0 | 709.0 | 1413.0 | 1454.0 | <img src="data:image/png;base64,iVBORw0KGgoAAA... | [-19.0, 14.0, -101.0, -866.0000000000009, -709... |
303 | 7.0 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | -79.0 | -151.0 | 91.0 | 629.0 | -28.0 | 592.0 | -68.0 | 3.0 | -79.0 | -230.0 | -139.0 | 490.0 | 462.0 | 1054.0 | 986.0 | <img src="data:image/png;base64,iVBORw0KGgoAAA... | [79.00000000000023, 230.0, 139.0, -490.0, -462... |
302 | 6.0 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO R... | TOYOTA | -146.0 | -106.0 | 123.0 | 622.0 | -46.0 | 620.0 | -121.0 | 4.0 | -146.0 | -252.0 | -129.0 | 493.0 | 447.0 | 1067.0 | 946.0 | <img src="data:image/png;base64,iVBORw0KGgoAAA... | [146.00000000000023, 252.00000000000045, 129.0... |
307 | 2.0 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | -40.0 | -50.0 | -138.0 | 661.0 | 17.0 | 603.0 | -109.0 | 5.0 | -40.0 | -90.0 | -228.0 | 433.0 | 450.0 | 1053.0 | 944.0 | <img src="data:image/png;base64,iVBORw0KGgoAAA... | [40.0, 90.0, 228.0, -433.0, -450.0, -1053.0] |
rb2cTop10['Stage Gap'] = rb2cTop10[:3]['test'].map(sparkline)
A more informative display gain show whether the gapt time at each split / waypoint is positive or negative by using different colour fills above and below a gap of zero seconds. We can also interpolate values to remove gaps appearing in the line when the sign of the value at one way point is different to the sign of the gap at the previous waypoint.
Because the sign of the gap is indicated, we don't need to identify it with the sign colour marker at the end of the line.
import scipy
def sparkline2(data, figsize=(4, 0.5), **kwags):
"""
Returns a HTML image tag containing a base64 encoded sparkline style plot
"""
data = [0 if pd.isnull(d) else d for d in data]
fig, ax = plt.subplots(1, 1, figsize=figsize, **kwags)
ax.plot(data, linewidth=0.0)
for k,v in ax.spines.items():
v.set_visible(False)
ax.set_xticks([])
ax.set_yticks([])
d = scipy.zeros(len(data))
#If we don't interpolate, we get a gap in the sections
# where times change sign compared to the previous section
ax.fill_between(range(len(data)), data, where=data>d, interpolate=True, color='green')
ax.fill_between(range(len(data)), data, where=data<d, interpolate=True, color='red')
return fig2inlinehtml(fig)
rb2cTop10.head(3)['test'].map(sparkline2);
%%capture
rb2cTop10['Stage Gap'] = rb2cTop10['test'].map(sparkline2)
rb2cTop10.drop('test', axis=1, inplace=True)
##sketch - data grab
#Create a dataframe of stage times over severl stages
rallydata = pd.DataFrame()
for stage in [1,2,3,4,5, 6]:
_data = _get_timing( _get_data(stage), TIMING)[TIMING]
_timing_data_long = _timing_long(_data)
_timing_data_long.insert(0,'stage', stage)
rallydata = pd.concat([rallydata, _timing_data_long], sort=False)
--------------------------------------------------------------------------- NameError Traceback (most recent call last) <ipython-input-762-adb3cf8e34e4> in <module>() 5 6 for stage in [1,2,3,4,5, 6]: ----> 7 _data = _get_timing( _get_data(stage), TIMING)[TIMING] 8 _timing_data_long = _timing_long(_data) 9 _timing_data_long.insert(0,'stage', stage) NameError: name '_get_data' is not defined
rallydata.head()
Changes to outputter - comment out set_window_size to allow browser to get full table, full table grabber
import os
import time
from selenium import webdriver
#Via https://stackoverflow.com/a/52572919/454773
def setup_screenshot(driver,path):
# Ref: https://stackoverflow.com/a/52572919/
original_size = driver.get_window_size()
required_width = driver.execute_script('return document.body.parentNode.scrollWidth')
required_height = driver.execute_script('return document.body.parentNode.scrollHeight')
driver.set_window_size(required_width, required_height)
# driver.save_screenshot(path) # has scrollbar
driver.find_element_by_tag_name('body').screenshot(path) # avoids scrollbar
driver.set_window_size(original_size['width'], original_size['height'])
def getTableImage(url, fn='dummy_table', basepath='.', path='.', delay=5, height=420, width=800):
''' Render HTML file in browser and grab a screenshot. '''
browser = webdriver.Chrome()
#browser.set_window_size(width, height)
browser.get(url)
#Give the map tiles some time to load
time.sleep(delay)
imgpath='{}/{}.png'.format(path,fn)
imgfn = '{}/{}'.format(basepath, imgpath)
imgfile = '{}/{}'.format(os.getcwd(),imgfn)
setup_screenshot(browser,imgfile)
browser.quit()
os.remove(imgfile.replace('.png','.html'))
#print(imgfn)
return imgpath
def getTablePNG(tablehtml,basepath='.', path='testpng', fnstub='testhtml'):
''' Save HTML table as file. '''
if not os.path.exists(path):
os.makedirs('{}/{}'.format(basepath, path))
fn='{cwd}/{basepath}/{path}/{fn}.html'.format(cwd=os.getcwd(), basepath=basepath, path=path,fn=fnstub)
tmpurl='file://{fn}'.format(fn=fn)
with open(fn, 'w') as out:
out.write(tablehtml)
return getTableImage(tmpurl, fnstub, basepath, path)
s2 = moreStyleDriverSplitReportBaseDataframe(rb2cTop10, STAGE)
display(HTML(s2))
Road Position | Crew | Brand | D01_wp1 | D02_wp2 | D03_ass1 | D04_wp4 | D05_wp5 | D06_wp6 | D07_ass | Pos | 01_wp1 | 02_wp2 | 03_ass1 | 04_wp4 | 05_wp5 | 06_wp6 | Stage Overall | Stage Gap | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Bib | |||||||||||||||||||
306 | 11 | S. LOEB D. ELENA PH-SPORT | PEUGEOT | -115 | -51 | 62 | -6888 | 796 | 154 | 1 | -115 | -166 | -104 | 951 | 1747 | 1901 | |||
301 | 4 | N. AL-ATTIYAH M. BAUMEL TOYOTA GAZOO RACING SA | TOYOTA | 19 | -33 | 115 | 765 | -157 | 704 | 41 | 2 | 19 | -14 | 101 | 866 | 709 | 1413 | 1454 | |
303 | 7 | J. PRZYGONSKI T. COLSOUL ORLEN X-RAID TEAM | MINI | -79 | -151 | 91 | 629 | -28 | 592 | -68 | 3 | -79 | -230 | -139 | 490 | 462 | 1054 | 986 | |
302 | 6 | G. DE VILLIERS D. VON ZITZEWITZ TOYOTA GAZOO RACING SA | TOYOTA | -146 | -106 | 123 | 622 | -46 | 620 | -121 | 4 | -146 | -252 | -129 | 493 | 447 | 1067 | 946 | |
307 | 2 | N. ROMA A. HARO BRAVO X-RAID TEAM | MINI | -40 | -50 | -138 | 661 | 17 | 603 | -109 | 5 | -40 | -90 | -228 | 433 | 450 | 1053 | 944 | |
314 | 13 | Y. AL RAJHI T. GOTTSCHALK X-RAID TEAM | MINI | -133 | -365 | -108 | 709 | -18 | 557 | -112 | 6 | -133 | -498 | -606 | 103 | 85 | 642 | 530 | |
305 | 10 | M. PROKOP J. TOMANEK MP-SPORTS | FORD | -144 | -237 | 10 | 610 | -129 | 439 | -364 | 7 | -144 | -381 | -371 | 239 | 110 | 549 | 185 | |
304 | 1 | S. PETERHANSEL D. CASTERA X-RAID MINI JCW TEAM | MINI | 8 | -0 | -0 | -0 | -0 | -0 | -0 | -0 | ||||||||
325 | 12 | P. LACHAUME JM. POLATO PH-SPORT | PEUGEOT | -355 | -430 | -167 | 574 | -139 | 444 | -208 | 9 | -355 | -785 | -952 | -378 | -517 | -73 | -281 | |
319 | 16 | R. CHABOT G. PILLOT OVERDRIVE TOYOTA | TOYOTA | -297 | -486 | -316 | 584 | -211 | 414 | -263 | 10 | -297 | -783 | -1099 | -515 | -726 | -312 | -575 | |
337 | 22 | V. ZALA S. JURGELENAS AGRORODEO | TOYOTA | -405 | -475 | -467 | 631 | -795 | 484 | -63 | 11 | -405 | -880 | -1347 | -716 | -1511 | -1027 | -1090 | |
321 | 14 | B. GARAFULIC F. PALMEIRO X-RAID TEAM | MINI | -204 | -396 | -196 | -284 | -154 | 325 | -304 | 12 | -204 | -600 | -796 | -1080 | -1234 | -909 | -1213 | |
315 | 21 | A. JUKNEVICIUS D. VAICIULIS CRAFT BEARINGS | TOYOTA | -588 | -719 | -521 | 494 | -237 | 320 | -116 | 13 | -588 | -1307 | -1828 | -1334 | -1571 | -1251 | -1367 | |
326 | 17 | P. GACHE S. PREVOT GEELY AUTO SHELL LUBRICANT COOPER TIRE T | BUGGY | -182 | -325 | -89 | -643 | -365 | 430 | -316 | 14 | -182 | -507 | -596 | -1239 | -1604 | -1174 | -1490 | |
353 | 19 | S. HENRARD G. DU BOIS HENRARD RACING TEAM | HENRARD RACING | -500 | -566 | -366 | 508 | -401 | 84 | -458 | 15 | -500 | -1066 | -1432 | -924 | -1325 | -1241 | -1699 |
getTablePNG(s2)
'testpng/testhtml.png'
!pwd
/Users/tonyhirst/Documents/notebooks