Repository: mie-lab/trackintel (python)

Issue:
explicit arguments for read_positionfixes_csv
 Can [read_positionfixes_csv](https://github.com/mie-lab/trackintel/blob/master/trackintel/io/file.py#L39) have `columns`, `tz` as an explicit argument? Main goal would be to improve readability. 

I think this applies also to the other read_csv functions

Pull request:
CLN: Added explicit parameters for read_csv functions.
Closes #111 

Patch:
diff --git a/docs/modules/analysis.rst b/docs/modules/analysis.rst
index 5b4afb3..87d1a37 100644
--- a/docs/modules/analysis.rst
+++ b/docs/modules/analysis.rst
@@ -4,6 +4,11 @@ Analysis
 The analysis module provides a variety of functions to analyze the mobility
 datasets computed by trackintel. 
 
+Tracking Quality
+================
+
+.. autofunction:: trackintel.analysis.tracking_quality.temporal_tracking_quality
+
 Triplegs
 ========
 
diff --git a/trackintel/analysis/__init__.py b/trackintel/analysis/__init__.py
index 849b37f..71d1841 100644
--- a/trackintel/analysis/__init__.py
+++ b/trackintel/analysis/__init__.py
@@ -1,1 +1,2 @@
 from .transport_mode_identification import predict_transport_mode
+from .tracking_quality import temporal_tracking_quality
\ No newline at end of file
diff --git a/trackintel/analysis/tracking_quality.py b/trackintel/analysis/tracking_quality.py
new file mode 100644
index 0000000..6b7ec9b
--- /dev/null
+++ b/trackintel/analysis/tracking_quality.py
@@ -0,0 +1,169 @@
+import datetime
+
+import pandas as pd
+
+
+def temporal_tracking_quality(source, granularity="all"):
+    """
+    Calculate per-user temporal tracking quality.
+
+    Parameters
+    ----------
+    df : GeoDataFrame (as trackintel datamodels)
+        The source dataframe to calculate temporal tracking quality.
+
+    granularity : {"all", "day", "hour"}, default="all"
+        The level of which the tracking quality is claculated. The default "all" returns
+        the overall tracking quality, and "day" and "hour" returns the tracking quality
+        by days and hours, respectively.
+
+    Returns
+    -------
+    quality: DataFrame
+        A per-user temporal tracking quality dataframe.
+
+    Note
+    ----
+    The temporal tracking quality is the time proportion of tracked period with the possible
+    time extent. The possible time extents of the different granularities are different: "all"
+    considers the time between the latest "finished_at" and the earliest "started_at", whereas
+    "day" considers the whole day (86400 sec) and "hour" considers the whole hour (3600 sec).
+
+    Examples
+    --------
+    >>> # calculate overall tracking quality of stps
+    >>> temporal_tracking_quality(stps, granularity="all")
+    >>> # calculate per-day tracking quality of stps and tpls sequence
+    >>> temporal_tracking_quality(spts_tpls, granularity="day")
+    """
+    df = source.copy()
+    df.reset_index(inplace=True)
+    if granularity == "all":
+        quality = df.groupby("user_id", as_index=False).apply(_get_tracking_quality_user, granularity)
+    elif granularity == "day":
+        # split records that span several days
+        df = _split_overlaps(df, granularity=granularity)
+        # get the tracked day relative to the first day
+        start_date = df["started_at"].min().date()
+        df["day"] = df["started_at"].apply(lambda x: (x.date() - start_date).days)
+        # calculate per-user per-day tracking quality
+        quality = df.groupby(["user_id", "day"], as_index=False).apply(_get_tracking_quality_user, granularity)
+    elif granularity == "hour":
+        # split records that span several hours
+        df = _split_overlaps(df, granularity="hour")
+        # get the tracked day relative to the first day
+        start_date = df["started_at"].min().date()
+        df["day"] = df["started_at"].apply(lambda x: (x.date() - start_date).days)
+        # get the hour
+        df["hour"] = df["started_at"].dt.hour
+        # calculate per-user per-hour tracking quality
+        quality = df.groupby(["user_id", "hour"], as_index=False).apply(_get_tracking_quality_user, granularity)
+    else:
+        raise AttributeError(f"granularity unknown. We only support ['all', 'day', 'hour']. You passed {granularity}")
+    return quality
+
+
+def _get_tracking_quality_user(df, granularity="all"):
+    """
+    Tracking quality per-user per-granularity.
+
+    Parameters
+    ----------
+    df : GeoDataFrame (as trackintel datamodels)
+        The source dataframe
+
+    granularity : {"all", "day", "hour"}, default "all"
+        Determines the extent of the tracking. "all" the entire tracking period,
+        "day" a whole day and "hour" a whole hour.
+
+    Returns
+    -------
+    pandas.Series
+        A pandas.Series object containing the tracking quality
+    """
+    tracked_duration = (df["finished_at"] - df["started_at"]).dt.total_seconds().sum()
+    if granularity == "all":
+        # the whole tracking period
+        extent = (df["finished_at"].max() - df["started_at"].min()).total_seconds()
+    elif granularity == "day":
+        # total seconds in a day
+        extent = 60 * 60 * 24
+    elif granularity == "hour":
+        # total seconds in an hour * number of different days
+        # (entries from multiple days may be grouped together)
+        extent = (60 * 60) * len(df["day"].unique())
+    return pd.Series([tracked_duration / extent], index=["quality"])
+
+
+def _split_overlaps(source, granularity="day"):
+    """
+    Split input df that have a duration of several days or hours.
+
+    Parameters
+    ----------
+    source : GeoDataFrame (as trackintel datamodels)
+        The source to perform the split
+
+    granularity : {'day', 'hour'}, default 'day'
+        The criteria of spliting. "day" splits records that have duration of several
+        days and "hour" splits records that have duration of several hours.
+
+    Returns
+    -------
+    GeoDataFrame (as trackintel datamodels)
+        The GeoDataFrame object after the spliting
+    """
+    df = source.copy()
+    change_flag = __get_split_index(df, granularity=granularity)
+
+    # Iteratively split one day/hour from multi day/hour entries until no entry spans over multiple days/hours
+    while change_flag.sum() > 0:
+
+        # calculate new finished_at timestamp (1sec before midnight)
+        finished_at_temp = df.loc[change_flag, "finished_at"].copy()
+        if granularity == "day":
+            df.loc[change_flag, "finished_at"] = df.loc[change_flag, "started_at"].apply(
+                lambda x: x.replace(hour=23, minute=59, second=59)
+            )
+        elif granularity == "hour":
+            df.loc[change_flag, "finished_at"] = df.loc[change_flag, "started_at"].apply(
+                lambda x: x.replace(minute=59, second=59)
+            )
+
+        # create new entries with remaining timestamp
+        new_df = df.loc[change_flag].copy()
+        new_df.loc[change_flag, "started_at"] = df.loc[change_flag, "finished_at"] + datetime.timedelta(seconds=1)
+        new_df.loc[change_flag, "finished_at"] = finished_at_temp
+
+        df = df.append(new_df, ignore_index=True, sort=True)
+
+        change_flag = __get_split_index(df, granularity=granularity)
+
+    return df
+
+
+def __get_split_index(df, granularity="day"):
+    """
+    Get the index that needs to be splitted.
+
+    Parameters
+    ----------
+    df : GeoDataFrame (as trackintel datamodels)
+        The source to perform the split.
+
+    granularity : {'day', 'hour'}, default 'day'
+        The criteria of spliting. "day" splits records that have duration of several
+        days and "hour" splits records that have duration of several hours.
+
+    Returns
+    -------
+    change_flag: pd.Series
+        Boolean index indicating which records needs to be splitted
+    """
+    change_flag = df["started_at"].dt.date != df["finished_at"].dt.date
+    if granularity == "hour":
+        hour_flag = df["started_at"].dt.hour != df["finished_at"].dt.hour
+        # union of day and hour change flag
+        change_flag = change_flag | hour_flag
+
+    return change_flag
\ No newline at end of file
diff --git a/trackintel/io/file.py b/trackintel/io/file.py
index b3b3106..1a13139 100644
--- a/trackintel/io/file.py
+++ b/trackintel/io/file.py
@@ -38,7 +38,7 @@ def localize_timestamp(dt_series, pytz_tzinfo, col_name):
     return dt_series.apply(pd.Timestamp, tz=timezone)
 
 
-def read_positionfixes_csv(*args, **kwargs):
+def read_positionfixes_csv(*args, columns=None, tz=None, index_col=object(), crs=None, **kwargs):
     """Wraps the pandas read_csv function, extracts longitude and latitude and
     builds a geopandas GeoDataFrame. This also validates that the ingested data
     conforms to the trackintel understanding of positionfixes (see
@@ -46,11 +46,11 @@ def read_positionfixes_csv(*args, **kwargs):
 
     Parameters
     ----------
-    columns : dict
+    columns : dict, optional
         The column names to rename in the format {'old_name':'trackintel_standard_name'}.
-    tz : str
+    tz : str, optional
         pytz compatible timezone string. If None UTC is assumed.
-    index_col : str
+    index_col : str, optional
         column name to be used as index. If None the default index is assumed
         as unique identifier.
     crs: pyproj.crs or str, optional
@@ -74,14 +74,14 @@ def read_positionfixes_csv(*args, **kwargs):
     >>> trackintel.read_positionfixes_csv('data.csv')
     >>> trackintel.read_positionfixes_csv('data.csv', columns={'time':'tracked_at', 'User':'user_id'})
     """
-    columns = kwargs.pop('columns', {})
-    tz = kwargs.pop('tz', None)
-    crs = kwargs.pop('crs', None)
-    
+    columns = {} if columns is None else columns
+
     # Warning if no 'index_col' parameter is provided
-    if 'index_col' not in kwargs:
+    if type(index_col) == object:
         warnings.warn("Assuming default index as unique identifier. Pass 'index_col=None' as explicit" +
                       "argument to avoid a warning when reading csv files.")
+    elif index_col is not None:
+        kwargs['index_col'] = index_col
 
     df = pd.read_csv(*args, **kwargs)
     df = df.rename(columns=columns)
@@ -125,7 +125,7 @@ def write_positionfixes_csv(positionfixes, filename, *args, **kwargs):
     gdf.to_csv(filename, index=True, *args, **kwargs)
 
 
-def read_triplegs_csv(*args, **kwargs):
+def read_triplegs_csv(*args, columns=None, tz=None, index_col=object(), crs=None, **kwargs):
     """Wraps the pandas read_csv function, extracts a WKT for the leg geometry and
     builds a geopandas GeoDataFrame. This also validates that the ingested data
     conforms to the trackintel understanding of triplegs (see :doc:`/modules/model`).
@@ -133,11 +133,11 @@ def read_triplegs_csv(*args, **kwargs):
 
     Parameters
     ----------
-    columns : dict
+    columns : dict, optional
         The column names to rename in the format {'old_name':'trackintel_standard_name'}.
-    tz : str
+    tz : str, optional
         pytz compatible timezone string. If None UTC is assumed.
-    index_col : str
+    index_col : str, optional
         column name to be used as index. If None the default index is assumed 
         as unique identifier.
     crs: pyproj.crs or str, optional
@@ -155,15 +155,14 @@ def read_triplegs_csv(*args, **kwargs):
     >>> trackintel.read_triplegs_csv('data.csv')
     >>> trackintel.read_triplegs_csv('data.csv', columns={'start_time':'started_at', 'User':'user_id'})
     """
-
-    columns = kwargs.pop('columns', {})
-    tz = kwargs.pop('tz', None)
-    crs = kwargs.pop('crs', None)
+    columns = {} if columns is None else columns
 
     # Warning if no 'index_col' parameter is provided
-    if 'index_col' not in kwargs:
+    if type(index_col) == object:
         warnings.warn("Assuming default index as unique identifier. Pass 'index_col=None' as explicit" +
                       "argument to avoid a warning when reading csv files.")
+    elif index_col is not None:
+        kwargs['index_col'] = index_col
 
     df = pd.read_csv(*args, **kwargs)
     df = df.rename(columns=columns)
@@ -205,7 +204,7 @@ def write_triplegs_csv(triplegs, filename, *args, **kwargs):
     gdf.to_csv(filename, index=True, *args, **kwargs)
 
 
-def read_staypoints_csv(*args, **kwargs):
+def read_staypoints_csv(*args, columns=None, tz=None, index_col=object(), crs=None, **kwargs):
     """Wraps the pandas read_csv function, extracts a WKT for the staypoint 
     geometry and builds a geopandas GeoDataFrame. This also validates that 
     the ingested data conforms to the trackintel understanding of staypoints 
@@ -213,11 +212,11 @@ def read_staypoints_csv(*args, **kwargs):
     
     Parameters
     ----------
-    columns : dict
+    columns : dict, optional
         The column names to rename in the format {'old_name':'trackintel_standard_name'}.
-    tz : str
+    tz : str, optional
         pytz compatible timezone string. If None UTC is assumed.
-    index_col : str
+    index_col : str, optional
         column name to be used as index. If None the default index is assumed 
         as unique identifier.
     crs: pyproj.crs or str, optional
@@ -235,15 +234,14 @@ def read_staypoints_csv(*args, **kwargs):
     >>> trackintel.read_staypoints_csv('data.csv')
     >>> trackintel.read_staypoints_csv('data.csv', columns={'start_time':'started_at', 'User':'user_id'})
     """
-
-    columns = kwargs.pop('columns', {})
-    tz = kwargs.pop('tz', None)
-    crs = kwargs.pop('crs', None)
+    columns = {} if columns is None else columns
     
     # Warning if no 'index_col' parameter is provided
-    if 'index_col' not in kwargs:
+    if type(index_col) == object:
         warnings.warn("Assuming default index as unique identifier. Pass 'index_col=None' as explicit" +
                       "argument to avoid a warning when reading csv files.")
+    elif index_col is not None:
+        kwargs['index_col'] = index_col
 
     df = pd.read_csv(*args, **kwargs)
     df = df.rename(columns=columns)
@@ -285,7 +283,7 @@ def write_staypoints_csv(staypoints, filename, *args, **kwargs):
     gdf.to_csv(filename, index=True, *args, **kwargs)
 
 
-def read_locations_csv(*args, **kwargs):
+def read_locations_csv(*args, columns=None, index_col=object(), crs=None, **kwargs):
     """Wraps the pandas read_csv function, extracts a WKT for the location 
     center (and extent) and builds a geopandas GeoDataFrame. This also 
     validates that the ingested data conforms to the trackintel understanding 
@@ -293,9 +291,9 @@ def read_locations_csv(*args, **kwargs):
 
     Parameters
     ----------
-    columns : dict
+    columns : dict, optional
         The column names to rename in the format {'old_name':'trackintel_standard_name'}.
-    index_col : str
+    index_col : str, optional
         column name to be used as index. If None the default index is assumed
         as unique identifier.
     crs: pyproj.crs or str, optional
@@ -313,13 +311,14 @@ def read_locations_csv(*args, **kwargs):
     >>> trackintel.read_locations_csv('data.csv')
     >>> trackintel.read_locations_csv('data.csv', columns={'start_time':'started_at', 'User':'user_id'})
     """
-    columns = kwargs.pop('columns', {})
-    crs = kwargs.pop('crs', None)
+    columns = {} if columns is None else columns
     
     # Warning if no 'index_col' parameter is provided
-    if 'index_col' not in kwargs:
+    if type(index_col) == object:
         warnings.warn("Assuming default index as unique identifier. Pass 'index_col=None' as explicit" +
                       "argument to avoid a warning when reading csv files.")
+    elif index_col is not None:
+        kwargs['index_col'] = index_col
     
     df = pd.read_csv(*args, **kwargs)
     df = df.rename(columns=columns)
@@ -352,18 +351,18 @@ def write_locations_csv(locations, filename, *args, **kwargs):
     gdf.to_csv(filename, index=True, *args, **kwargs)
 
 
-def read_trips_csv(*args, **kwargs):
+def read_trips_csv(*args, columns=None, tz=None, index_col=object(), **kwargs):
     """Wraps the pandas read_csv function and extracts proper datetimes. This also
     validates that the ingested data conforms to the trackintel understanding 
     of trips (see :doc:`/modules/model`).
     
     Parameters
     ----------
-    columns : dict
+    columns : dict, optional
         The column names to rename in the format {'old_name':'trackintel_standard_name'}.
-    tz : str
+    tz : str, optional
         pytz compatible timezone string. If None UTC is assumed.
-    index_col : str
+    index_col : str, optional
         column name to be used as index. If None the default index is assumed 
         as unique identifier.
         
@@ -378,13 +377,13 @@ def read_trips_csv(*args, **kwargs):
     >>> trackintel.read_trips_csv('data.csv')
     >>> trackintel.read_trips_csv('data.csv', columns={'start_time':'started_at', 'User':'user_id'})
     """
+    columns = {} if columns is None else columns
 
-    columns = kwargs.pop('columns', {})
-    tz = kwargs.pop('tz', None)
-    
-    if 'index_col' not in kwargs:
+    if type(index_col) == object:
         warnings.warn("Assuming default index as unique identifier. Pass 'index_col=None' as explicit" +
                       "argument to avoid a warning when reading csv files.")
+    elif index_col is not None:
+        kwargs['index_col'] = index_col
     
     df = pd.read_csv(*args, **kwargs)
     df = df.rename(columns=columns)
@@ -419,14 +418,14 @@ def write_trips_csv(trips, filename, *args, **kwargs):
     df.to_csv(filename, index=True, *args, **kwargs)
 
 
-def read_tours_csv(*args, **kwargs):
+def read_tours_csv(*args, columns=None, tz=None, **kwargs):
     """Wraps the pandas read_csv function and extracts proper datetimes. This also
     validates that the ingested data conforms to the trackintel understanding 
     of tours (see :doc:`/modules/model`).
 
-    columns : dict
+    columns : dict, optional
         The column names to rename in the format {'old_name':'trackintel_standard_name'}.
-    tz : str
+    tz : str, optional
         pytz compatible timezone string. If None UTC is assumed.
 
     Returns
diff --git a/trackintel/model/positionfixes.py b/trackintel/model/positionfixes.py
index 6cb3639..9d8bf05 100644
--- a/trackintel/model/positionfixes.py
+++ b/trackintel/model/positionfixes.py
@@ -16,7 +16,7 @@ class PositionfixesAccessor(object):
     of the `Positionfixes`
 
     For several usecases, the following additional columns are required:
-    ``['elevation', 'accuracy', 'tracking_tech', 'context']``
+    ``['elevation', 'accuracy', 'tracking_tech', 'context', 'staypoint_id', 'tripleg_id']``
 
     Notes
     -------
diff --git a/trackintel/model/staypoints.py b/trackintel/model/staypoints.py
index 733c1d3..f1cf93b 100644
--- a/trackintel/model/staypoints.py
+++ b/trackintel/model/staypoints.py
@@ -16,7 +16,8 @@ class StaypointsAccessor(object):
     of the `Staypoints`
 
     For several usecases, the following additional columns are required:
-    ``['elevation', 'radius', 'context', 'purpose', 'activity', 'next_trip_id', 'prev_trip_id', 'trip_id']``
+    ``['elevation', 'radius', 'context', 'purpose', 'activity', 'next_trip_id', 'prev_trip_id', 'trip_id',
+    location_id]``
 
     Notes
     -------
diff --git a/trackintel/model/tours.py b/trackintel/model/tours.py
index 378809b..3e06beb 100644
--- a/trackintel/model/tours.py
+++ b/trackintel/model/tours.py
@@ -6,7 +6,7 @@ class ToursAccessor(object):
     """A pandas accessor to treat DataFrames as collections of tours. 
 
     Requires at least the following columns: 
-    ``['user_id', 'started_at', 'finished_at', 'origin_destination_location_id', 'journey']``
+    ``['user_id', 'started_at', 'finished_at', 'origin_staypoint_id', 'journey']``
 
     The ``index`` of the GeoDataFrame will be treated as unique identifier of the `trips`
 
diff --git a/trackintel/model/trips.py b/trackintel/model/trips.py
index 8d9f712..ed1cec7 100644
--- a/trackintel/model/trips.py
+++ b/trackintel/model/trips.py
@@ -16,7 +16,7 @@ class TripsAccessor(object):
     The ``index`` of the GeoDataFrame will be treated as unique identifier of the `trips`
 
     For several usecases, the following additional columns are required:
-    ``['context', 'origin_activity', 'destination_activity', 'modes', 'primary_mode']``
+    ``['context', 'origin_activity', 'destination_activity', 'modes', 'primary_mode', 'tour_id']``
 
     Notes
     -----
diff --git a/trackintel/visualization/locations.py b/trackintel/visualization/locations.py
index 20d742f..072b2ea 100644
--- a/trackintel/visualization/locations.py
+++ b/trackintel/visualization/locations.py
@@ -4,7 +4,7 @@ import warnings
 
 from trackintel.geogr.distances import meters_to_decimal_degrees
 from trackintel.visualization.osm import plot_osm_streets
-from trackintel.visualization.util import regular_figure, save_fig
+from trackintel.visualization.util import regular_figure, save_fig, transform_gdf_to_wgs84
 
 
 def plot_center_of_locations(locations, out_filename=None, radius=None, positionfixes=None, 
@@ -45,13 +45,7 @@ def plot_center_of_locations(locations, out_filename=None, radius=None, position
         _, ax = regular_figure()
     else:
         ax = axis
-
-    crs_wgs84 = 'EPSG:4326'
-    if locations.crs is None:
-        warnings.warn("Coordinate System (CRS) is not set, default to WGS84.")
-        locations.crs = crs_wgs84
-    elif locations.crs != crs_wgs84:
-        locations = locations.to_crs(crs_wgs84)
+    locations = transform_gdf_to_wgs84(locations)
 
     if staypoints is not None:
         staypoints.as_staypoints.plot(radius=staypoints_radius,
diff --git a/trackintel/visualization/positionfixes.py b/trackintel/visualization/positionfixes.py
index 1b10a67..7e5d57e 100644
--- a/trackintel/visualization/positionfixes.py
+++ b/trackintel/visualization/positionfixes.py
@@ -1,7 +1,7 @@
 import matplotlib.pyplot as plt
 import warnings
 from trackintel.visualization.osm import plot_osm_streets
-from trackintel.visualization.util import regular_figure, save_fig
+from trackintel.visualization.util import regular_figure, save_fig, transform_gdf_to_wgs84
 
 
 def plot_positionfixes(positionfixes, out_filename=None, plot_osm=False, axis=None):
@@ -33,13 +33,7 @@ def plot_positionfixes(positionfixes, out_filename=None, plot_osm=False, axis=No
         _, ax = regular_figure()
     else:
         ax = axis
-
-    crs_wgs84 = 'EPSG:4326'
-    if positionfixes.crs is None:
-        warnings.warn("Coordinate System (CRS) is not set, default to WGS84.")
-        positionfixes.crs = crs_wgs84
-    elif positionfixes.crs != crs_wgs84:
-        positionfixes = positionfixes.to_crs(crs_wgs84)
+    positionfixes = transform_gdf_to_wgs84(positionfixes)
 
     if plot_osm:
         west = positionfixes.geometry.x.min()
diff --git a/trackintel/visualization/staypoints.py b/trackintel/visualization/staypoints.py
index a88e859..99fa511 100644
--- a/trackintel/visualization/staypoints.py
+++ b/trackintel/visualization/staypoints.py
@@ -4,7 +4,7 @@ import warnings
 
 from trackintel.geogr.distances import meters_to_decimal_degrees
 from trackintel.visualization.osm import plot_osm_streets
-from trackintel.visualization.util import regular_figure, save_fig
+from trackintel.visualization.util import regular_figure, save_fig, transform_gdf_to_wgs84
 
 
 def plot_staypoints(staypoints, out_filename=None, radius=None, positionfixes=None, plot_osm=False, axis=None):
@@ -42,13 +42,7 @@ def plot_staypoints(staypoints, out_filename=None, radius=None, positionfixes=No
     else:
         ax = axis
     name_geocol = staypoints.geometry.name
-
-    crs_wgs84 = 'EPSG:4326'
-    if staypoints.crs is None:
-        warnings.warn("Coordinate System (CRS) is not set, default to WGS84.")
-        staypoints.crs = crs_wgs84
-    elif staypoints.crs != crs_wgs84:
-        staypoints = staypoints.to_crs(crs_wgs84)
+    staypoints = transform_gdf_to_wgs84(staypoints)
 
     if positionfixes is not None:
         positionfixes.as_positionfixes.plot(plot_osm=plot_osm, axis=ax)
diff --git a/trackintel/visualization/triplegs.py b/trackintel/visualization/triplegs.py
index 98e2cc9..ca249a4 100644
--- a/trackintel/visualization/triplegs.py
+++ b/trackintel/visualization/triplegs.py
@@ -3,7 +3,7 @@ import matplotlib.pyplot as plt
 import warnings
 
 from trackintel.visualization.osm import plot_osm_streets
-from trackintel.visualization.util import regular_figure, save_fig
+from trackintel.visualization.util import regular_figure, save_fig, transform_gdf_to_wgs84
 
 
 def plot_triplegs(triplegs, out_filename=None, positionfixes=None, staypoints=None, 
@@ -39,13 +39,7 @@ def plot_triplegs(triplegs, out_filename=None, positionfixes=None, staypoints=No
         _, ax = regular_figure()
     else:
         ax = axis
-
-    crs_wgs84 = 'EPSG:4326'
-    if triplegs.crs is None:
-        warnings.warn("Coordinate System (CRS) is not set, default to WGS84.")
-        triplegs.crs = crs_wgs84
-    elif triplegs.crs != crs_wgs84:
-        triplegs = triplegs.to_crs(crs_wgs84)
+    triplegs = transform_gdf_to_wgs84(triplegs)
 
     if staypoints is not None:
         staypoints.as_staypoints.plot(radius=staypoints_radius,
diff --git a/trackintel/visualization/util.py b/trackintel/visualization/util.py
index 851531b..5a370d6 100644
--- a/trackintel/visualization/util.py
+++ b/trackintel/visualization/util.py
@@ -1,5 +1,6 @@
 import time
 import logging
+import warnings
 
 import matplotlib
 import matplotlib.pyplot as plt
@@ -118,3 +119,25 @@ def save_fig(out_filename, tight='tight', formats=['png', 'pdf']):
         logging.info("...took {} s!".format(round(time.time() - ts, 2)))
     plt.close()
     logging.info("Finished!")
+
+
+def transform_gdf_to_wgs84(gdf):
+    """Transforms a GeoDataFrame into WGS84.
+
+    Additionally checks if data has CRS or is already in WGS84.
+
+    Parameters
+    ----------
+    gdf : GeoDataFrame
+
+    Example
+    -------
+    >>> gdf = transform_gdf_to_wgs84(gdf)
+    """
+    crs_wgs84 = 'EPSG:4326'
+    if gdf.crs is None:
+        warnings.warn("Coordinate System (CRS) is not set, default to WGS84.")
+        gdf.crs = crs_wgs84
+    elif gdf.crs != crs_wgs84:
+        gdf = gdf.to_crs(crs_wgs84)
+    return gdf


Test patch:
diff --git a/tests/analysis/test_tracking_quality.py b/tests/analysis/test_tracking_quality.py
new file mode 100644
index 0000000..aa38467
--- /dev/null
+++ b/tests/analysis/test_tracking_quality.py
@@ -0,0 +1,142 @@
+import os
+import pytest
+
+import trackintel as ti
+
+
+@pytest.fixture
+def testdata_stps_tpls_geolife_long():
+    """Generate stps and tpls sequences of the original pfs for subsequent testing."""
+    pfs = ti.io.dataset_reader.read_geolife(os.path.join("tests", "data", "geolife_long"))
+    pfs, stps = pfs.as_positionfixes.generate_staypoints(method="sliding", dist_threshold=25, time_threshold=5 * 60)
+    pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps, method="between_staypoints")
+
+    tpls["type"] = "tripleg"
+    stps["type"] = "staypoint"
+    stps_tpls = stps.append(tpls, ignore_index=True).sort_values(by="started_at")
+    return stps_tpls
+
+
+class TestTemporal_tracking_quality:
+    """Tests for the temporal_tracking_quality() function and its subfunctions."""
+
+    def test_tracking_quality_all(self, testdata_stps_tpls_geolife_long):
+        """Test if the calculated total tracking quality is correct."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+
+        # calculate tracking quality for a sample user
+        user_0 = stps_tpls.loc[stps_tpls["user_id"] == 0]
+        extent = (user_0["finished_at"].max() - user_0["started_at"].min()).total_seconds()
+        tracked = (user_0["finished_at"] - user_0["started_at"]).dt.total_seconds().sum()
+        quality_manual = tracked / extent
+
+        # test if the result of the user agrees
+        quality = ti.analysis.tracking_quality.temporal_tracking_quality(stps_tpls, granularity="all")
+
+        assert quality_manual == quality.loc[quality["user_id"] == 0, "quality"].values[0]
+
+    def test_tracking_quality_day(self, testdata_stps_tpls_geolife_long):
+        """Test if the calculated tracking quality per day is correct."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+        splitted_records = ti.analysis.tracking_quality._split_overlaps(stps_tpls, granularity="day")
+
+        # get the day relative to the start day
+        start_date = splitted_records["started_at"].min().date()
+        splitted_records["day"] = splitted_records["started_at"].apply(lambda x: (x.date() - start_date).days)
+        # calculate tracking quality of the first day for the first user
+        user_0 = splitted_records.loc[(splitted_records["user_id"] == 0) & (splitted_records["day"] == 0)]
+        extent = 60 * 60 * 24
+        tracked = (user_0["finished_at"] - user_0["started_at"]).dt.total_seconds().sum()
+        quality_manual = tracked / extent
+        # test if the result of the user agrees
+        quality = ti.analysis.tracking_quality.temporal_tracking_quality(stps_tpls, granularity="day")
+
+        assert quality_manual == quality.loc[(quality["user_id"] == 0) & (quality["day"] == 0), "quality"].values[0]
+
+    def test_tracking_quality_hour(self, testdata_stps_tpls_geolife_long):
+        """Test if the calculated tracking quality per hour is correct."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+
+        splitted = ti.analysis.tracking_quality._split_overlaps(stps_tpls, granularity="hour")
+
+        # get the day relative to the start day
+        start_date = splitted["started_at"].min().date()
+        splitted["day"] = splitted["started_at"].apply(lambda x: (x.date() - start_date).days)
+        # get the hour of the record
+        splitted["hour"] = splitted["started_at"].dt.hour
+
+        # calculate tracking quality of an hour for the first user
+        user_0 = splitted.loc[(splitted["user_id"] == 0) & (splitted["hour"] == 2)]
+        extent = (60 * 60) * len(user_0["day"].unique())
+        tracked = (user_0["finished_at"] - user_0["started_at"]).dt.total_seconds().sum()
+        quality_manual = tracked / extent
+
+        # test if the result of the user agrees
+        quality = ti.analysis.tracking_quality.temporal_tracking_quality(stps_tpls, granularity="hour")
+
+        assert quality_manual == quality.loc[(quality["user_id"] == 0) & (quality["hour"] == 2), "quality"].values[0]
+
+    def test_tracking_quality_error(self, testdata_stps_tpls_geolife_long):
+        """Test if the an error is raised when passing unknown 'granularity'."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+
+        with pytest.raises(AttributeError):
+            ti.analysis.tracking_quality.temporal_tracking_quality(stps_tpls, granularity=12345)
+        with pytest.raises(AttributeError):
+            ti.analysis.tracking_quality.temporal_tracking_quality(stps_tpls, granularity="random")
+
+    def test_split_overlaps_days(self, testdata_stps_tpls_geolife_long):
+        """Test if _split_overlaps() function can split records that span several days."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+
+        # some of the records span several day
+        multi_day_records = stps_tpls["finished_at"].dt.day - stps_tpls["started_at"].dt.day
+        assert (multi_day_records > 0).any()
+
+        # split the records according to day
+        stps_tpls.reset_index(inplace=True)
+        splitted = ti.analysis.tracking_quality._split_overlaps(stps_tpls, granularity="day")
+
+        # no record spans several days after the split
+        multi_day_records = splitted["finished_at"].dt.day - splitted["started_at"].dt.day
+        assert (multi_day_records == 0).all()
+
+    def test_split_overlaps_hours(self, testdata_stps_tpls_geolife_long):
+        """Test if _split_overlaps() function can split records that span several hours."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+
+        # some of the records span several hours
+        hour_diff = stps_tpls["finished_at"].dt.hour - stps_tpls["started_at"].dt.hour
+        assert (hour_diff > 0).any()
+
+        # split the records according to hour
+        stps_tpls.reset_index(inplace=True)
+        splitted = ti.analysis.tracking_quality._split_overlaps(stps_tpls, granularity="hour")
+
+        # no record spans several hours after the split
+        hour_diff = splitted["finished_at"].dt.hour - splitted["started_at"].dt.hour
+        assert (hour_diff == 0).all()
+
+    def test_split_overlaps_hours_case2(self, testdata_stps_tpls_geolife_long):
+        """Test if _split_overlaps() function can split record that have the same hour but different days."""
+        stps_tpls = testdata_stps_tpls_geolife_long
+
+        # get the first two records
+        head2 = stps_tpls.head(2).copy()
+        # construct the finished_at exactly one day after started_at
+        head2["finished_at"] = head2.apply(lambda x: x["started_at"].replace(day=x["started_at"].day + 1), axis=1)
+
+        # the records have the same hour
+        hour_diff = head2["finished_at"].dt.hour - head2["started_at"].dt.hour
+        assert (hour_diff == 0).all()
+        # but have different days
+        day_diff = head2["finished_at"].dt.day - head2["started_at"].dt.day
+        assert (day_diff > 0).all()
+
+        # split the records according to hour
+        head2.reset_index(inplace=True)
+        splitted = ti.analysis.tracking_quality._split_overlaps(head2, granularity="hour")
+
+        # no record has different days after the split
+        day_diff = splitted["finished_at"].dt.day - splitted["started_at"].dt.day
+        assert (day_diff == 0).all()
\ No newline at end of file
diff --git a/tests/io/test_file.py b/tests/io/test_file.py
index 856990e..613506c 100644
--- a/tests/io/test_file.py
+++ b/tests/io/test_file.py
@@ -36,7 +36,16 @@ class TestFile:
         file = os.path.join('tests', 'data', 'positionfixes.csv')
         with pytest.warns(UserWarning):
             ti.read_positionfixes_csv(file, sep=';')
-        
+
+    def test_positionfixes_csv_index_col(self):
+        """Test if `index_col` can be set."""
+        file = os.path.join('tests', 'data', 'positionfixes.csv')
+        ind_name = 'id'
+        pfs = ti.read_positionfixes_csv(file, sep=";", index_col=ind_name)
+        assert pfs.index.name == ind_name
+        pfs = ti.read_positionfixes_csv(file, sep=";", index_col=None)
+        assert pfs.index.name is None
+
     def test_positionfixes_from_to_postgis(self):
         # TODO Implement some tests for PostGIS.
         pass
@@ -73,6 +82,15 @@ class TestFile:
         with pytest.warns(UserWarning):
             ti.read_triplegs_csv(file, sep=';')
 
+    def test_triplegs_csv_index_col(self):
+        """Test if `index_col` can be set."""
+        file = os.path.join('tests', 'data', 'triplegs.csv')
+        ind_name = 'id'
+        pfs = ti.read_triplegs_csv(file, sep=";", index_col=ind_name)
+        assert pfs.index.name == ind_name
+        pfs = ti.read_triplegs_csv(file, sep=";", index_col=None)
+        assert pfs.index.name is None
+
     def test_triplegs_from_to_postgis(self):
         # TODO Implement some tests for PostGIS.
         pass
@@ -106,6 +124,15 @@ class TestFile:
         with pytest.warns(UserWarning):
             ti.read_staypoints_csv(file, sep=';')
 
+    def test_staypoints_csv_index_col(self):
+        """Test if `index_col` can be set."""
+        file = os.path.join('tests', 'data', 'staypoints.csv')
+        ind_name = 'id'
+        pfs = ti.read_staypoints_csv(file, sep=";", index_col=ind_name)
+        assert pfs.index.name == ind_name
+        pfs = ti.read_staypoints_csv(file, sep=";", index_col=None)
+        assert pfs.index.name is None
+
     def test_staypoints_from_to_postgis(self):
         # TODO Implement some tests for PostGIS.
         pass
@@ -134,7 +161,15 @@ class TestFile:
         file = os.path.join('tests', 'data', 'locations.csv')
         with pytest.warns(UserWarning):
             ti.read_locations_csv(file, sep=';')
-        
+
+    def test_locations_csv_index_col(self):
+        file = os.path.join('tests', 'data', 'locations.csv')
+        ind_name = 'id'
+        pfs = ti.read_locations_csv(file, sep=";", index_col=ind_name)
+        assert pfs.index.name == ind_name
+        pfs = ti.read_locations_csv(file, sep=";", index_col=None)
+        assert pfs.index.name is None
+
     def test_locations_from_to_postgis(self):
         # TODO Implement some tests for PostGIS.
         pass
@@ -160,7 +195,16 @@ class TestFile:
         file = os.path.join('tests', 'data', 'trips.csv')
         with pytest.warns(UserWarning):
             ti.read_trips_csv(file, sep=';')
-            
+
+    def test_trips_csv_index_col(self):
+        """Test if `index_col` can be set."""
+        file = os.path.join('tests', 'data', 'trips.csv')
+        ind_name = 'id'
+        gdf = ti.read_trips_csv(file, sep=";", index_col=ind_name)
+        assert gdf.index.name == ind_name
+        gdf = ti.read_trips_csv(file, sep=";", index_col=None)
+        assert gdf.index.name is None
+
     def test_trips_from_to_postgis(self):
         # TODO Implement some tests for PostGIS.
         pass
diff --git a/tests/test_visualization.py b/tests/test_visualization.py
index cea364f..df67b96 100644
--- a/tests/test_visualization.py
+++ b/tests/test_visualization.py
@@ -13,7 +13,7 @@ class TestIO:
 
         tmp_file = os.path.join('tests', 'data', 'positionfixes_plot.png')
         pfs_file = os.path.join('tests', 'data', 'positionfixes.csv')
-        pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id')
+        pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id', crs='EPSG:4326')
         pfs.as_positionfixes.plot(out_filename=tmp_file, plot_osm=False)
         assert os.path.exists(tmp_file)
         os.remove(tmp_file)
@@ -23,10 +23,10 @@ class TestIO:
 
         tmp_file = os.path.join('tests', 'data', 'triplegs_plot.png')
         pfs_file = os.path.join('tests', 'data', 'positionfixes.csv')
-        pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id')
+        pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id', crs='EPSG:4326')
 
         tpls_file = os.path.join('tests', 'data', 'triplegs.csv')
-        tpls = ti.read_triplegs_csv(tpls_file, sep=';', index_col='id')
+        tpls = ti.read_triplegs_csv(tpls_file, sep=';', index_col='id', crs='EPSG:4326')
         tpls.as_triplegs.plot(out_filename=tmp_file, positionfixes=pfs, plot_osm=False)
         assert os.path.exists(tmp_file)
         os.remove(tmp_file)
@@ -36,10 +36,10 @@ class TestIO:
 
         tmp_file = os.path.join('tests', 'data', 'staypoints_plot.png')
         pfs_file = os.path.join('tests', 'data', 'positionfixes.csv')
-        pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id')
+        pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id', crs='EPSG:4326')
 
         stps_file = os.path.join('tests', 'data', 'staypoints.csv')
-        stps = ti.read_staypoints_csv(stps_file, sep=';', index_col='id')
+        stps = ti.read_staypoints_csv(stps_file, sep=';', index_col='id', crs='EPSG:4326')
         stps.as_staypoints.plot(out_filename=tmp_file, radius=0.01, positionfixes=pfs, plot_osm=False)
         assert os.path.exists(tmp_file)
         os.remove(tmp_file)
@@ -48,46 +48,13 @@ class TestIO:
         """Use trackintel visualization function to plot locations and check if file exists."""
 
         tmp_file = os.path.join("tests", "data", "locations_plot.png")
-        pfs = pfs = ti.read_positionfixes_csv('tests/data/positionfixes.csv', sep=';')
-        stps = ti.read_staypoints_csv('tests/data/staypoints.csv', sep=';')
-        locs = ti.read_locations_csv('tests/data/locations.csv', sep=';')
+        pfs_file = os.path.join('tests', 'data', 'positionfixes.csv')
+        pfs = pfs = ti.read_positionfixes_csv(pfs_file, sep=';', index_col='id', crs='EPSG:4326')
+        stps_file = os.path.join('tests', 'data', 'staypoints.csv')
+        stps = ti.read_staypoints_csv(stps_file, sep=';', index_col='id', crs='EPSG:4326')
+        locs_file = os.path.join('tests', 'data', 'locations.csv')
+        locs = ti.read_locations_csv(locs_file, sep=';', index_col='id', crs='EPSG:4326')
         locs.as_locations.plot(out_filename=tmp_file, radius=120, positionfixes=pfs,
                                staypoints=stps, staypoints_radius=100, plot_osm=False)
         assert os.path.exists(tmp_file)
         os.remove(tmp_file)
-
-    def test_positionfixes_crs_warning(self):
-        """Check if warning is raised for data without crs."""
-
-        file = os.path.join('tests', 'data', 'positionfixes.csv')
-        _, ax = mpl.pyplot.subplots()
-        pfs = ti.read_positionfixes_csv(file, sep=';')
-        with pytest.warns(UserWarning):
-            pfs.as_positionfixes.plot(axis=ax)
-
-    def test_staypoints_crs_warning(self):
-        """Check if warning is raised for data without crs."""
-
-        file = os.path.join('tests', 'data', 'staypoints.csv')
-        _, ax = mpl.pyplot.subplots()
-        pfs = ti.read_staypoints_csv(file, sep=';')
-        with pytest.warns(UserWarning):
-            pfs.as_staypoints.plot(axis=ax)
-
-    def test_triplegs_crs_warning(self):
-        """Check if warning is raised for data without crs."""
-
-        file = os.path.join('tests', 'data', 'triplegs.csv')
-        _, ax = mpl.pyplot.subplots()
-        pfs = ti.read_triplegs_csv(file, sep=';')
-        with pytest.warns(UserWarning):
-            pfs.as_triplegs.plot(axis=ax)
-
-    def test_locations_crs_warning(self):
-        """Check if warning is raised for data without crs."""
-
-        file = os.path.join('tests', 'data', 'locations.csv')
-        _, ax = mpl.pyplot.subplots()
-        pfs = ti.read_locations_csv(file, sep=';')
-        with pytest.warns(UserWarning):
-            pfs.as_locations.plot(axis=ax)
diff --git a/tests/visualization/__init__.py b/tests/visualization/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/visualization/test_util.py b/tests/visualization/test_util.py
new file mode 100644
index 0000000..7f24588
--- /dev/null
+++ b/tests/visualization/test_util.py
@@ -0,0 +1,24 @@
+import os
+import pytest
+from geopandas.testing import assert_geodataframe_equal
+
+import trackintel as ti
+
+
+class TestTransform_gdf_to_wgs84:
+    def test_transformation(self):
+        """Check if data gets transformed."""
+
+        file = os.path.join('tests', 'data', 'positionfixes.csv')
+        pfs = ti.read_positionfixes_csv(file, sep=';', crs='EPSG:4326', index_col=None)
+        pfs_2056 = pfs.to_crs("EPSG:2056")
+        pfs_4326 = ti.visualization.util.transform_gdf_to_wgs84(pfs_2056)
+        assert_geodataframe_equal(pfs, pfs_4326, check_less_precise=True)
+
+    def test_crs_warning(self):
+        """Check if warning is raised for data without crs."""
+
+        file = os.path.join('tests', 'data', 'positionfixes.csv')
+        pfs = ti.read_positionfixes_csv(file, sep=';', crs=None, index_col=None)
+        with pytest.warns(UserWarning):
+            ti.visualization.util.transform_gdf_to_wgs84(pfs)