Repository: mie-lab/trackintel

Issue:
Implement a cleaner and faster version of trip generation
Current code for trip generation is complicated. It would be great to have a cleaner appraoch that can be vectorized.

Discussion:
bifbof: There is one thing I am not sure about, if we filter out trips without triplegs as we say in the docstring.

Maybe in another pull request we should write a test for that.
codecov[bot]: # [Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
> Merging [#220](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (a362298) into [master](https://codecov.io/gh/mie-lab/trackintel/commit/76bdb34fd7c0ba29ccd40cef0b0991d351066f19?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (76bdb34) will **increase** coverage by `0.47%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/mie-lab/trackintel/pull/220/graphs/tree.svg?width=650&height=150&src=pr&token=ITDZEEE1MO&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab)](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab)

```diff
@@            Coverage Diff             @@
##           master     #220      +/-   ##
==========================================
+ Coverage   94.89%   95.36%   +0.47%     
==========================================
  Files          37       37              
  Lines        1548     1511      -37     
  Branches      259      240      -19     
==========================================
- Hits         1469     1441      -28     
+ Misses         41       36       -5     
+ Partials       38       34       -4     
```


| [Impacted Files](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/preprocessing/triplegs.py](https://codecov.io/gh/mie-lab/trackintel/pull/220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9wcmVwcm9jZXNzaW5nL3RyaXBsZWdzLnB5) | `100.00% <100.00%> (+8.02%)` | :arrow_up: |
| [trackintel/visualization/util.py](https://codecov.io/gh/mie-lab/trackintel/pull/220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC92aXN1YWxpemF0aW9uL3V0aWwucHk=) | `94.44% <0.00%> (-3.71%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab). Last update [76bdb34...a362298](https://codecov.io/gh/mie-lab/trackintel/pull/220?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab).

henrymartin1: Is this now ready for review again? It is a bit messy with not being able to be requested as a reviewer for this PR ;-) 
bifbof: > Is this now ready for review again? It is a bit messy with not being able to be requested as a reviewer for this PR ;-)

Yes it should be good again. :)
henrymartin1: @hong2223 Ready for your review!
bifbof: Thanks Henry for the cleanup! :)
bifbof: The linting check keeps failing and my local black don't want to change anything. :)
bifbof: Thanks for running it. I think I may know the reason for that to quote myself 1 month ago:
> There is one thing I am not sure about, if we filter out trips without triplegs as we say in the docstring.

Changing this behavior should be pretty easy, I'll take a look at it.
henrymartin1: > 
> 
> Sorry for taking a bit longer than expected. I observed another error when running this on a bigger subset of Geolife. The generated trips have two records different from the original function
> Original (old function):
> id 	user_id 	started_at 	finished_at 	origin_staypoint_id 	destination_staypoint_id
> 698 	52 	2008-04-29 08:21:34+00:00 	2008-05-20 10:53:46+00:00 		646.0
> 5472 	88 	2008-04-11 08:38:23+00:00 	2008-09-26 11:13:15+00:00 		
> 
> Now (new):
> id 	user_id 	started_at 	finished_at 	origin_staypoint_id 	destination_staypoint_id
> 689 	52 	2008-04-29 08:21:34+00:00 	2008-10-30 07:37:25+00:00 		646.0
> 5472 	88 	2008-04-11 08:38:23+00:00 	2008-10-12 08:11:36+00:00 		
> 
> Note that the `finished_at` time is actually the last tripleg `finished_at` for that user. These records have super long time spans and I am not sure what causes this discrepancy.
> 
> ```python
> pfs, mode_labels = read_geolife("./geolife/Data", print_progress=True)
> # get user with mode labels
> user_with_mode = []
> for key, value in mode_labels.items():
>     if len(value) > 0:
>         user_with_mode.append(key)
> # only select user who has mode labels
> pfs = pfs.loc[pfs["user_id"].isin(user_with_mode)].copy()
> 
> # generate staypoints, triplegs and trips
> pfs, spts = pfs.as_positionfixes.generate_staypoints(time_threshold=5.0, print_progress=True)
> pfs, tpls = pfs.as_positionfixes.generate_triplegs(spts)
> tpls = geolife_add_modes_to_triplegs(tpls, mode_labels)
> 
> spts = ti.analysis.labelling.create_activity_flag(spts, time_threshold=15)
> 
> stps, tpls, trips = ti.preprocessing.triplegs.generate_trips(spts, tpls, gap_threshold=15)
> stps_, tpls_, trips_ = generate_trips_new(spts, tpls, gap_threshold=15)
> 
> # print(trips, trips_)
> trips.to_csv("trips_old.csv")
> trips_.to_csv("trips_new.csv")
> spts.to_csv("spts.csv")
> tpls.to_csv("tpls.csv")
> assert_frame_equal(trips, trips_, check_like=True, check_index_type=False)
> assert_frame_equal(stps, stps_, check_like=True, check_index_type=False)
> assert_frame_equal(tpls, tpls_, check_like=True, check_index_type=False)
> ```

ah wow, that actually looks really wrong for both cases... Thanks for the example, I'll have a look

henrymartin1: Note for future bug reports
If possible, include the import statements in code-snippets :-)
bifbof: Thanks Ye for finding that error. But I have problems reproducing it.
I work only a subset of the trackintel data of users 50-55 and 80-90 more my laptop cannot handle. ^^
And run on that subset I don't get that error in the trips.
But I found instead found a bug in `geolife_add_modes_to_triplegs` (#273) but that one _should_ have no influence over the outcome. 
hong2223: **TL;DR**: the current version is good, we can merge this now.

I doubled checked the result of generate_trips() after the #283 fix. Now, I cannot reproduce the original bug as reported, guess this is a really special case and we can leave it for now.

I do find another discrepancy when comparing this to the old function, but I think it's a bug in the old function. The old function sometimes (10 times in Geolife) assigns an `origin_staypoint_id` to trips when the previous staypoint and the first tripleg has a temporal gap larger than the defined `gap_threshold` (correct assignment should be pd.NA). This is because in Line [237](https://github.com/mie-lab/trackintel/blob/47c4d236cd18ba204db9c1772e9091230c730f5a/trackintel/preprocessing/triplegs.py#L237) we directly assign the current row to origin_activity, without judging whether the origin_activity and first tripleg has a gap.

The bug can already be observed in the generated trip_id 52 of user 0

```python
import trackintel as ti
from pandas.testing import assert_frame_equal

# data from user 0
pfs, mode_labels = read_geolife("./Data", print_progress=True)

pfs, stps = pfs.as_positionfixes.generate_staypoints(time_threshold=5, print_progress=True)
pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
stps = stps.as_staypoints.create_activity_flag(time_threshold=15)

stps_1, tpls_1, trips_1 = generate_trips_new(stps, tpls, gap_threshold=15)
stps_2, tpls_2, trips_2 = _generate_trips_old(stps, tpls, gap_threshold=15, print_progress=True)

# False when considering origin_staypoint_id
assert_frame_equal(trips_1, trips_2, check_like=True, check_index_type=False)
# True when only considering time
assert_frame_equal(trips_1.iloc[:,:3], trips_2.iloc[:,:3], check_like=True, check_index_type=False)
```




henrymartin1: Great work everybody! Thanks @bifbof for most of the implementation and special thanks to @hong2223 for the thorough review!

Patch:
diff --git a/trackintel/model/triplegs.py b/trackintel/model/triplegs.py
index dbfb855..25e1fac 100644
--- a/trackintel/model/triplegs.py
+++ b/trackintel/model/triplegs.py
@@ -1,5 +1,6 @@
 import pandas as pd
 import trackintel as ti
+
 from trackintel.analysis.labelling import predict_transport_mode
 from trackintel.analysis.modal_split import calculate_modal_split
 from trackintel.analysis.tracking_quality import temporal_tracking_quality
@@ -127,15 +128,15 @@ class TriplegsAccessor(object):
         See :func:`trackintel.preprocessing.triplegs.generate_trips`.
         """
         # if spts in kwargs: 'spts' can not be in args as it would be the first argument
-        if "stps_input" in kwargs:
-            return ti.preprocessing.triplegs.generate_trips(tpls_input=self._obj, **kwargs)
+        if "spts" in kwargs:
+            return ti.preprocessing.triplegs.generate_trips(tpls=self._obj, **kwargs)
         # if 'spts' no in kwargs it has to be the first argument in 'args'
         else:
             assert len(args) <= 1, (
                 "All arguments except 'stps_input' have to be given as keyword arguments. You gave"
                 f" {args[1:]} as positional arguments."
             )
-            return ti.preprocessing.triplegs.generate_trips(stps_input=args[0], tpls_input=self._obj, **kwargs)
+            return ti.preprocessing.triplegs.generate_trips(spts=args[0], tpls=self._obj, **kwargs)
 
     @copy_docstring(predict_transport_mode)
     def predict_transport_mode(self, *args, **kwargs):
diff --git a/trackintel/preprocessing/triplegs.py b/trackintel/preprocessing/triplegs.py
index 50de0eb..ef18e06 100644
--- a/trackintel/preprocessing/triplegs.py
+++ b/trackintel/preprocessing/triplegs.py
@@ -1,8 +1,7 @@
-import datetime
+import warnings
 
 import numpy as np
 import pandas as pd
-from tqdm import tqdm
 
 
 def smoothen_triplegs(triplegs, tolerance=1.0, preserve_topology=True):
@@ -37,19 +36,17 @@ def smoothen_triplegs(triplegs, tolerance=1.0, preserve_topology=True):
     return ret_tpls
 
 
-def generate_trips(stps_input, tpls_input, gap_threshold=15, print_progress=False):
+def generate_trips(spts, tpls, gap_threshold=15):
     """Generate trips based on staypoints and triplegs.
 
     Parameters
-    ---------- 
-    stps_input : GeoDataFrame (as trackintel staypoints)
-        The staypoints have to follow the standard definition for staypoints DataFrames.
-                
-    tpls_input : GeoDataFrame (as trackintel triplegs)
-        The triplegs have to follow the standard definition for triplegs DataFrames.
-                
+    ----------
+    spts : GeoDataFrame (as trackintel staypoints)
+
+    tpls : GeoDataFrame (as trackintel triplegs)
+
     gap_threshold : float, default 15 (minutes)
-        Maximum allowed temporal gap size in minutes. If tracking data is missing for more than 
+        Maximum allowed temporal gap size in minutes. If tracking data is missing for more than
         `gap_threshold` minutes, then a new trip begins after the gap.
 
     print_progress : bool, default False
@@ -58,13 +55,13 @@ def generate_trips(stps_input, tpls_input, gap_threshold=15, print_progress=Fals
     Returns
     -------
     staypoints: GeoDataFrame (as trackintel staypoints)
-        the original staypoints with new columns ``[`trip_id`, `prev_trip_id`, `next_trip_id`]``.
-        
+        The original staypoints with new columns ``[`trip_id`, `prev_trip_id`, `next_trip_id`]``.
+
     triplegs: GeoDataFrame (as trackintel triplegs)
         The original triplegs with a new column ``[`trip_id`]``.
-    
+
     trips: GeoDataFrame (as trackintel trips)
-        The generated trips. 
+        The generated trips.
 
     Notes
     -----
@@ -73,10 +70,9 @@ def generate_trips(stps_input, tpls_input, gap_threshold=15, print_progress=Fals
     The function returns altered versions of the input staypoints and triplegs. Staypoints receive the fields
     [`trip_id` `prev_trip_id` and `next_trip_id`], triplegs receive the field [`trip_id`].
     The following assumptions are implemented
-    
-        - All movement before the first and after the last activity is omitted
-        - If we do not record a person for more than `gap_threshold` minutes, \
-            we assume that the person performed an activity in the recording gap and split the trip at the gap.
+
+        - If we do not record a person for more than `gap_threshold` minutes,
+          we assume that the person performed an activity in the recording gap and split the trip at the gap.
         - Trips that start/end in a recording gap can have an unknown origin/destination
         - There are no trips without a (recored) tripleg
 
@@ -89,283 +85,192 @@ def generate_trips(stps_input, tpls_input, gap_threshold=15, print_progress=Fals
     >>> staypoints, triplegs, trips = triplegs.as_triplegs.generate_trips(staypoints)
 
     """
-    assert (
-        "activity" in stps_input.columns
-    ), "staypoints need the column 'activities' \
-                                         to be able to generate trips"
 
-    # we copy the input because we need to add a temporary column
-    tpls = tpls_input.copy()
-    stps = stps_input.copy()
+    assert "activity" in spts.columns, "staypoints need the column 'activities' to be able to generate trips"
 
-    # if the triplegs already have a column "trip_id", we drop it
+    # Copy the input because we add a temporary columns
+    tpls = tpls.copy()
+    spts = spts.copy()
+    gap_threshold = pd.to_timedelta(gap_threshold, unit="min")
+
+    # If the triplegs already have a column "trip_id", we drop it
     if "trip_id" in tpls:
         tpls.drop(columns="trip_id", inplace=True)
+        warnings.warn("Deleted existing column 'trip_id' from tpls.")
 
-    # if the staypoints already have any of the columns  "trip_id", "prev_trip_id", "next_trip_id", we drop them
+    # if the staypoints already have any of the columns "trip_id", "prev_trip_id", "next_trip_id", we drop them
     for col in ["trip_id", "prev_trip_id", "next_trip_id"]:
-        if col in stps:
-            stps.drop(columns=col, inplace=True)
+        if col in spts:
+            spts.drop(columns=col, inplace=True)
+            warnings.warn(f"Deleted column '{col}' from spts.")
 
     tpls["type"] = "tripleg"
-    stps["type"] = "staypoint"
+    spts["type"] = "staypoint"
 
     # create table with relevant information from triplegs and staypoints.
-    stps_tpls = stps[["started_at", "finished_at", "user_id", "type", "activity"]].append(
-        tpls[["started_at", "finished_at", "user_id", "type"]]
+    spts_tpls = pd.concat(
+        [
+            spts[["started_at", "finished_at", "user_id", "type", "activity"]],
+            tpls[["started_at", "finished_at", "user_id", "type"]],
+        ]
     )
+    # transform nan to bool
+    spts_tpls["activity"].fillna(False, inplace=True)
 
     # create ID field from index
-    stps_tpls["id"] = stps_tpls.index
+    spts_tpls["spts_tpls_id"] = spts_tpls.index
 
-    # transform nan to bool
-    stps_tpls["activity"] = stps_tpls["activity"] == True
-
-    stps_tpls.sort_values(by=["user_id", "started_at"], inplace=True)
-    stps_tpls["started_at_next"] = stps_tpls["started_at"].shift(-1)
-    stps_tpls["activity_next"] = stps_tpls["activity"].shift(-1)
-
-    if print_progress:
-        tqdm.pandas(desc="User trip generation")
-        trips = (
-            stps_tpls.groupby(["user_id"], group_keys=False, as_index=False)
-            .progress_apply(_generate_trips_user, gap_threshold=gap_threshold)
-            .reset_index(drop=True)
-        )
-    else:
-        trips = (
-            stps_tpls.groupby(["user_id"], group_keys=False, as_index=False)
-            .apply(_generate_trips_user, gap_threshold=gap_threshold)
-            .reset_index(drop=True)
-        )
-
-    # index management
-    trips["id"] = np.arange(len(trips))
-    trips.set_index("id", inplace=True)
+    spts_tpls.sort_values(by=["user_id", "started_at"], inplace=True)
+
+    # conditions for new trip
+    # start new trip if the user changes
+    condition_new_user = spts_tpls["user_id"] != spts_tpls["user_id"].shift(1)
+
+    # start new trip if there is a new activity (last activity in group)
+    _, _, condition_new_activity = _get_activity_masks(spts_tpls)
+
+    # gap conditions
+    # start new trip after a gap, difference of started next with finish of current.
+    gap = (spts_tpls["started_at"].shift(-1) - spts_tpls["finished_at"]) > gap_threshold
+    condition_time_gap = gap.shift(1, fill_value=False)  # trip starts on next entry
+
+    new_trip = condition_new_user | condition_new_activity | condition_time_gap
+
+    # assign an incrementing id to all triplegs that start a trip
+    # temporary as empty trips are not filtered out yet.
+    spts_tpls.loc[new_trip, "temp_trip_id"] = np.arange(new_trip.sum())
+    spts_tpls["temp_trip_id"].fillna(method="ffill", inplace=True)
+
+    # exclude activities to aggregate trips together.
+    # activity can be thought of as the same aggregation level as trips.
+    spts_tpls_no_act = spts_tpls[~spts_tpls["activity"]]
+    spts_tpls_only_act = spts_tpls[spts_tpls["activity"]]
+
+    trips_grouper = spts_tpls_no_act.groupby("temp_trip_id")
+    trips = trips_grouper.agg(
+        {"user_id": "mean", "started_at": min, "finished_at": max, "type": list, "spts_tpls_id": list}
+    )
+
+    def _seperate_ids(row):
+        """Split aggregated spts_tpls_ids into staypoint ids and tripleg ids columns."""
+        row_type = np.array(row["type"])
+        row_id = np.array(row["spts_tpls_id"])
+        t = row_type == "tripleg"
+        tpls_ids = row_id[t]
+        spts_ids = row_id[~t]
+        # for dropping trips that don't have triplegs
+        tpls_ids = tpls_ids if len(tpls_ids) > 0 else None
+        return [spts_ids, tpls_ids]
+
+    trips[["spts", "tpls"]] = trips.apply(_seperate_ids, axis=1, result_type="expand")
+
+    # drop all trips that don't contain any triplegs
+    trips.dropna(subset=["tpls"], inplace=True)
+
+    # recount trips ignoring empty trips and save trip_id as for id assignment.
+    trips.reset_index(inplace=True, drop=True)
+    trips["trip_id"] = trips.index
+
+    # add gaps as activities, to simplify id assignment.
+    gaps = pd.DataFrame(spts_tpls.loc[gap, "user_id"])
+    gaps["started_at"] = spts_tpls.loc[gap, "finished_at"] + gap_threshold / 2
+    gaps[["type", "activity"]] = ["gap", True]  # nicer for debugging
+
+    # same for user changes
+    user_change = pd.DataFrame(spts_tpls.loc[condition_new_user, "user_id"])
+    user_change["started_at"] = spts_tpls.loc[condition_new_user, "started_at"] - gap_threshold / 2
+    user_change[["type", "activity"]] = ["user_change", True]  # nicer for debugging
+
+    # merge trips with (filler) activities
+    trips.drop(columns=["type", "spts_tpls_id"], inplace=True)  # make space so no overlap with activity "spts_tpls_id"
+    # Inserting `gaps` and `user_change` into the dataframe creates buffers that catch shifted
+    # "staypoint_id" and "trip_id" from corrupting staypoints/trips.
+    trips_with_act = pd.concat((trips, spts_tpls_only_act, gaps, user_change), axis=0, ignore_index=True)
+    trips_with_act.sort_values(["user_id", "started_at"], inplace=True)
+
+    # ID assignment #
+    # add origin/destination ids by shifting
+    trips_with_act["origin_staypoint_id"] = trips_with_act["spts_tpls_id"].shift(1)
+    trips_with_act["destination_staypoint_id"] = trips_with_act["spts_tpls_id"].shift(-1)
+
+    # add prev_trip_id and next_trip_id for activity staypoints
+    trips_with_act["prev_trip_id"] = trips_with_act["trip_id"].shift(1)
+    trips_with_act["next_trip_id"] = trips_with_act["trip_id"].shift(-1)
+    activity_staypoints = trips_with_act[trips_with_act["type"] == "staypoint"].copy()
+    activity_staypoints.index = activity_staypoints["spts_tpls_id"]
+    spts = spts.join(activity_staypoints[["prev_trip_id", "next_trip_id"]], how="left")
+
+    # transform column to binary
+    trips_with_act["activity"].fillna(False, inplace=True)
+    # delete activities
+    trips = trips_with_act[~trips_with_act["activity"]].copy()
+
+    trips.drop(
+        [
+            "type",
+            "spts_tpls_id",
+            "activity",
+            "temp_trip_id",
+            "prev_trip_id",
+            "next_trip_id",
+        ],
+        inplace=True,
+        axis=1,
+    )
 
+    # now handle the data that is aggregated in the trips
     # assign trip_id to tpls
-    trip2tpl_map = trips[["tpls"]].to_dict()["tpls"]
-    ls = []
-    for key, values in trip2tpl_map.items():
-        for value in values:
-            ls.append([value, key])
-    temp = pd.DataFrame(ls, columns=[tpls.index.name, "trip_id"]).set_index(tpls.index.name)
-    tpls = tpls.join(temp, how="left")
-
-    # assign trip_id to stps, for non-activity stps
-    trip2spt_map = trips[["stps"]].to_dict()["stps"]
-    ls = []
-    for key, values in trip2spt_map.items():
-        for value in values:
-            ls.append([value, key])
-    temp = pd.DataFrame(ls, columns=[stps.index.name, "trip_id"]).set_index(stps.index.name)
-    stps = stps.join(temp, how="left")
-
-    # assign prev_trip_id to stps
-    temp = trips[["destination_staypoint_id"]].copy()
-    temp.rename(columns={"destination_staypoint_id": stps.index.name}, inplace=True)
-    temp.index.name = "prev_trip_id"
-    temp = temp.reset_index().set_index(stps.index.name)
-    stps = stps.join(temp, how="left")
-
-    # assign next_trip_id to stps
-    temp = trips[["origin_staypoint_id"]].copy()
-    temp.rename(columns={"origin_staypoint_id": stps.index.name}, inplace=True)
-    temp.index.name = "next_trip_id"
-    temp = temp.reset_index().set_index(stps.index.name)
-    stps = stps.join(temp, how="left")
+    temp = trips.explode("tpls")
+    temp.index = temp["tpls"]
+    temp = temp[temp["tpls"].notna()]
+    tpls = tpls.join(temp["trip_id"], how="left")
+
+    # assign trip_id to spts, for non-activity spts
+    temp = trips.explode("spts")
+    temp.index = temp["spts"]
+    temp = temp[temp["spts"].notna()]
+    spts = spts.join(temp["trip_id"], how="left")
 
     # final cleaning
     tpls.drop(columns=["type"], inplace=True)
-    stps.drop(columns=["type"], inplace=True)
-    trips.drop(columns=["tpls", "stps"], inplace=True)
+    spts.drop(columns=["type"], inplace=True)
+    trips.drop(columns=["tpls", "spts", "trip_id"], inplace=True)
 
-    ## dtype consistency
+    # dtype consistency
     # trips id (generated by this function) should be int64
     trips.index = trips.index.astype("int64")
-    # trip id of stps and tpls can only be in Int64 (missing values)
-    stps["trip_id"] = stps["trip_id"].astype("Int64")
-    stps["prev_trip_id"] = stps["prev_trip_id"].astype("Int64")
-    stps["next_trip_id"] = stps["next_trip_id"].astype("Int64")
+    trips.index.name = "id"  # TODO: some legacy issue for tests
+    # trip id of spts and tpls can only be in Int64 (missing values)
+    spts["trip_id"] = spts["trip_id"].astype("Int64")
+    spts["prev_trip_id"] = spts["prev_trip_id"].astype("Int64")
+    spts["next_trip_id"] = spts["next_trip_id"].astype("Int64")
     tpls["trip_id"] = tpls["trip_id"].astype("Int64")
 
     # user_id of trips should be the same as tpls
     trips["user_id"] = trips["user_id"].astype(tpls["user_id"].dtype)
 
-    return stps, tpls, trips
-
-
-def _generate_trips_user(df, gap_threshold):
-    # function called after groupby: should only contain records of one user
-    user_id = df["user_id"].unique()
-    assert len(user_id) == 1
-    user_id = user_id[0]
-
-    unknown_activity = {"user_id": user_id, "activity": True, "id": np.nan}
-    origin_activity = unknown_activity
-    temp_trip_stack = []
-    in_trip = False
-    trip_ls = []
-
-    for _, row in df.iterrows():
-
-        # check if we can start a new trip
-        # (we make sure that we start the trip with the most recent activity)
-        if in_trip is False:
-            # If there are several activities in a row, we skip until the last one
-            if row["activity"] and row["activity_next"]:
-                continue
-
-            # if this is the last activity before the trip starts, reset the origin
-            elif row["activity"]:
-                origin_activity = row
-                in_trip = True
-                continue
-
-            # if for non-activities we simply start the trip
-            else:
-                in_trip = True
-
-        if in_trip is True:
-            # during trip generation/recording
-
-            # check if trip ends regularly
-            if row["activity"] is True:
-
-                # if there are no triplegs in the trip, set the current activity as origin and start over
-                if not _check_trip_stack_has_tripleg(temp_trip_stack):
-                    origin_activity = row
-                    temp_trip_stack = list()
-                    in_trip = True
-
-                else:
-                    # record trip
-                    destination_activity = row
-                    trip_ls.append(_create_trip_from_stack(temp_trip_stack, origin_activity, destination_activity))
-
-                    # set values for next trip
-                    if row["started_at_next"] - row["finished_at"] > datetime.timedelta(minutes=gap_threshold):
-                        # if there is a gap after this trip the origin of the next trip is unknown
-                        origin_activity = unknown_activity
-                        destination_activity = None
-                        temp_trip_stack = list()
-                        in_trip = False
-
-                    else:
-                        # if there is no gap after this trip the origin of the next trip is the destination of the
-                        # current trip
-                        origin_activity = destination_activity
-                        destination_activity = None
-                        temp_trip_stack = list()
-                        in_trip = False
-
-            # check if gap during the trip
-            elif row["started_at_next"] - row["finished_at"] > datetime.timedelta(minutes=gap_threshold):
-                # in case of a gap, the destination of the current trip and the origin of the next trip
-                # are unknown.
-
-                # add current item to trip
-                temp_trip_stack.append(row)
-
-                # if the trip has no recored triplegs, we do not generate the current trip.
-                if not _check_trip_stack_has_tripleg(temp_trip_stack):
-                    origin_activity = unknown_activity
-                    in_trip = True
-                    temp_trip_stack = list()
-
-                else:
-                    # add tripleg to trip, generate trip, start new trip with unknown origin
-                    destination_activity = unknown_activity
-
-                    trip_ls.append(_create_trip_from_stack(temp_trip_stack, origin_activity, destination_activity))
-                    origin_activity = unknown_activity
-                    destination_activity = None
-                    temp_trip_stack = list()
-                    in_trip = True
-
-            else:
-                temp_trip_stack.append(row)
-
-    # if user ends generate last trip with unknown destination
-    if (len(temp_trip_stack) > 0) and (_check_trip_stack_has_tripleg(temp_trip_stack)):
-        destination_activity = unknown_activity
-        trip_ls.append(
-            _create_trip_from_stack(
-                temp_trip_stack,
-                origin_activity,
-                destination_activity,
-            )
-        )
-
-    # print(trip_ls)
-    trips = pd.DataFrame(trip_ls)
-    return trips
-
-
-def _check_trip_stack_has_tripleg(temp_trip_stack):
-    """
-    Check if a trip has at least 1 tripleg.
-
-    Parameters
-    ----------
-    temp_trip_stack : list
-        list of dictionary like elements (either pandas series or python dictionary).
-        Contains all elements that will be aggregated into a trip
-
-    Returns
-    -------
-    has_tripleg: Bool
-    """
-    has_tripleg = False
-    for row in temp_trip_stack:
-        if row["type"] == "tripleg":
-            has_tripleg = True
-            break
+    return spts, tpls, trips
 
-    return has_tripleg
 
+def _get_activity_masks(df):
+    """Split activities into three groups depending if other activities.
 
-def _create_trip_from_stack(temp_trip_stack, origin_activity, destination_activity):
-    """
-    Aggregate information of trip elements in a structured dictionary.
+    Tell if activity is first (trip end), intermediate (can be deleted), or last (trip starts).
+    First and last are intended to overlap.
 
     Parameters
     ----------
-    temp_trip_stack : list
-        list of dictionary like elements (either pandas series or python dictionary).
-        Contains all elements that will be aggregated into a trip
-
-    origin_activity : dictionary like
-        Either dictionary or pandas series
-
-    destination_activity : dictionary like
-        Either dictionary or pandas series
+    df : DataFrame
+        DataFrame with boolean column "activity".
 
     Returns
     -------
-    trip_dict_entry: dictionary
-
+    is_first, is_inter, is_last
+        Three boolean Series
     """
-    # this function return and empty dict if no tripleg is in the stack
-    first_trip_element = temp_trip_stack[0]
-    last_trip_element = temp_trip_stack[-1]
-
-    # all data has to be from the same user
-    assert origin_activity["user_id"] == last_trip_element["user_id"]
-
-    # double check if trip requirements are fulfilled
-    assert origin_activity["activity"] == True
-    assert destination_activity["activity"] == True
-    assert first_trip_element["activity"] == False
-
-    trip_dict_entry = {
-        "user_id": origin_activity["user_id"],
-        "started_at": first_trip_element["started_at"],
-        "finished_at": last_trip_element["finished_at"],
-        "origin_staypoint_id": origin_activity["id"],
-        "destination_staypoint_id": destination_activity["id"],
-        "tpls": [tripleg["id"] for tripleg in temp_trip_stack if tripleg["type"] == "tripleg"],
-        "stps": [tripleg["id"] for tripleg in temp_trip_stack if tripleg["type"] == "staypoint"],
-    }
-
-    return trip_dict_entry
+    prev_activity = df["activity"].shift(1, fill_value=False)
+    next_activity = df["activity"].shift(-1, fill_value=False)
+    is_first = df["activity"] & ~prev_activity
+    is_last = df["activity"] & ~next_activity
+    is_inter = df["activity"] & ~is_first & ~is_last
+    return is_first, is_inter, is_last


Test patch:
diff --git a/tests/preprocessing/test_triplegs.py b/tests/preprocessing/test_triplegs.py
index 5d59730..4a7aa1d 100644
--- a/tests/preprocessing/test_triplegs.py
+++ b/tests/preprocessing/test_triplegs.py
@@ -1,3 +1,4 @@
+import datetime
 import os
 
 import geopandas as gpd
@@ -5,9 +6,11 @@ import numpy as np
 import pandas as pd
 import pytest
 from geopandas.testing import assert_geodataframe_equal
-from shapely.geometry import Point, LineString
+from pandas.testing import assert_frame_equal, assert_series_equal
+from shapely.geometry import LineString, Point
 
 import trackintel as ti
+from trackintel.preprocessing.triplegs import generate_trips
 
 
 class TestSmoothen_triplegs:
@@ -33,9 +36,6 @@ class TestGenerate_trips:
 
     def test_duplicate_columns(self):
         """Test if running the function twice, the generated column does not yield exception in join statement"""
-        # load pregenerated trips
-        trips_loaded = ti.read_trips_csv(os.path.join("tests", "data", "geolife_long", "trips.csv"), index_col="id")
-
         # create trips from geolife (based on positionfixes)
         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)
@@ -43,8 +43,9 @@ class TestGenerate_trips:
         pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
 
         # generate trips and a joint staypoint/triplegs dataframe
-        stps_run_1, tpls_run_1, _ = ti.preprocessing.triplegs.generate_trips(stps, tpls, gap_threshold=15)
-        stps_run_2, tpls_run_2, _ = ti.preprocessing.triplegs.generate_trips(stps_run_1, tpls_run_1, gap_threshold=15)
+        stps_run_1, tpls_run_1, _ = generate_trips(stps, tpls, gap_threshold=15)
+        with pytest.warns(UserWarning):
+            stps_run_2, tpls_run_2, _ = generate_trips(stps_run_1, tpls_run_1, gap_threshold=15)
 
         assert set(tpls_run_1.columns) == set(tpls_run_2.columns)
         assert set(stps_run_1.columns) == set(stps_run_2.columns)
@@ -63,9 +64,9 @@ class TestGenerate_trips:
         pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
 
         # generate trips and a joint staypoint/triplegs dataframe
-        stps, tpls, trips = ti.preprocessing.triplegs.generate_trips(stps, tpls, gap_threshold=15)
+        stps, tpls, trips = generate_trips(stps, tpls, gap_threshold=15)
         # test if generated trips are equal
-        pd.testing.assert_frame_equal(trips_loaded, trips)
+        assert_frame_equal(trips_loaded, trips)
 
     def test_accessor(self):
         """Test if the accessor leads to the same results as the explicit function."""
@@ -96,22 +97,22 @@ class TestGenerate_trips:
 
         # prepare data
         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)
-        stps = stps.as_staypoints.create_activity_flag(time_threshold=15)
-        pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
+        pfs, spts = pfs.as_positionfixes.generate_staypoints(method="sliding", dist_threshold=25, time_threshold=5)
+        spts = spts.as_staypoints.create_activity_flag(time_threshold=15)
+        pfs, tpls = pfs.as_positionfixes.generate_triplegs(spts)
 
         # accessor with only arguments (not allowed)
         with pytest.raises(AssertionError):
-            _, _, _ = tpls.as_triplegs.generate_trips(stps, 15)
+            _, _, _ = tpls.as_triplegs.generate_trips(spts, 15)
 
         # accessor with only keywords
-        stps_1, tpls_1, trips_1 = tpls.as_triplegs.generate_trips(stps_input=stps, gap_threshold=15)
+        spts_1, tpls_1, trips_1 = tpls.as_triplegs.generate_trips(spts=spts, gap_threshold=15)
 
         # accessor with mixed arguments/keywords
-        stps_2, tpls_2, trips_2 = tpls.as_triplegs.generate_trips(stps, gap_threshold=15)
+        spts_2, tpls_2, trips_2 = tpls.as_triplegs.generate_trips(spts, gap_threshold=15)
 
         # test if generated trips are equal (1,2)
-        assert_geodataframe_equal(stps_1, stps_2)
+        assert_geodataframe_equal(spts_1, spts_2)
         assert_geodataframe_equal(tpls_1, tpls_2)
         pd.testing.assert_frame_equal(trips_1, trips_2)
 
@@ -124,7 +125,7 @@ class TestGenerate_trips:
         pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
 
         # generate trips and a joint staypoint/triplegs dataframe
-        stps, tpls, _ = ti.preprocessing.triplegs.generate_trips(stps, tpls, gap_threshold=15)
+        stps, tpls, _ = generate_trips(stps, tpls, gap_threshold=15)
         assert pd.isna(stps["trip_id"]).any()
         assert pd.isna(stps["prev_trip_id"]).any()
         assert pd.isna(stps["next_trip_id"]).any()
@@ -138,7 +139,7 @@ class TestGenerate_trips:
         pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
 
         # generate trips and a joint staypoint/triplegs dataframe
-        stps, tpls, trips = ti.preprocessing.triplegs.generate_trips(stps, tpls, gap_threshold=15)
+        stps, tpls, trips = generate_trips(stps, tpls, gap_threshold=15)
 
         assert stps["user_id"].dtype == trips["user_id"].dtype
         assert trips.index.dtype == "int64"
@@ -148,6 +149,26 @@ class TestGenerate_trips:
         assert stps["next_trip_id"].dtype == "Int64"
         assert tpls["trip_id"].dtype == "Int64"
 
+    def test_compare_to_old_trip_function(self):
+        """Test if we can generate the example trips based on example data."""
+        # load pregenerated trips
+
+        # create trips from geolife (based on positionfixes)
+        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)
+        stps = stps.as_staypoints.create_activity_flag(time_threshold=15)
+        pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
+
+        # generate trips and a joint staypoint/triplegs dataframe
+        stps, tpls, trips = generate_trips(stps, tpls, gap_threshold=15)
+        stps_, tpls_, trips_ = _generate_trips_old(stps, tpls, gap_threshold=15)
+
+        # test if generated trips are equal
+        # ignore column order and index dtype
+        assert_frame_equal(trips, trips_, check_like=True, check_index_type=False)
+        assert_frame_equal(stps, stps_, check_like=True, check_index_type=False)
+        assert_frame_equal(tpls, tpls_, check_like=True, check_index_type=False)
+
     def test_generate_trips_index_start(self):
         """Test the generated index start from 0 for different methods."""
         pfs, _ = ti.io.dataset_reader.read_geolife(os.path.join("tests", "data", "geolife_long"))
@@ -156,7 +177,7 @@ class TestGenerate_trips:
         pfs, tpls = pfs.as_positionfixes.generate_triplegs(stps)
 
         # generate trips and a joint staypoint/triplegs dataframe
-        _, _, trips = ti.preprocessing.triplegs.generate_trips(stps, tpls, gap_threshold=15)
+        _, _, trips = generate_trips(stps, tpls, gap_threshold=15)
 
         assert (trips.index == np.arange(len(trips))).any()
 
@@ -215,14 +236,14 @@ class TestGenerate_trips:
         stps_tpls_loaded["finished_at"] = pd.to_datetime(stps_tpls_loaded["finished_at"], utc=True)
 
         # generate trips and a joint staypoint/triplegs dataframe
-        stps_proc, tpls_proc, trips = ti.preprocessing.triplegs.generate_trips(stps_in, tpls_in, gap_threshold=15)
+        stps_proc, tpls_proc, trips = generate_trips(stps_in, tpls_in, gap_threshold=gap_threshold)
         stps_tpls = _create_debug_stps_tpls_data(stps_proc, tpls_proc, gap_threshold=gap_threshold)
 
         # test if generated trips are equal
-        pd.testing.assert_frame_equal(trips_loaded, trips)
+        assert_frame_equal(trips_loaded, trips)
 
         # test if generated staypoints/triplegs are equal (especially important for trip ids)
-        pd.testing.assert_frame_equal(stps_tpls_loaded, stps_tpls, check_dtype=False)
+        assert_frame_equal(stps_tpls_loaded, stps_tpls, check_dtype=False)
 
     def test_generate_trips_id_management(self):
         """Test if we can generate the example trips based on example data."""
@@ -241,11 +262,37 @@ class TestGenerate_trips:
 
         # generate trips and a joint staypoint/triplegs dataframe
         gap_threshold = 15
-        stps, tpls, _ = ti.preprocessing.triplegs.generate_trips(stps, tpls, gap_threshold=gap_threshold)
+        stps, tpls, _ = generate_trips(stps, tpls, gap_threshold=gap_threshold)
         stps_tpls = _create_debug_stps_tpls_data(stps, tpls, gap_threshold=gap_threshold)
 
         # test if generated staypoints/triplegs are equal (especially important for trip ids)
-        pd.testing.assert_frame_equal(stps_tpls_loaded, stps_tpls, check_dtype=False)
+        assert_frame_equal(stps_tpls_loaded, stps_tpls, check_dtype=False)
+
+    def test_only_staypoints_in_trip(self):
+        """Test that trips with only staypoints (non-activities) are deleted."""
+        start = pd.Timestamp("2021-07-11 8:00:00")
+        h = pd.to_timedelta("1h")
+        spts_tpls = [
+            {"activity": True, "type": "staypoint"},
+            {"activity": False, "type": "staypoint"},
+            {"activity": True, "type": "staypoint"},
+            {"activity": False, "type": "tripleg"},
+            {"activity": False, "type": "staypoint"},
+            {"activity": True, "type": "staypoint"},
+        ]
+        for n, d in enumerate(spts_tpls):
+            d["user_id"] = 0
+            d["started_at"] = start + n * h
+            d["finished_at"] = d["started_at"] + h
+        spts_tpls = pd.DataFrame(spts_tpls)
+        spts = spts_tpls[spts_tpls["type"] == "staypoint"]
+        tpls = spts_tpls[spts_tpls["type"] == "tripleg"]
+        spts_, tpls_, trips = generate_trips(spts, tpls)
+        trip_id_truth = pd.Series([None, None, None, 0, None], dtype="Int64")
+        trip_id_truth.index = spts_.index  # don't check index
+        assert_series_equal(spts_["trip_id"], trip_id_truth, check_names=False)
+        assert (tpls_["trip_id"] == 0).all()
+        assert len(trips) == 1
 
 
 def _create_debug_stps_tpls_data(stps, tpls, gap_threshold):
@@ -266,3 +313,330 @@ def _create_debug_stps_tpls_data(stps, tpls, gap_threshold):
     stps_tpls["gap"] = (stps_tpls["started_at_next"] - stps_tpls["finished_at"]).dt.seconds / 60 > gap_threshold
 
     return stps_tpls
+
+
+def _generate_trips_old(stps_input, tpls_input, gap_threshold=15, print_progress=False):
+    """Generate trips based on staypoints and triplegs.
+
+    Parameters
+    ----------
+    stps_input : GeoDataFrame (as trackintel staypoints)
+        The staypoints have to follow the standard definition for staypoints DataFrames.
+
+    tpls_input : GeoDataFrame (as trackintel triplegs)
+        The triplegs have to follow the standard definition for triplegs DataFrames.
+
+    gap_threshold : float, default 15 (minutes)
+        Maximum allowed temporal gap size in minutes. If tracking data is missing for more than
+        `gap_threshold` minutes, then a new trip begins after the gap.
+
+    Returns
+    -------
+    staypoints: GeoDataFrame (as trackintel staypoints)
+        the original staypoints with new columns ``[`trip_id`, `prev_trip_id`, `next_trip_id`]``.
+
+    triplegs: GeoDataFrame (as trackintel triplegs)
+        The original triplegs with a new column ``[`trip_id`]``.
+
+    trips: GeoDataFrame (as trackintel trips)
+        The generated trips.
+
+    Notes
+    -----
+    Trips are an aggregation level in transport planning that summarize all movement and all non-essential actions
+    (e.g., waiting) between two relevant activities.
+    The function returns altered versions of the input staypoints and triplegs. Staypoints receive the fields
+    [`trip_id` `prev_trip_id` and `next_trip_id`], triplegs receive the field [`trip_id`].
+    The following assumptions are implemented
+
+        - All movement before the first and after the last activity is omitted
+        - If we do not record a person for more than `gap_threshold` minutes,
+          we assume that the person performed an activity in the recording gap and split the trip at the gap.
+        - Trips that start/end in a recording gap can have an unknown origin/destination
+        - There are no trips without a (recored) tripleg
+
+    Examples
+    --------
+    >>> staypoints, triplegs, trips = generate_trips(staypoints, triplegs)
+    """
+    assert "activity" in stps_input.columns, "staypoints need the column 'activities' to be able to generate trips"
+
+    # we copy the input because we need to add a temporary column
+    tpls = tpls_input.copy()
+    stps = stps_input.copy()
+
+    # if the triplegs already have a column "trip_id", we drop it
+    if "trip_id" in tpls:
+        tpls.drop(columns="trip_id", inplace=True)
+
+    # if the staypoints already have any of the columns "trip_id", "prev_trip_id", "next_trip_id", we drop them
+    for col in ["trip_id", "prev_trip_id", "next_trip_id"]:
+        if col in stps:
+            stps.drop(columns=col, inplace=True)
+
+    tpls["type"] = "tripleg"
+    stps["type"] = "staypoint"
+
+    # create table with relevant information from triplegs and staypoints.
+    stps_tpls = pd.concat(
+        [
+            stps[["started_at", "finished_at", "user_id", "type", "activity"]],
+            tpls[["started_at", "finished_at", "user_id", "type"]],
+        ]
+    )
+
+    # create ID field from index
+    stps_tpls["id"] = stps_tpls.index
+
+    # transform nan to bool
+    stps_tpls["activity"] = stps_tpls["activity"] == True
+
+    stps_tpls.sort_values(by=["user_id", "started_at"], inplace=True)
+    stps_tpls["started_at_next"] = stps_tpls["started_at"].shift(-1)
+    stps_tpls["activity_next"] = stps_tpls["activity"].shift(-1)
+
+    if print_progress:
+        tqdm.pandas(desc="User trip generation")
+        trips = (
+            stps_tpls.groupby(["user_id"], group_keys=False, as_index=False)
+            .progress_apply(_generate_trips_user, gap_threshold=gap_threshold)
+            .reset_index(drop=True)
+        )
+    else:
+        trips = (
+            stps_tpls.groupby(["user_id"], group_keys=False, as_index=False)
+            .apply(_generate_trips_user, gap_threshold=gap_threshold)
+            .reset_index(drop=True)
+        )
+
+    # index management
+    trips["id"] = np.arange(len(trips))
+    trips.set_index("id", inplace=True)
+
+    # assign trip_id to tpls
+    trip2tpl_map = trips[["tpls"]].to_dict()["tpls"]
+    ls = []
+    for key, values in trip2tpl_map.items():
+        for value in values:
+            ls.append([value, key])
+    temp = pd.DataFrame(ls, columns=[tpls.index.name, "trip_id"]).set_index(tpls.index.name)
+    tpls = tpls.join(temp, how="left")
+
+    # assign trip_id to stps, for non-activity stps
+    trip2spt_map = trips[["stps"]].to_dict()["stps"]
+    ls = []
+    for key, values in trip2spt_map.items():
+        for value in values:
+            ls.append([value, key])
+    temp = pd.DataFrame(ls, columns=[stps.index.name, "trip_id"]).set_index(stps.index.name)
+    stps = stps.join(temp, how="left")
+
+    # assign prev_trip_id to stps
+    temp = trips[["destination_staypoint_id"]].copy()
+    temp.rename(columns={"destination_staypoint_id": stps.index.name}, inplace=True)
+    temp.index.name = "prev_trip_id"
+    temp = temp.reset_index().set_index(stps.index.name)
+    stps = stps.join(temp, how="left")
+
+    # assign next_trip_id to stps
+    temp = trips[["origin_staypoint_id"]].copy()
+    temp.rename(columns={"origin_staypoint_id": stps.index.name}, inplace=True)
+    temp.index.name = "next_trip_id"
+    temp = temp.reset_index().set_index(stps.index.name)
+    stps = stps.join(temp, how="left")
+
+    # final cleaning
+    tpls.drop(columns=["type"], inplace=True)
+    stps.drop(columns=["type"], inplace=True)
+    trips.drop(columns=["tpls", "stps"], inplace=True)
+
+    ## dtype consistency
+    # trips id (generated by this function) should be int64
+    trips.index = trips.index.astype("int64")
+    # trip id of stps and tpls can only be in Int64 (missing values)
+    stps["trip_id"] = stps["trip_id"].astype("Int64")
+    stps["prev_trip_id"] = stps["prev_trip_id"].astype("Int64")
+    stps["next_trip_id"] = stps["next_trip_id"].astype("Int64")
+    tpls["trip_id"] = tpls["trip_id"].astype("Int64")
+
+    # user_id of trips should be the same as tpls
+    trips["user_id"] = trips["user_id"].astype(tpls["user_id"].dtype)
+
+    return stps, tpls, trips
+
+
+def _generate_trips_user(df, gap_threshold):
+    # function called after groupby: should only contain records of one user
+    user_id = df["user_id"].unique()
+    assert len(user_id) == 1
+    user_id = user_id[0]
+
+    unknown_activity = {"user_id": user_id, "activity": True, "id": np.nan}
+    origin_activity = unknown_activity
+    temp_trip_stack = []
+    in_trip = False
+    trip_ls = []
+
+    for _, row in df.iterrows():
+
+        # check if we can start a new trip
+        # (we make sure that we start the trip with the most recent activity)
+        if in_trip is False:
+            # If there are several activities in a row, we skip until the last one
+            if row["activity"] and row["activity_next"]:
+                continue
+
+            # if this is the last activity before the trip starts, reset the origin
+            elif row["activity"]:
+                origin_activity = row
+                in_trip = True
+                continue
+
+            # if for non-activities we simply start the trip
+            else:
+                in_trip = True
+
+        if in_trip is True:
+            # during trip generation/recording
+
+            # check if trip ends regularly
+            is_gap = row["started_at_next"] - row["finished_at"] > datetime.timedelta(minutes=gap_threshold)
+            if row["activity"] is True:
+
+                # if there are no triplegs in the trip, set the current activity as origin and start over
+                if not _check_trip_stack_has_tripleg(temp_trip_stack):
+                    origin_activity = row
+                    temp_trip_stack = list()
+                    in_trip = True
+
+                else:
+                    # record trip
+                    destination_activity = row
+                    trip_ls.append(_create_trip_from_stack(temp_trip_stack, origin_activity, destination_activity))
+
+                    # set values for next trip
+                    if is_gap:
+                        # if there is a gap after this trip the origin of the next trip is unknown
+                        origin_activity = unknown_activity
+                        destination_activity = None
+                        temp_trip_stack = list()
+                        in_trip = False
+
+                    else:
+                        # if there is no gap after this trip the origin of the next trip is the destination of the
+                        # current trip
+                        origin_activity = destination_activity
+                        destination_activity = None
+                        temp_trip_stack = list()
+                        in_trip = False
+
+            # check if gap during the trip
+            elif is_gap:
+                # in case of a gap, the destination of the current trip and the origin of the next trip
+                # are unknown.
+
+                # add current item to trip
+                temp_trip_stack.append(row)
+
+                # if the trip has no recored triplegs, we do not generate the current trip.
+                if not _check_trip_stack_has_tripleg(temp_trip_stack):
+                    origin_activity = unknown_activity
+                    in_trip = True
+                    temp_trip_stack = list()
+
+                else:
+                    # add tripleg to trip, generate trip, start new trip with unknown origin
+                    destination_activity = unknown_activity
+
+                    trip_ls.append(_create_trip_from_stack(temp_trip_stack, origin_activity, destination_activity))
+                    origin_activity = unknown_activity
+                    destination_activity = None
+                    temp_trip_stack = list()
+                    in_trip = True
+
+            else:
+                temp_trip_stack.append(row)
+
+    # if user ends generate last trip with unknown destination
+    if (len(temp_trip_stack) > 0) and (_check_trip_stack_has_tripleg(temp_trip_stack)):
+        destination_activity = unknown_activity
+        trip_ls.append(
+            _create_trip_from_stack(
+                temp_trip_stack,
+                origin_activity,
+                destination_activity,
+            )
+        )
+
+    # print(trip_ls)
+    trips = pd.DataFrame(trip_ls)
+    return trips
+
+
+def _check_trip_stack_has_tripleg(temp_trip_stack):
+    """
+    Check if a trip has at least 1 tripleg.
+
+    Parameters
+    ----------
+    temp_trip_stack : list
+        list of dictionary like elements (either pandas series or python dictionary).
+        Contains all elements that will be aggregated into a trip
+
+    Returns
+    -------
+    has_tripleg: Bool
+    """
+    has_tripleg = False
+    for row in temp_trip_stack:
+        if row["type"] == "tripleg":
+            has_tripleg = True
+            break
+
+    return has_tripleg
+
+
+def _create_trip_from_stack(temp_trip_stack, origin_activity, destination_activity):
+    """
+    Aggregate information of trip elements in a structured dictionary.
+
+    Parameters
+    ----------
+    temp_trip_stack : list
+        list of dictionary like elements (either pandas series or python dictionary).
+        Contains all elements that will be aggregated into a trip
+
+    origin_activity : dictionary like
+        Either dictionary or pandas series
+
+    destination_activity : dictionary like
+        Either dictionary or pandas series
+
+    Returns
+    -------
+    trip_dict_entry: dictionary
+
+    """
+    # this function return and empty dict if no tripleg is in the stack
+    first_trip_element = temp_trip_stack[0]
+    last_trip_element = temp_trip_stack[-1]
+
+    # all data has to be from the same user
+    assert origin_activity["user_id"] == last_trip_element["user_id"]
+
+    # double check if trip requirements are fulfilled
+    assert origin_activity["activity"] == True
+    assert destination_activity["activity"] == True
+    assert first_trip_element["activity"] == False
+
+    trip_dict_entry = {
+        "user_id": origin_activity["user_id"],
+        "started_at": first_trip_element["started_at"],
+        "finished_at": last_trip_element["finished_at"],
+        "origin_staypoint_id": origin_activity["id"],
+        "destination_staypoint_id": destination_activity["id"],
+        "tpls": [tripleg["id"] for tripleg in temp_trip_stack if tripleg["type"] == "tripleg"],
+        "stps": [tripleg["id"] for tripleg in temp_trip_stack if tripleg["type"] == "staypoint"],
+    }
+
+    return trip_dict_entry