Repository: mie-lab/trackintel

Issue:
Implement a generate tour function 
Tours represent an integral part of the trackintel data model. However they are not supported yet. A core functionality that we should support is to be able to create tours from existing trips.

A tour is defined as the collection of all consecutive **trips** that are used to return to the starting location. Tours can be nested and a tour that starts and ends at home is called a home-based tour or a journey.
Here is an image that can hopefully provide some clarification.
![grafik](https://user-images.githubusercontent.com/31069329/124736389-3fa5bb80-df17-11eb-879c-dcb2ae1dfcca.png)


Discussion:

Patch:
diff --git a/docs/modules/io.rst b/docs/modules/io.rst
index d557b4e..1483fd6 100644
--- a/docs/modules/io.rst
+++ b/docs/modules/io.rst
@@ -30,6 +30,8 @@ CSV File Import
 
 .. autofunction:: trackintel.io.file.read_trips_csv
 
+.. autofunction:: trackintel.io.file.read_tours_csv
+
 GeoDataFrame Import
 =============================
 
@@ -70,6 +72,8 @@ CSV File Export
 
 .. autofunction:: trackintel.io.file.write_trips_csv
 
+.. autofunction:: trackintel.io.file.write_tours_csv
+
 PostGIS Export
 ==============
 
diff --git a/docs/modules/model.rst b/docs/modules/model.rst
index 0b779b9..ce54943 100644
--- a/docs/modules/model.rst
+++ b/docs/modules/model.rst
@@ -301,7 +301,7 @@ They are linked to a user::
         user_id integer NOT NULL,
 
         -- References to foreign tables.
-        origin_destination_location_id bigint,
+        location_id bigint,
 
         -- Temporal attributes.
         started_at timestamp with time zone NOT NULL,
diff --git a/docs/modules/preprocessing.rst b/docs/modules/preprocessing.rst
index 9829c1f..cbf76da 100644
--- a/docs/modules/preprocessing.rst
+++ b/docs/modules/preprocessing.rst
@@ -50,3 +50,18 @@ The function `generate_trips` follows this algorithm:
 .. image:: /_static/tripalgorithm.png
    :scale: 100 %
    :align: center
+
+Trips
+========
+
+Trips denote the sequence of all triplegs between two consecutive activities. These can be composed of multiple means
+of transports. A further aggregation of Trips are Tours, which is a sequence of trips such that it starts and ends
+at the same location. Using the trips, we can generate tours.
+
+.. autofunction:: trackintel.preprocessing.trips.generate_tours
+
+Trips and Tours have an n:n relationship: One tour consists of multiple trips, but due to nested or overlapping tours,
+one trip can also be part of mulitple tours. A helper function can be used to get the trips grouped by tour.
+
+.. autofunction:: trackintel.preprocessing.trips.get_trips_grouped
+
diff --git a/sql/create_tables_pg.sql b/sql/create_tables_pg.sql
index 8572745..4e1ce7d 100644
--- a/sql/create_tables_pg.sql
+++ b/sql/create_tables_pg.sql
@@ -163,14 +163,12 @@ CREATE TABLE tours (
     user_id integer NOT NULL,
 
     -- References to foreign tables.
-    origin_destination_location_id bigint,
+    location_id bigint,
 
     -- Temporal attributes.
     started_at timestamp without time zone NOT NULL,
     finished_at timestamp without time zone NOT NULL,
     
-    -- Specific attributes.
-    journey bool,
     -- The context contains additional information that might be filled in by trackintel.
     -- This could include things such as the temperature, public transport stops in vicinity, etc.
     context json,
diff --git a/trackintel/__init__.py b/trackintel/__init__.py
index 9df718b..6fcd23b 100644
--- a/trackintel/__init__.py
+++ b/trackintel/__init__.py
@@ -8,6 +8,7 @@ from trackintel.io.file import read_triplegs_csv
 from trackintel.io.file import read_staypoints_csv
 from trackintel.io.file import read_locations_csv
 from trackintel.io.file import read_trips_csv
+from trackintel.io.file import read_tours_csv
 
 #
 from trackintel.__version__ import __version__
diff --git a/trackintel/io/__init__.py b/trackintel/io/__init__.py
index 7b78224..bc8260f 100644
--- a/trackintel/io/__init__.py
+++ b/trackintel/io/__init__.py
@@ -28,5 +28,8 @@ from .postgis import read_trips_postgis
 from .postgis import write_trips_postgis
 from .from_geopandas import read_trips_gpd
 
+from .file import read_tours_csv
+from .file import write_tours_csv
+
 from .dataset_reader import read_geolife
 from .dataset_reader import geolife_add_modes_to_triplegs
diff --git a/trackintel/io/file.py b/trackintel/io/file.py
index 1681a2e..098f374 100644
--- a/trackintel/io/file.py
+++ b/trackintel/io/file.py
@@ -510,7 +510,7 @@ def write_trips_csv(trips, filename, *args, **kwargs):
     df.to_csv(filename, index=True, *args, **kwargs)
 
 
-def read_tours_csv(*args, columns=None, tz=None, **kwargs):
+def read_tours_csv(*args, columns=None, index_col=object(), tz=None, **kwargs):
     """
     Read tours from csv file.
 
@@ -531,17 +531,31 @@ def read_tours_csv(*args, columns=None, tz=None, **kwargs):
     tours : DataFrame (as trackintel tours)
         A DataFrame containing the tours.
     """
-    # TODO: implement the reading function for tours
+    columns = {} if columns is None else columns
+
+    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
+
+    tours = pd.read_csv(*args, **kwargs)
+    tours = tours.rename(columns=columns)
+
+    # transform to datatime
+    tours["started_at"] = pd.to_datetime(tours["started_at"])
+    tours["finished_at"] = pd.to_datetime(tours["finished_at"])
+
+    # check and/or set timezone
+    for col in ["started_at", "finished_at"]:
+        if not pd.api.types.is_datetime64tz_dtype(tours[col]):
+            tours[col] = _localize_timestamp(dt_series=tours[col], pytz_tzinfo=tz, col_name=col)
 
-    # # check and/or set timezone
-    # for col in ['started_at', 'finished_at']:
-    #     if not pd.api.types.is_datetime64tz_dtype(df[col]):
-    #         df[col] = _localize_timestamp(dt_series=df[col], pytz_tzinfo=kwargs.pop('tz', None), col_name=col)
-    #         else:
-    #             # dateutil parser timezones are sometimes not compatible with pandas (e.g., in asserts)
-    #             tz = df[col].iloc[0].tzinfo.tzname(df[col].iloc[0])
-    #             df[col] = df[col].dt.tz_convert(tz)
-    pass
+    # assert validity of tours
+    tours.as_tours
+    return tours
 
 
 def write_tours_csv(tours, filename, *args, **kwargs):
@@ -558,8 +572,7 @@ def write_tours_csv(tours, filename, *args, **kwargs):
     filename : str
         The file to write to.
     """
-    # TODO: implement the writing function for tours
-    pass
+    tours.to_csv(filename, index=True, *args, **kwargs)
 
 
 def _localize_timestamp(dt_series, pytz_tzinfo, col_name):
diff --git a/trackintel/io/from_geopandas.py b/trackintel/io/from_geopandas.py
index 8fecdea..4d7cdd3 100644
--- a/trackintel/io/from_geopandas.py
+++ b/trackintel/io/from_geopandas.py
@@ -323,7 +323,7 @@ def read_tours_gpd(
     user_id="user_id",
     started_at="started_at",
     finished_at="finished_at",
-    origin_destination_location_id="origin_destination_location_id",
+    location_id="location_id",
     journey="journey",
     tz=None,
     mapper={},
@@ -347,7 +347,7 @@ def read_tours_gpd(
     finished_at : str, default 'finished_at'
         name of the column storing the endtime of the staypoints.
 
-    origin_destination_location_id : str, default 'origin_destination_location_id'
+    location_id : str, default 'location_id'
         the name of the column storing the id of the location where the tour starts and ends.
 
     journey : str, default 'journey'
@@ -364,7 +364,7 @@ def read_tours_gpd(
     # columns = {user_id: 'user_id',
     #            started_at: 'tracked_at',
     #            finished_at: 'finished_at',
-    #            origin_destination_location_id: 'origin_destination_location_id',
+    #            location_id: 'location_id',
     #            journey: 'journey'}
     # columns.update(mapper)
 
diff --git a/trackintel/model/tours.py b/trackintel/model/tours.py
index 010783e..bc11abd 100644
--- a/trackintel/model/tours.py
+++ b/trackintel/model/tours.py
@@ -1,4 +1,5 @@
 import pandas as pd
+import trackintel as ti
 
 
 @pd.api.extensions.register_dataframe_accessor("as_tours")
@@ -6,12 +7,12 @@ 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_staypoint_id', 'journey']
+    ['user_id', 'started_at', 'finished_at']
 
-    The 'index' of the GeoDataFrame will be treated as unique identifier of the `Tours`
+    The 'index' of the DataFrame will be treated as unique identifier of the `Tours`
 
     For several usecases, the following additional columns are required:
-    ['context']
+    ['location_id', 'journey', 'context', 'origin_staypoint_id', 'destination_staypoint_id']
 
     Notes
     -----
@@ -25,7 +26,7 @@ class ToursAccessor(object):
     >>> df.as_tours.plot()
     """
 
-    required_columns = ["user_id", "started_at", "finished_at", "origin_destination_location_id", "journey"]
+    required_columns = ["user_id", "started_at", "finished_at"]
 
     def __init__(self, pandas_obj):
         self._validate(pandas_obj)
@@ -54,7 +55,7 @@ class ToursAccessor(object):
 
         See :func:`trackintel.io.file.write_tours_csv`.
         """
-        raise NotImplementedError
+        ti.io.file.write_tours_csv(self._obj, filename, *args, **kwargs)
 
     def plot(self, *args, **kwargs):
         """
diff --git a/trackintel/model/trips.py b/trackintel/model/trips.py
index 3d57f22..6b35fde 100644
--- a/trackintel/model/trips.py
+++ b/trackintel/model/trips.py
@@ -111,3 +111,12 @@ class TripsAccessor(object):
         See :func:`trackintel.analysis.tracking_quality.temporal_tracking_quality`.
         """
         return ti.analysis.tracking_quality.temporal_tracking_quality(self._obj, *args, **kwargs)
+
+    def generate_tours(self, *args, **kwargs):
+        """
+        Generate tours based on trips (and optionally staypoint locations).
+
+        See :func:`trackintel.preprocessing.trips.generate_tours`.
+        """
+        assert len(args) == 0, "When calling 'generate_tours' via the accessor all arguments must be keyword arguments"
+        return ti.preprocessing.trips.generate_tours(trips=self._obj, **kwargs)
diff --git a/trackintel/preprocessing/__init__.py b/trackintel/preprocessing/__init__.py
index a21032e..a90ed0c 100644
--- a/trackintel/preprocessing/__init__.py
+++ b/trackintel/preprocessing/__init__.py
@@ -8,6 +8,8 @@ from .staypoints import generate_locations
 from .triplegs import smoothen_triplegs
 from .triplegs import generate_trips
 
+from .trips import generate_tours
+
 __all__ = [
     "generate_staypoints",
     "generate_triplegs",
@@ -15,4 +17,5 @@ __all__ = [
     "generate_locations",
     "smoothen_triplegs",
     "generate_trips",
+    "generate_tours",
 ]
diff --git a/trackintel/preprocessing/trips.py b/trackintel/preprocessing/trips.py
new file mode 100644
index 0000000..53aa921
--- /dev/null
+++ b/trackintel/preprocessing/trips.py
@@ -0,0 +1,440 @@
+from datetime import timedelta
+import geopandas as gpd
+import pandas as pd
+import numpy as np
+from tqdm import tqdm
+import warnings
+
+import trackintel as ti
+
+
+def get_trips_grouped(trips, tours):
+    """Helper function to get grouped trips by tour id
+
+    Parameters
+    ----------
+    trips: GeoDataFrame (as trackintel trips)
+        Trips dataframe
+
+    tours: GeoDataFrame (as trackintel tours)
+        Output of generate_tours function, must contain column "trips" with list of trip ids on tour
+
+
+    Returns
+    -------
+    trips_grouped_by_tour: DataFrameGroupBy object
+        Trips grouped by tour id
+
+    Notes
+    -------
+    This function is necessary because when running generate_tours, one trip only gets the tour ID of the smallest
+    tour it belongs to assigned. Here, we return all trips for each tour, which might contain a nested tour.
+    """
+    trips_inp = trips.copy()
+    if "tour_id" in trips_inp.columns:
+        trips_inp.drop(columns=["tour_id"], inplace=True)
+    # make smaller version of tours
+    tours_to_trips = tours.reset_index()[["id", "trips"]]
+    # switch to trips id as index
+    tours_to_trips.rename(columns={"id": "tour_id", "trips": "trip_id"}, inplace=True)
+    # expand this small version so that each trip id is one row
+    tours_expanded = tours_to_trips.explode("trip_id").reset_index(drop=True)
+
+    # join with trips table by id
+    tours_with_trips = tours_expanded.merge(trips_inp, left_on="trip_id", right_on="id", how="left")
+    # group
+    trips_grouped_by_tour = tours_with_trips.groupby("tour_id")
+    return trips_grouped_by_tour
+
+
+def generate_tours(
+    trips,
+    staypoints=None,
+    max_dist=100,
+    max_time="1d",
+    max_nr_gaps=0,
+    print_progress=False,
+):
+    """
+    Generate trackintel-tours from trips
+
+    Parameters
+    ----------
+    trips : GeoDataFrame (as trackintel trips)
+        The trips have to follow the standard definition for trips DataFrames
+
+    staypoints : GeoDataFrame (as trackintel staypoints, preprocessed to contain location IDs), default None
+        The staypoints have to follow the standard definition for staypoints DataFrames. The location ID column
+        is necessary to connect trips via locations to a tour. If None, trips will be connected based only on a
+        distance threshold `max_dist`.
+
+    max_dist: float, default 100 (meters)
+        Maximum distance between the end point of one trip and the start point of the next trip on a tour.
+        This is parameter is only used if staypoints is None!
+        Also, if `max_nr_gaps > 0`, a tour can contain larger spatial gaps (see Notes below)
+
+    max_time: str or pd.Timedelta, default "1d" (1 day)
+        Maximum time that a tour is allowed to take
+
+    max_nr_gaps: int, default 0
+        Maximum number of spatial gaps on the tour. Use with caution - see notes below.
+
+    print_progress : bool, default False
+        If print_progress is True, the progress bar is displayed
+
+
+    Returns
+    -------
+    trips_with_tours: GeoDataFrame (as trackintel trips)
+        Same as `trips`, but with column `tour_id`, containing a list of the tours that the trip is part of (see notes).
+
+    tours: GeoDataFrame (as trackintel tours)
+        The generated tours
+
+    Notes
+    -------
+    - Tours are defined as a collection of trips in a certain time frame that start and end at the same point
+    - Tours and trips have an N:N relationship: One tour consists of multiple trips, but also one trip can be part of
+      multiple tours, due to nested tours or overlapping tours.
+    - This function implements two possibilities to generate tours of trips: Via the location ID in the `staypoints`
+      df, or via a maximum distance. Thus, note that only one of the parameters `staypoints` or `max_dist` is used!
+    - Nested tours are possible and will be regarded as 2 (or more tours).
+    - It is possible to allow spatial gaps to occur on the tour, which might be useful to deal with missing data.
+      Example: The two trips home-work, supermarket-home would still be detected as a tour when max_nr_gaps >= 1,
+      although the work-supermarket trip is missing.
+      Warning: This only counts the number of gaps, but neither temporal or spatial distance of gaps, nor the number
+      of missing trips in a gap are bounded. Thus, this parameter should be set with caution, because trips that are
+      hours apart might still be connected to a tour if `max_nr_gaps > 0`.
+    """
+    # Two options: either the location IDs for staypoints on the trips are provided, or a maximum distance threshold
+    # between end and start of trips is used
+    if staypoints is not None:
+        assert (
+            "location_id" in staypoints.columns
+        ), "Staypoints with location ID is required, otherwise tours are generated without location using max_dist"
+        geom_col = None  # not used
+        crs_is_projected = False  # not used
+    else:
+        # if no location is given, we need the trips table to have a geometry column
+        assert isinstance(trips, gpd.geodataframe.GeoDataFrame), "Trips table must be a GeoDataFrame"
+        geom_col = trips.geometry.name
+        # get crs
+        crs_is_projected = ti.geogr.distances.check_gdf_planar(trips)
+
+    # convert max_time to timedelta
+    if isinstance(max_time, str):
+        max_time = pd.to_timedelta(max_time)
+    # otherwise check if it's a Timedelta already, and raise error if not
+    elif not isinstance(max_time, pd.Timedelta):
+        raise TypeError("Parameter max_time must be either of type String or pd.Timedelta!")
+
+    trips_input = trips.copy()
+    # If the trips already have a column "tour_id", we drop it
+    if "tour_id" in trips_input:
+        trips_input.drop(columns="tour_id", inplace=True)
+        warnings.warn("Deleted existing column 'tour_id' from trips.")
+
+    kwargs = {
+        "max_dist": max_dist,
+        "max_nr_gaps": max_nr_gaps,
+        "max_time": max_time,
+        "staypoints": staypoints,
+        "geom_col": geom_col,
+        "crs_is_projected": crs_is_projected,
+    }
+    if print_progress:
+        tqdm.pandas(desc="User trip generation")
+        tours = (
+            trips_input.groupby(["user_id"], group_keys=False, as_index=False)
+            .progress_apply(_generate_tours_user, **kwargs)
+            .reset_index(drop=True)
+        )
+    else:
+        tours = (
+            trips_input.groupby(["user_id"], group_keys=False, as_index=False)
+            .apply(_generate_tours_user, **kwargs)
+            .reset_index(drop=True)
+        )
+
+    # No tours found
+    if len(tours) == 0:
+        warnings.warn("No tours can be generated, return empty tours")
+        return trips_input, tours
+
+    # index management
+    tours["id"] = np.arange(len(tours))
+    tours.set_index("id", inplace=True)
+
+    # assign tour id to trips
+    tour2trip_map = tours.reset_index().explode("trips").rename(columns={"id": "tour_id"})
+    # Each trip is only assigned to one tour. If a trip belongs to multiple tours, we can find its smallest subtour
+    # by using the first one it is assigned to (nested tours are always found before big tours - have smaller tour_id)
+    temp = tour2trip_map.groupby("trips").agg({"tour_id": list})
+
+    trips_with_tours = trips_input.join(temp, how="left")
+
+    # trips id (generated by this function) should be int64
+    tours.index = tours.index.astype("int64")
+
+    return trips_with_tours, tours
+
+
+def _generate_tours_user(
+    user_trip_df,
+    staypoints=None,
+    max_dist=100,
+    max_nr_gaps=0,
+    max_time=timedelta(days=1),
+    geom_col="geom",
+    crs_is_projected=False,
+):
+    """
+    Compute tours from trips for one user
+
+    Parameters
+    ----------
+    user_trip_df : GeoDataFrame (as trackintel trips)
+        The trips have to follow the standard definition for trips DataFrames
+
+    staypoints : GeoDataFrame (as trackintel staypoints, preprocessed to contain location IDs), default None
+        The staypoints have to follow the standard definition for staypoints DataFrames. The location ID column
+        is necessary to connect trips via locations to a tour. If None, trips will be connected based only on a
+        distance threshold `max_dist`.
+
+    max_dist: float, default 100 (meters)
+        Maximum distance between the end point of one trip and the start point of the next trip on a tour.
+        However, if `max_nr_gaps > 0`, a tour can contain larger spatial gaps (see notes in `generate_tours`)
+
+    max_time: Timedelta, default 1 day
+        Maximum time that a tour is allowed to take
+
+    max_nr_gaps: int, default 0
+        Maximum number of spatial gaps on the tour. Use with caution - see notes in `generate_tours`.
+
+    geom_col : str, optional
+        Name of geometry column of user_trip_df, by default "geom"
+
+    crs_is_projected : bool, optional
+        Whether the crs of user_trip_df is projected, by default False
+
+    Returns
+    -------
+    tours_df: DataFrame
+        Tours for one user
+    """
+    user_id = user_trip_df["user_id"].unique()
+    assert len(user_id) == 1
+    user_id = user_id[0]
+
+    # sort by time
+    user_trip_df = user_trip_df.sort_values(by=["started_at"])
+
+    # save only the trip id (row.name) in the start candidates
+    start_candidates = []
+
+    # collect tours
+    tours = []
+    # Iterate over trips
+    for _, row in user_trip_df.iterrows():
+        end_time = row["finished_at"]
+
+        if len(start_candidates) > 0:
+            # Check if there is a spatial gap between the previous and current trip:
+            # If staypoints with locations are available, check whether they share the same location
+            if staypoints is not None:
+                end_start_at_same_loc = _check_same_loc(
+                    user_trip_df.loc[start_candidates[-1], "destination_staypoint_id"],  # dest. stp of previous trip
+                    row["origin_staypoint_id"],  # start stp of current trip
+                    staypoints,
+                )
+            else:
+                # If no locations are available, check whether the distance is smaller than max_dist
+                end_start_at_same_loc = _check_max_dist(
+                    user_trip_df.loc[start_candidates[-1], geom_col][1],  # destination point of previous trip
+                    row[geom_col][0],  # start point of current trip
+                    max_dist,
+                    crs_is_projected,
+                )
+
+            # if the current trip does not start at the end of the previous trip, there is a gap
+            if not end_start_at_same_loc:
+                # option 1: no gaps allowed - start search again
+                if max_nr_gaps == 0:
+                    start_candidates = [row.name]
+                    continue
+                # option 2: gaps allowed - search further
+                else:
+                    start_candidates.append(np.nan)
+
+        # Add this point as a candidate
+        start_candidates.append(row.name)
+
+        # Check whether endpoint would be an unknown activity
+        if pd.isna(row["destination_staypoint_id"]):
+            continue
+
+        # keep a list of which candidates to remove (because of time frame)
+        new_list_start = 0
+
+        # keep track of how many gaps we encountered, if greater than max_nr_gaps then stop
+        gap_counter = 0
+
+        # check for all candidates whether they form a tour with the current trip
+        for j, cand in enumerate(start_candidates[::-1]):
+            # gap
+            if np.isnan(cand):
+                gap_counter += 1
+                if gap_counter > max_nr_gaps:
+                    # these gaps won't vanish, so we can crop the candidate list here
+                    new_list_start = j + 1
+                    break
+                else:
+                    continue
+
+            # check time difference - if time too long, we can remove the candidate
+            cand_start_time = user_trip_df.loc[cand, "started_at"]
+            if end_time - cand_start_time > max_time:
+                new_list_start = len(start_candidates) - j - 1
+                break
+
+            # check whether the start-end candidate of a tour is an unknown activity
+            if pd.isna(user_trip_df.loc[cand, "origin_staypoint_id"]):
+                continue
+
+            # check if endpoint of trip = start location of cand
+            if staypoints is not None:
+                end_start_at_same_loc = _check_same_loc(
+                    user_trip_df.loc[cand, "origin_staypoint_id"],  # start stp of first trip
+                    row["destination_staypoint_id"],  # destination stp of current trip
+                    staypoints,
+                )
+            else:
+                # if no locations are available, check whether the distance is smaller than max_dist
+                end_start_at_same_loc = _check_max_dist(
+                    user_trip_df.loc[cand, geom_col][0],  # start point of first trip
+                    row[geom_col][1],  # destination point of current trip
+                    max_dist,
+                    crs_is_projected=crs_is_projected,
+                )
+
+            if end_start_at_same_loc:
+                # Tour found!
+                # collect the trips on the tour in a list
+                non_gap_trip_idxs = [c for c in start_candidates[-j - 1 :] if ~np.isnan(c)]
+                tour_candidate = user_trip_df[user_trip_df.index.isin(non_gap_trip_idxs)]
+                tours.append(_create_tour_from_stack(tour_candidate, staypoints, max_time))
+
+                # do not consider the other trips - one trip cannot close two tours at a time
+                break
+
+        # remove points because they are out of the time window
+        start_candidates = start_candidates[new_list_start:]
+
+    if len(tours) == 0:
+        return pd.DataFrame(
+            tours,
+            columns=[
+                "user_id",
+                "started_at",
+                "finished_at",
+                "origin_staypoint_id",
+                "destination_staypoint_id",
+                "trips",
+                "location_id",
+            ],
+        )
+    tours_df = pd.DataFrame(tours)
+    return tours_df
+
+
+def _check_same_loc(stp1, stp2, staypoints):
+    """Check whether two staypoints are at the same location
+
+    Parameters
+    ----------
+    stp1 : int
+        First staypoint id
+    stp2 : int
+        Second staypoint id
+    staypoints : Trackintel staypoints
+        GeoDataFrame with staypoints and also location ids
+
+    Returns
+    -------
+    share_location, bool
+        If True, stp1 and stp2 are at the same location
+    """
+    if pd.isna(stp1) or pd.isna(stp2):
+        return False
+    share_location = staypoints.loc[stp1, "location_id"] == staypoints.loc[stp2, "location_id"]
+    return share_location
+
+
+def _check_max_dist(p1, p2, max_dist, crs_is_projected=False):
+    """
+    Check whether two points p1, p2 are less or equal than max_dist apart
+
+    Parameters
+    --------
+    p1, p2: shapely Point objects
+    max_dist: int
+
+    Returns
+    ------
+    dist_below_thresh: bool
+        indicating whether p1 and p2 are less than max_dist apart
+    """
+    if crs_is_projected:
+        dist = p1.distance(p2)
+    else:
+        dist = ti.geogr.point_distances.haversine_dist(p1.x, p1.y, p2.x, p2.y)
+    dist_below_thresh = dist <= max_dist
+    return dist_below_thresh
+
+
+def _create_tour_from_stack(temp_tour_stack, staypoints, max_time):
+    """
+    Aggregate information of tour elements in a structured dictionary.
+
+    Parameters
+    ----------
+    temp_tour_stack : list
+        list of dictionary like elements (either pandas series or python dictionary).
+        Contains all trips that will be aggregated into a tour
+
+    Returns
+    -------
+    tour_dict_entry: dictionary
+
+    """
+    # this function return and empty dict if no tripleg is in the stack
+    first_trip = temp_tour_stack.iloc[0]
+    last_trip = temp_tour_stack.iloc[-1]
+
+    # get location ID if available:
+    if staypoints is not None:
+        start_loc = staypoints.loc[first_trip["origin_staypoint_id"], "location_id"]
+        # double check whether start and end location are the same
+        end_loc = staypoints.loc[last_trip["destination_staypoint_id"], "location_id"]
+        assert start_loc == end_loc
+    else:
+        # set location to NaN since not available
+        start_loc = pd.NA
+
+    # all data has to be from the same user
+    assert len(temp_tour_stack["user_id"].unique()) == 1
+
+    # double check if tour requirements are fulfilled
+    assert last_trip["finished_at"] - first_trip["started_at"] <= max_time
+
+    tour_dict_entry = {
+        "user_id": first_trip["user_id"],
+        "started_at": first_trip["started_at"],
+        "finished_at": last_trip["finished_at"],
+        "origin_staypoint_id": first_trip["origin_staypoint_id"],
+        "destination_staypoint_id": last_trip["destination_staypoint_id"],
+        "trips": list(temp_tour_stack.index),
+        "location_id": start_loc,
+    }
+
+    return tour_dict_entry


Test patch:
diff --git a/tests/data/geolife_long/tours.csv b/tests/data/geolife_long/tours.csv
new file mode 100644
index 0000000..706b625
--- /dev/null
+++ b/tests/data/geolife_long/tours.csv
@@ -0,0 +1,5 @@
+id,user_id,started_at,finished_at,origin_staypoint_id,destination_staypoint_id,location_id
+0,0,2008-10-23 11:09:22+00:00,2008-10-23 11:10:57+00:00,5.0,6.0,2
+1,1,2008-10-23 11:49:08+00:00,2008-10-23 11:49:48+00:00,11.0,12.0,5
+2,1,2008-10-23 10:32:53+00:00,2008-10-24 00:23:03+00:00,10.0,14.0,4
+3,1,2008-10-24 01:45:41+00:00,2008-10-24 04:12:50+00:00,14.0,20.0,4
diff --git a/tests/io/test_file.py b/tests/io/test_file.py
index 1478c14..84f62d9 100644
--- a/tests/io/test_file.py
+++ b/tests/io/test_file.py
@@ -315,4 +315,26 @@ class TestTrips:
 class TestTours:
     """Test for 'read_tours_csv' and 'write_tours_csv' functions."""
 
-    pass
+    def test_from_to_csv(self):
+        """Test basic reading and writing functions."""
+        orig_file = os.path.join("tests", "data", "geolife_long", "tours.csv")
+        tmp_file = os.path.join("tests", "data", "tours_test.csv")
+        tours = ti.read_tours_csv(orig_file, index_col="id")
+
+        ti.io.write_tours_csv(tours, tmp_file)
+        assert filecmp.cmp(orig_file, tmp_file, shallow=False)
+        os.remove(tmp_file)
+
+    def test_to_csv_accessor(self):
+        """Test basic reading and writing functions."""
+        orig_file = os.path.join("tests", "data", "geolife_long", "tours.csv")
+        tmp_file = os.path.join("tests", "data", "tours_test.csv")
+        tours = ti.read_tours_csv(orig_file, index_col="id")
+
+        tours.as_tours.to_csv(tmp_file)
+        assert filecmp.cmp(orig_file, tmp_file, shallow=False)
+        os.remove(tmp_file)
+
+    def test_from_to_postgis(self):
+        # TODO Implement some tests for reading and writing tours.
+        pass
diff --git a/tests/preprocessing/test_trips.py b/tests/preprocessing/test_trips.py
new file mode 100644
index 0000000..bf3c86d
--- /dev/null
+++ b/tests/preprocessing/test_trips.py
@@ -0,0 +1,315 @@
+import os
+import pandas as pd
+import pytest
+from shapely.geometry import MultiPoint, Point
+import datetime
+import geopandas as gpd
+from geopandas.testing import assert_geodataframe_equal
+
+import trackintel as ti
+
+
+@pytest.fixture
+def example_trip_data():
+    """Trips and locations for tour generation."""
+    # define points for each staypoint
+    sp_geom_mapping = {
+        1: Point(8.5067847, 47.4),
+        2: Point(8.5067847, 47.40001),
+        3: Point(8.5067847, 47.6),
+        4: Point(8.5067847, 47.7),
+        5: Point(8.5067847, 47.399),
+        6: Point(8.5067847, 47.60001),
+        7: Point(9.5067847, 47.20001),
+    }
+
+    t1 = pd.Timestamp("1971-01-01 00:00:00", tz="utc")
+    t2 = pd.Timestamp("1971-01-01 05:00:00", tz="utc")
+    t3 = pd.Timestamp("1971-01-02 07:00:00", tz="utc")
+    t4 = pd.Timestamp("1971-01-02 08:00:00", tz="utc")
+    t5 = pd.Timestamp("1971-01-02 09:00:00", tz="utc")
+    t6 = pd.Timestamp("1971-01-02 10:00:00", tz="utc")
+
+    trips_list_dict = [
+        # loop
+        {
+            "id": 1,
+            "user_id": 0,
+            "started_at": t1,
+            "finished_at": t2,
+            "origin_staypoint_id": 1,
+            "destination_staypoint_id": 2,
+        },
+        # this should not belong to tour because time-wise not connected
+        {
+            "id": 5,
+            "user_id": 0,
+            "started_at": t2,
+            "finished_at": t2,
+            "origin_staypoint_id": 2,
+            "destination_staypoint_id": 3,
+        },
+        # long tour
+        {
+            "id": 2,
+            "user_id": 0,
+            "started_at": t3,
+            "finished_at": t4,
+            "origin_staypoint_id": 3,
+            "destination_staypoint_id": 4,
+        },
+        {
+            "id": 6,
+            "user_id": 0,
+            "started_at": t4,
+            "finished_at": t5,
+            "origin_staypoint_id": 4,
+            "destination_staypoint_id": 5,
+        },
+        {
+            "id": 15,
+            "user_id": 0,
+            "started_at": t5,
+            "finished_at": t6,
+            "origin_staypoint_id": 5,
+            "destination_staypoint_id": 6,
+        },
+        # new user - gap in tour
+        {
+            "id": 7,
+            "user_id": 1,
+            "started_at": t3,
+            "finished_at": t4,
+            "origin_staypoint_id": 3,
+            "destination_staypoint_id": 5,
+        },
+        {
+            "id": 80,
+            "user_id": 1,
+            "started_at": t4,
+            "finished_at": t5,
+            "origin_staypoint_id": 4,
+            "destination_staypoint_id": 3,
+        },
+        # no tour
+        {
+            "id": 3,
+            "user_id": 1,
+            "started_at": t5,
+            "finished_at": t6,
+            "origin_staypoint_id": 1,
+            "destination_staypoint_id": 7,
+        },
+    ]
+    # fill geom based on staypoints
+    for row_dict in trips_list_dict:
+        row_dict["geom"] = MultiPoint(
+            (sp_geom_mapping[row_dict["origin_staypoint_id"]], sp_geom_mapping[row_dict["destination_staypoint_id"]])
+        )
+
+    # assign location IDs (only for testing, not a trackintel staypoints standard!)
+    sp_locs = pd.DataFrame({"location_id": [1, 1, 2, 3, 1, 2, 4]}, index=[1, 2, 3, 4, 5, 6, 7])
+
+    trips = gpd.GeoDataFrame(data=trips_list_dict, geometry="geom", crs="EPSG:4326")
+    trips = trips.set_index("id")
+    # assert valid trips
+    trips.as_trips
+    return trips, sp_locs
+
+
+@pytest.fixture
+def example_nested_tour(example_trip_data):
+    """Helper function to create a nested trip"""
+    trips, _ = example_trip_data
+
+    # construct trips that lie between trips 6 and 15 and form a tour on their own
+    # define start and end points of these trips
+    first_trip_subtour = MultiPoint((trips.loc[15, "geom"][0], Point(9.5067847, 47.20001)))
+    second_trip_subtour = MultiPoint((Point(9.5067847, 47.20001), trips.loc[15, "geom"][0]))
+    # define time of start and time at the intermediate point
+    start_time_subtour = pd.Timestamp("1971-01-02 08:45:00", tz="utc")
+    middle_time = pd.Timestamp("1971-01-02 08:55:00", tz="utc")
+    trips.loc[6, "finished_at"] = start_time_subtour
+    # trip 6 starts at 8:45 at sp 5, then at 8:45 the user goes to 7 and back to 5
+    trips.loc[100] = [0, start_time_subtour, middle_time, 5, 7, first_trip_subtour]
+    trips.loc[200] = [0, middle_time, trips.loc[15, "started_at"], 7, 5, second_trip_subtour]
+    trips.sort_values(by=["user_id", "started_at", "origin_staypoint_id", "destination_staypoint_id"], inplace=True)
+    return trips
+
+
+class TestGenerate_tours:
+    """Tests for generate_tours() method."""
+
+    def test_generate_tours(self, example_trip_data):
+        """Test general functionality of generate tours function"""
+        trips, sp_locs = example_trip_data
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips)
+        # check that nothing else than the new column has changed in trips df
+        assert all(trips_out.iloc[:, :6] == trips)
+        # check that the two tours were found
+        assert trips_out.loc[1, "tour_id"] == [0]
+        assert (
+            trips_out.loc[2, "tour_id"] == [1]
+            and trips_out.loc[6, "tour_id"] == [1]
+            and trips_out.loc[15, "tour_id"] == [1]
+        )
+        # all others have tour id nan
+        user_1_df = trips_out[trips_out["user_id"] == 1]
+        assert all(pd.isna(user_1_df["tour_id"]))
+
+    def test_tours_with_gap(self, example_trip_data):
+        """Test functionality of max_nr_gaps parameter in tour generation"""
+        trips, sp_locs = example_trip_data
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips, max_nr_gaps=1)
+        # new tour was found for user 1
+        assert len(tours) == 3
+        assert trips_out.loc[7, "tour_id"] == [2]
+        assert trips_out.loc[80, "tour_id"] == [2]
+
+    def test_tour_times(self, example_trip_data):
+        """Check whether the start and end times of generated tours are correct"""
+        trips, sp_locs = example_trip_data
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips, max_nr_gaps=1, max_time="1d")
+        # check that all times are below the max time
+        for i, row in tours.iterrows():
+            time_diff = row["finished_at"] - row["started_at"]
+            assert time_diff > pd.to_timedelta("0m") and time_diff < pd.to_timedelta("1d")
+
+        # group trips by tour and check that start and end of each tour are correct
+        grouped_trips = ti.preprocessing.trips.get_trips_grouped(trips, tours)
+        for tour_id, tour_df in grouped_trips:
+            gt_start = tour_df.iloc[0]["started_at"]
+            gt_end = tour_df.iloc[-1]["finished_at"]
+            assert gt_start == tours.loc[tour_id, "started_at"]
+            assert gt_end == tours.loc[tour_id, "finished_at"]
+
+    def test_tour_geom(self, example_trip_data):
+        """Test whether tour generation is invariant to the name of the geometry column"""
+        trips, sp_locs = example_trip_data
+        trips.rename(columns={"geom": "other_geom_name"}, inplace=True)
+        trips = trips.set_geometry("other_geom_name")
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips)
+        # check that nothing else than the new column has changed in trips df
+        assert all(trips_out.iloc[:, :6] == trips)
+
+    def test_tour_max_time(self, example_trip_data):
+        """Test functionality of max time argument in tour generation"""
+        trips, sp_locs = example_trip_data
+        with pytest.warns(UserWarning, match="No tours can be generated, return empty tours"):
+            _, tours = ti.preprocessing.trips.generate_tours(trips, max_time="2h")  # only 2 hours allowed
+            assert len(tours) == 0
+        _, tours = ti.preprocessing.trips.generate_tours(trips, max_time="3h")  # increase to 3 hours
+        assert len(tours) == 1
+
+    def test_tours_locations(self, example_trip_data):
+        """Test whether tour generation with locations as input yields correct results as well"""
+        trips, sp_locs = example_trip_data
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips, staypoints=sp_locs, max_nr_gaps=1)
+        assert all(tours["location_id"] == pd.Series([1, 2, 2]))
+
+        # group trips by tour and check that the locations of start and end of each tour are correct
+        grouped_trips = ti.preprocessing.trips.get_trips_grouped(trips, tours)
+        for tour_id, tour_df in grouped_trips:
+            gt_start = tour_df.iloc[0]["origin_staypoint_id"]
+            gt_loc = sp_locs.loc[gt_start, "location_id"]
+            gt_end = tour_df.iloc[-1]["destination_staypoint_id"]
+            assert gt_start == tours.loc[tour_id, "origin_staypoint_id"]
+            assert gt_end == tours.loc[tour_id, "destination_staypoint_id"]
+            assert gt_loc == tours.loc[tour_id, "location_id"]
+
+    def test_tours_crs(self, example_trip_data):
+        """Test if the tours generation works with projected coordinate system"""
+        trips, _ = example_trip_data
+        trips_crs = trips.copy()
+        # normal baseline
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips)
+        # set other crs
+        trips_crs.set_crs("WGS84", inplace=True)
+        trips_crs.to_crs("EPSG:2056", inplace=True)
+        trips_out_crs, tours_crs = ti.preprocessing.trips.generate_tours(trips_crs)
+        trips_out_crs.to_crs("WGS84", inplace=True)
+        # assert equal
+        assert_geodataframe_equal(trips_out, trips_out_crs, check_less_precise=True)
+        pd.testing.assert_frame_equal(tours, tours_crs)
+
+    def test_generate_tours_geolife(self):
+        """Test tour generation also on the geolife dataset"""
+        trips = ti.io.file.read_trips_csv(os.path.join("tests", "data", "geolife_long", "trips.csv"), index_col="id")
+        _, tours = ti.preprocessing.trips.generate_tours(trips, max_dist=100)
+
+        tours_loaded = ti.io.file.read_tours_csv(
+            os.path.join("tests", "data", "geolife_long", "tours.csv"), index_col="id"
+        )
+        # check only first five columns because location ID is empty in this case
+        pd.testing.assert_frame_equal(tours_loaded.iloc[:, :5], tours.iloc[:, :5])
+
+    def test_accessor(self, example_trip_data):
+        """Test if the accessor leads to the same results as the explicit function."""
+        # generate tours
+        trips, _ = example_trip_data
+
+        # generate tours using the explicit function import
+        trips_expl, tours_expl = ti.preprocessing.trips.generate_tours(trips)
+
+        # generate tours using the accessor
+        trips_acc, tours_acc = trips.as_trips.generate_tours()
+
+        pd.testing.assert_frame_equal(trips_expl, trips_acc)
+        pd.testing.assert_frame_equal(tours_expl, tours_acc)
+
+    def test_print_progress_flag(self, example_trip_data, capsys):
+        """Test if the print_progress bar controls the printing behavior."""
+        trips, sp_locs = example_trip_data
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips, print_progress=True)
+        captured_print = capsys.readouterr()
+        assert captured_print.err != ""
+
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips, print_progress=False)
+        captured_noprint = capsys.readouterr()
+        assert captured_noprint.err == ""
+
+    def test_index_stability(self, example_trip_data):
+        """Test if the index of the trips remains stable"""
+        trips, sp_locs = example_trip_data
+        trips_out, tours = ti.preprocessing.trips.generate_tours(trips, print_progress=True)
+
+        assert trips.index.equals(trips_out.index)
+
+    def test_nested_tour(self, example_nested_tour):
+        """
+        Test whether we get two tours (a long one and a nested short one), for example for home-work-lunch-work-home
+        """
+        trips_out, tours = ti.preprocessing.trips.generate_tours(example_nested_tour)
+        assert len(tours) == 3
+        # the nested tour of length 2 should be found
+        assert tours.loc[1, "trips"] == [100, 200]
+        # the big tour should be found
+        assert tours.loc[2, "trips"] == [2, 6, 100, 200, 15]
+        # in the trips table, trip 100 and 200 is assigned the tour id of its smaller tour
+        assert trips_out.loc[100, "tour_id"] == [1, 2]
+        assert trips_out.loc[200, "tour_id"] == [1, 2]
+
+    def test_tours_warn_existing_column(self, example_trip_data):
+        trips, _ = example_trip_data
+        trips["tour_id"] = 1
+        with pytest.warns(UserWarning, match="Deleted existing column 'tour_id' from trips."):
+            _ = ti.preprocessing.trips.generate_tours(trips, print_progress=True)
+
+    def test_tours_time_gap_error(self, example_trip_data):
+        trips, _ = example_trip_data
+        # check that an int as max time raises a TypeError
+        with pytest.raises(Exception) as e_info:
+            _ = ti.preprocessing.trips.generate_tours(trips, max_time=24)
+            assert e_info == "Parameter max_time must be either of type String or pd.Timedelta!"
+
+
+class TestTourHelpers:
+    """Test auxiliary function for trip grouping"""
+
+    def test_get_trips_grouped(self, example_nested_tour):
+        trips, tours = ti.preprocessing.trips.generate_tours(example_nested_tour)
+        grouped_trips = ti.preprocessing.trips.get_trips_grouped(trips, tours)
+        for tour_id, trips_on_tour in grouped_trips:
+            # check that all trips belong to the tour
+            for i, id in enumerate(trips_on_tour["trip_id"]):
+                assert id in list(tours.loc[tour_id, "trips"])