Skip to content

Commit

Permalink
Add full support for live timing data
Browse files Browse the repository at this point in the history
  • Loading branch information
theOehrly committed Mar 13, 2021
1 parent 52ff4b5 commit 027ab67
Show file tree
Hide file tree
Showing 5 changed files with 434 additions and 44 deletions.
95 changes: 66 additions & 29 deletions fastf1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ def make_path(wname, wdate, sname, sdate):


@Cache.api_request_wrapper
def timing_data(path, response=None):
def timing_data(path, response=None, livedata=None):
"""Fetch and parse timing data.
Timing data is a mixed stream of information. At a given time a packet of data may indicate position, lap time,
Expand Down Expand Up @@ -280,11 +280,14 @@ def timing_data(path, response=None):
# - inlap has to be followed by outlap
# - pit stops may never be negative (missing outlap)
# - speed traps against telemetry (especially in Q FastLap - Slow Lap)
if response is None: # no previous response provided
if livedata is not None and livedata.has('TimingData'):
response = livedata.get('TimingData')
elif response is None: # no previous response provided
logging.info("Fetching timing data...")
response = fetch_page(path, 'timing_data')
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")

logging.info("Parsing timing data...")

# split up response per driver for easier iteration and processing later
Expand Down Expand Up @@ -610,7 +613,7 @@ def _stream_data_driver(driver_raw, empty_vals, drv):


@Cache.api_request_wrapper
def timing_app_data(path, response=None):
def timing_app_data(path, response=None, livedata=None):
"""Fetch and parse 'timing app data'.
Timing app data provides the following data channels per sample:
Expand All @@ -636,18 +639,21 @@ def timing_app_data(path, response=None):
Args:
path (str): api path base string (see :func:`make_path`)
response: Response as returned by :func:`fetch_page` can be passed if it was downloaded already.
livedata: An instance of :class:`fastf1.livetiming.data.LivetimingData` to use as a source instead of the api
Returns:
A DataFrame contianing one column for each data channel as listed above.
Raises:
SessionNotAvailableError: in case the F1 livetiming api returns no data
"""
if response is None: # no response provided
if livedata is not None and livedata.has('TimingAppData'):
response = livedata.get('TimingAppData')
elif response is None: # no previous response provided
logging.info("Fetching timing app data...")
response = fetch_page(path, 'timing_app_data')
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")

data = {'LapNumber': [], 'Driver': [], 'LapTime': [], 'Stint': [], 'TotalLaps': [], 'Compound': [], 'New': [],
'TyresNotChanged': [], 'Time': [], 'LapFlags': [], 'LapCountTime': [], 'StartLaps': [], 'Outlap': []}
Expand Down Expand Up @@ -679,7 +685,7 @@ def timing_app_data(path, response=None):


@Cache.api_request_wrapper
def car_data(path, response=None):
def car_data(path, response=None, livedata=None):
"""Fetch and parse car data.
Car data provides the following data channels per sample:
Expand All @@ -698,6 +704,7 @@ def car_data(path, response=None):
Args:
path (str): api path base string (see :func:`make_path`)
response: Response as returned by :func:`fetch_page` can be passed if it was downloaded already.
livedata: An instance of :class:`fastf1.livetiming.data.LivetimingData` to use as a source instead of the api
Returns:
| A dictionary containing one pandas DataFrame per driver. Dictionary keys are the driver's numbers as
Expand All @@ -707,11 +714,17 @@ def car_data(path, response=None):
Raises:
SessionNotAvailableError: in case the F1 livetiming api returns no data
"""
if response is None:
# data recorded from live timing has a slightly different structure
is_livedata = False # flag to indicate live timing data

if livedata is not None and livedata.has('CarData.z'):
response = livedata.get('CarData.z')
is_livedata = True
elif response is None:
logging.info("Fetching car data...")
response = fetch_page(path, 'car_data')
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")

logging.info("Parsing car data...")

Expand All @@ -720,9 +733,14 @@ def car_data(path, response=None):

data = dict()

for line in response:
time = to_timedelta(line[0])
for entry in line[1]['Entries']:
for record in response:
time = to_timedelta(record[0])
if is_livedata:
jrecord = parse(record[1], zipped=True)
else:
jrecord = record[1]

for entry in jrecord['Entries']:
# date format is '2020-08-08T09:45:03.0619797Z' with a varying number of millisecond decimal points
# always remove last char ('z'), max len 26, right pad to len 26 with zeroes if shorter
date = datetime.fromisoformat('{:<026}'.format(entry['Utc'][:-1][:26]))
Expand Down Expand Up @@ -766,7 +784,7 @@ def car_data(path, response=None):


@Cache.api_request_wrapper
def position_data(path, response=None):
def position_data(path, response=None, livedata=None):
"""Fetch and parse position data.
Position data provides the following data channels per sample:
Expand All @@ -781,6 +799,7 @@ def position_data(path, response=None):
Args:
path (str): api path base string (see :func:`api.make_path`)
response: Response as returned by :func:`api.fetch_page` can be passed if it was downloaded already.
livedata: An instance of :class:`fastf1.livetiming.data.LivetimingData` to use as a source instead of the api
Returns:
| A dictionary containing one pandas DataFrame per driver. Dictionary keys are the driver's numbers as
Expand All @@ -790,11 +809,17 @@ def position_data(path, response=None):
Raises:
SessionNotAvailableError: in case the F1 livetiming api returns no data
"""
if response is None:
# data recorded from live timing has a slightly different structure
is_livedata = False # flag to indicate live timing data

if livedata is not None and livedata.has('Position.z'):
response = livedata.get('Position.z')
is_livedata = True
elif response is None:
logging.info("Fetching position data...")
response = fetch_page(path, 'position')
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")

logging.info("Parsing position data...")

Expand All @@ -807,8 +832,12 @@ def position_data(path, response=None):
data = dict()

for record in response:
time = to_timedelta(record[:ts_length])
jrecord = parse(record[ts_length:], zipped=True)
if is_livedata:
time = record[0]
jrecord = parse(record[1], zipped=True)
else:
time = to_timedelta(record[:ts_length])
jrecord = parse(record[ts_length:], zipped=True)

for sample in jrecord['Position']:
# date format is '2020-08-08T09:45:03.0619797Z' with a varying number of millisecond decimal points
Expand Down Expand Up @@ -858,7 +887,7 @@ def position_data(path, response=None):


@Cache.api_request_wrapper
def track_status_data(path, response=None):
def track_status_data(path, response=None, livedata=None):
"""Fetch and parse track status data.
Track status contains information on yellow/red/green flags, safety car and virtual safety car. It provides the
Expand All @@ -885,18 +914,22 @@ def track_status_data(path, response=None):
Args:
path (str): api path base string (see :func:`api.make_path`)
response: Response as returned by :func:`api.fetch_page` can be passed if it was downloaded already.
livedata: An instance of :class:`fastf1.livetiming.data.LivetimingData` to use as a source instead of the api
Returns:
A dictionary containing one key for each data channel and a list of values per key.
Raises:
SessionNotAvailableError: in case the F1 livetiming api returns no data
"""
if response is None:
if livedata is not None and livedata.has('TrackStatus'):
# does not need any further processing
return livedata.get('TrackStatus')
elif response is None:
logging.info("Fetching track status data...")
response = fetch_page(path, 'track_status')
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")

data = {'Time': [], 'Status': [], 'Message': []}

Expand All @@ -910,7 +943,7 @@ def track_status_data(path, response=None):


@Cache.api_request_wrapper
def session_status_data(path, response=None):
def session_status_data(path, response=None, livedata=None):
"""Fetch and parse session status data.
Session status contains information on when a session was started and when it ended (amongst others). It
Expand All @@ -924,18 +957,22 @@ def session_status_data(path, response=None):
Args:
path (str): api path base string (see :func:`api.make_path`)
response: Response as returned by :func:`api.fetch_page` can be passed if it was downloaded already.
livedata: An instance of :class:`fastf1.livetiming.data.LivetimingData` to use as a source instead of the api
Returns:
A dictionary containing one key for each data channel and a list of values per key.
Raises:
SessionNotAvailableError: in case the F1 livetiming api returns no data
"""
if response is None:
if livedata is not None and livedata.has('SessionStatus'):
# does not need any further processing
return livedata.get('SessionStatus')
elif response is None:
logging.info("Fetching session status data...")
response = fetch_page(path, 'session_status')
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")
if response is None: # no response received
raise SessionNotAvailableError("No data for this session! Are you sure this session wasn't cancelled?")

data = {'Time': [], 'Status': []}

Expand Down
31 changes: 20 additions & 11 deletions fastf1/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,7 +1092,7 @@ def _get_session_date(self):

return date

def load_laps(self, with_telemetry=True):
def load_laps(self, with_telemetry=False, livedata=None):
"""Load lap timing information and telemetry data.
This method creates a :class:`Laps` instance (:attr:`Session.laps`) which subclasses :class:`pandas.DataFrame`
Expand All @@ -1113,6 +1113,9 @@ def load_laps(self, with_telemetry=True):
Args:
with_telemetry (bool): Load telemetry data also. (Same as calling :meth:`Session.load_telemetry` manually)
livedata (fastf1.api.LivetimingResponse, optional) :
instead of requesting the data from the api, locally saved
livetiming data can be used as a data source
Returns:
instance of :class:`Laps`
Expand All @@ -1127,8 +1130,8 @@ def load_laps(self, with_telemetry=True):
pandas dataframe
"""
data, _ = api.timing_data(self.api_path)
app_data = api.timing_app_data(self.api_path)
data, _ = api.timing_data(self.api_path, livedata=livedata)
app_data = api.timing_app_data(self.api_path, livedata=livedata)
# Now we do some manipulation to make it beautiful
logging.info("Processing timing data...")

Expand All @@ -1144,8 +1147,9 @@ def load_laps(self, with_telemetry=True):
raise NoLapDataError

# check when a session was started; for a race this indicates the start of the race
session_status = api.session_status_data(self.api_path)
for i in range(len(session_status)):
session_status = api.session_status_data(self.api_path,
livedata=livedata)
for i in range(len(session_status['Status'])):
if session_status['Status'][i] == 'Started':
self.session_start_time = session_status['Time'][i]
break
Expand Down Expand Up @@ -1186,7 +1190,7 @@ def load_laps(self, with_telemetry=True):
d_map = {r['number']: r['Driver']['code'] for r in self.results}
laps['Driver'] = laps['DriverNumber'].map(d_map)
# add track status data
ts_data = api.track_status_data(self.api_path)
ts_data = api.track_status_data(self.api_path, livedata=livedata)
laps['TrackStatus'] = '1'

def applicator(new_status, current_status):
Expand All @@ -1197,7 +1201,7 @@ def applicator(new_status, current_status):
else:
return current_status

if len(ts_data) > 0:
if len(ts_data['Time']) > 0:
t = ts_data['Time'][0]
status = ts_data['Status'][0]
for next_t, next_status in zip(ts_data['Time'][1:], ts_data['Status'][1:]):
Expand Down Expand Up @@ -1231,7 +1235,7 @@ def applicator(new_status, current_status):
self._check_lap_accuracy()

if with_telemetry:
self.load_telemetry()
self.load_telemetry(livedata=livedata)

logging.info(f"Loaded data for {len(self.drivers)} drivers: {self.drivers}")

Expand Down Expand Up @@ -1283,7 +1287,7 @@ def _check_lap_accuracy(self):
if integrity_errors > 0:
logging.warning(f"Driver {drv: >2}: Lap timing integrity check failed for {integrity_errors} lap(s)")

def load_telemetry(self):
def load_telemetry(self, livedata=None):
"""Load telemetry data from API.
The raw data is divided into car data (Speed, RPM, ...) and position data (coordinates, on/off track). For each
Expand All @@ -1297,9 +1301,14 @@ def load_telemetry(self):
Note that this method additionally calculates :attr:`Session.t0_date` and adds a `LapStartDate` column to
:attr:`Session.laps`.
Args:
response (fastf1.api.LivetimingResponse, optional) :
instead of requesting the data from the api, locally saved
livetiming data can be used as a data source
"""
car_data = api.car_data(self.api_path)
pos_data = api.position_data(self.api_path)
car_data = api.car_data(self.api_path, livedata=livedata)
pos_data = api.position_data(self.api_path, livedata=livedata)

self.drivers = list(set(self.drivers).intersection(set(car_data.keys())).intersection(set(pos_data.keys())))
# self.drivers should only contain drivers which exist in all parts of the data
Expand Down
Loading

0 comments on commit 027ab67

Please sign in to comment.