Repository: mie-lab/trackintel
Pull request #212 (2021-05-05 12:12:04)

ENH: Add simple location identifier.
Added simple location identifier freq. That algorithm simply selects the location that the user spend the most time on.
Other "recipes" will be much easier to program as they build all on the same principle.


Patch:
diff --git a/docs/modules/analysis.rst b/docs/modules/analysis.rst
index ed4c045..49b95e5 100644
--- a/docs/modules/analysis.rst
+++ b/docs/modules/analysis.rst
@@ -19,4 +19,13 @@ Tracking Quality
 Modal Split
 ===========
 
-.. autofunction:: trackintel.analysis.modal_split.calculate_modal_split
\ No newline at end of file
+.. autofunction:: trackintel.analysis.modal_split.calculate_modal_split
+
+Location Identification
+=======================
+
+.. autofunction:: trackintel.analysis.location_identification.location_identifier
+
+.. autofunction:: trackintel.analysis.location_identification.pre_filter_locations
+
+.. autofunction:: trackintel.analysis.location_identification.freq_recipe
\ No newline at end of file
diff --git a/trackintel/analysis/location_identification.py b/trackintel/analysis/location_identification.py
new file mode 100644
index 0000000..3a85af5
--- /dev/null
+++ b/trackintel/analysis/location_identification.py
@@ -0,0 +1,231 @@
+import numpy as np
+import pandas as pd
+import trackintel as ti
+
+
+def location_identifier(spts, method="FREQ", pre_filter=True, **pre_filter_kwargs):
+    """Assign "home" and "work" activity label for each user with different methods.
+
+    Parameters
+    ----------
+    spts : Geodataframe (as trackintel staypoints)
+        Staypoints with column "location_id".
+
+    method : {'FREQ'}, default "FREQ"
+        Choose which method to use.
+
+        - FREQ: "Generate a activity label per user by assigning the most visited location the label "home"
+          and the second most visited location the label "work". The remaining locations get no label.
+
+    pre_filter : bool, default True
+        Prefiltering the staypoints to exclude locations with not enough data.
+        The filter function can also be accessed via `pre_filter_locations`.
+
+    pre_filter_kwargs : dict
+        Kwargs to hand to `pre_filter_locations` if used. See function for more informations.
+
+    Returns
+    -------
+    Geodataframe (as trackintel staypoints)
+        With additional column `activity label` assigning one of three activity labels {'home', 'work', None}.
+
+    Note
+    ----
+    The methods are adapted from [1]. The original algorithms count the distinct hours at a
+    location as the home location is derived from geo-tagged tweets. We directly sum the time
+    spent at a location as our data model includes that.
+
+    References
+    ----------
+    [1] Chen, Qingqing, and Ate Poorthuis. 2021.
+    ‘Identifying Home Locations in Human Mobility Data: An Open-Source R Package for Comparison and Reproducibility’.
+    International Journal of Geographical Information Science 0 (0): 1–24.
+    https://doi.org/10.1080/13658816.2021.1887489.
+
+    Examples
+    --------
+    >>> ti.analysis.location_identifier(spts, pre_filter=True, method="FREQ")
+    """
+    assert spts.as_staypoints
+    spts = spts.copy()
+    if "location_id" not in spts.columns:
+        raise KeyError(
+            (
+                "To derive activity labels the GeoDataFrame (as trackintel staypoints) must have a column "
+                f"named 'location_id' but it has [{', '.join(spts.columns)}]"
+            )
+        )
+    if pre_filter:
+        f = pre_filter_locations(spts, **pre_filter_kwargs)
+    else:
+        f = pd.Series(np.full(len(spts.index), True))
+
+    if method == "FREQ":
+        method_val = freq_method(spts[f], "home", "work")
+    else:
+        raise ValueError(f"Method {method} does not exist.")
+
+    spts.loc[f, "activity_label"] = method_val["activity_label"]
+    return spts
+
+
+def pre_filter_locations(
+    spts,
+    agg_level="user",
+    thresh_sp=10,
+    thresh_loc=10,
+    thresh_sp_at_loc=10,
+    thresh_loc_time="1h",
+    thresh_loc_period="5h",
+):
+    """Filter locations and user out that have not enough data to do a proper analysis.
+
+    To disable a specific filter parameter set it to zero.
+
+    Parameters
+    ----------
+    spts : GeoDataFrame (as trackintel staypoints)
+        Staypoints with the column "location_id".
+
+    agg_level: {"user", "dataset"}, default "user"
+        The level of aggregation when filtering locations:
+        - 'user' : locations are filtered per-user.
+        - 'dataset' : locations are filtered over the whole dataset.
+
+    thresh_sp : int, default 10
+        Minimum staypoints a user must have to be included.
+
+    thresh_loc : int, default 10
+        Minimum locations a user must have to be included.
+
+    thresh_sp_at_loc : int, default 10
+        Minimum number of staypoints at a location must have to be included.
+
+    thresh_loc_time : str or pd.Timedelta, default "1h"
+        Minimum sum of durations that was spent at location to be included.
+        If str must be parsable by pd.to_timedelta.
+
+    thresh_loc_period : str or pd.Timedelta, default "5h"
+        Minimum timespan of first to last visit at a location to be included.
+        If str must be parsable by pd.to_timedelta.
+
+    Returns
+    -------
+    pd.Series
+        Boolean series containing the filter as a mask.
+
+    Examples
+    --------
+    >> mask = ti.analysis.pre_filter_locations(spts)
+    >> spts = spts[mask]
+    """
+    assert spts.as_staypoints
+    spts = spts.copy()
+    if isinstance(thresh_loc_time, str):
+        thresh_loc_time = pd.to_timedelta(thresh_loc_time)
+    if isinstance(thresh_loc_period, str):
+        thresh_loc_period = pd.to_timedelta(thresh_loc_period)
+
+    # filtering users
+    user = spts.groupby("user_id").nunique()
+    user_sp = user["started_at"] >= thresh_sp  # every staypoint should have a started_at -> count
+    user_loc = user["location_id"] >= thresh_loc
+    user_filter_agg = user_sp & user_loc
+    user_filter_agg.rename("user_filter", inplace=True)  # rename for merging
+    user_filter = pd.merge(spts["user_id"], user_filter_agg, left_on="user_id", right_index=True)["user_filter"]
+
+    # filtering locations
+    spts["duration"] = spts["finished_at"] - spts["started_at"]
+    if agg_level == "user":
+        groupby_loc = ["user_id", "location_id"]
+    elif agg_level == "dataset":
+        groupby_loc = ["location_id"]
+    else:
+        raise ValueError(f"Unknown agg_level '{agg_level}' use instead {{'user', 'dataset'}}.")
+    loc = spts.groupby(groupby_loc).agg({"started_at": [min, "count"], "finished_at": max, "duration": sum})
+    loc.columns = loc.columns.droplevel(0)  # remove possible multi-index
+    loc.rename(columns={"min": "started_at", "max": "finished_at", "sum": "duration"}, inplace=True)
+    # period for maximal time span first visit - last visit.
+    # duration for effective time spent at location summed up.
+    loc["period"] = loc["finished_at"] - loc["started_at"]
+    loc_sp = loc["count"] >= thresh_sp_at_loc
+    loc_time = loc["duration"] >= thresh_loc_time
+    loc_period = loc["period"] >= thresh_loc_period
+    loc_filter_agg = loc_sp & loc_time & loc_period
+    loc_filter_agg.rename("loc_filter", inplace=True)  # rename for merging
+    loc_filter = pd.merge(spts[groupby_loc], loc_filter_agg.reset_index(), on=groupby_loc, how="left")["loc_filter"]
+
+    total_filter = user_filter & loc_filter
+
+    return total_filter
+
+
+def freq_method(spts, *labels):
+    """Generate an activity label per user by assigning the most visited location the label "home"
+    and the second most visited location the label "work" or assign your own labels. The remaining
+    locations get no label.
+
+    Parameters
+    ----------
+    spts : GeoDataFrame (as trackintel staypoints)
+        Staypoints with the column "location_id".
+    labels : collection of str, default ("home", "work")
+        Labels in decreasing time of activity.
+
+    Returns
+    -------
+    GeoDataFrame
+        with column "activity_label".
+
+    Examples
+    --------
+    >> staypoints = ti.analysis.freq_method(staypoints, "home", "work")
+    """
+    spts = spts.copy()
+    if not labels:
+        labels = ("home", "work")
+    for name, group in spts.groupby("user_id"):
+        if "duration" not in group.columns:
+            group["duration"] = group["finished_at"] - group["started_at"]
+        # pandas keeps inner order of groups
+        spts.loc[spts["user_id"] == name, "activity_label"] = _freq_transform(group, *labels)
+    return spts
+
+
+def _freq_transform(group, *labels):
+    """Transform function that assigns the longest (duration) visited locations the labels in order.
+
+    Parameters
+    ----------
+    group : pd.DataFrame
+        Should have columns "location_id" and "duration".
+
+    Returns
+    -------
+    pd.Series
+        dtype : `object`
+    """
+    group_agg = group.groupby("location_id").agg({"duration": sum})
+    group_agg["activity_label"] = _freq_assign(group_agg["duration"], *labels)
+    group_merge = pd.merge(
+        group["location_id"], group_agg["activity_label"], how="left", left_on="location_id", right_index=True
+    )
+    return group_merge["activity_label"]
+
+
+def _freq_assign(duration, *labels):
+    """Assign k labels to k longest durations the rest is `None`.
+
+    Parameters
+    ----------
+    duration : pd.Series
+
+    Returns
+    -------
+    np.array
+        dtype : object
+    """
+    kth = (-duration).argsort()[: len(labels)]  # if inefficient use partial sort.
+    label_array = np.full(len(duration), fill_value=None)
+    label_array[kth] = labels
+    return label_array
diff --git a/trackintel/preprocessing/positionfixes.py b/trackintel/preprocessing/positionfixes.py
index 650c5a3..46afbf9 100644
--- a/trackintel/preprocessing/positionfixes.py
+++ b/trackintel/preprocessing/positionfixes.py
@@ -85,6 +85,10 @@ def generate_staypoints(
     # copy the original pfs for adding 'staypoint_id' column
     pfs = pfs_input.copy()
 
+    # if the positionfixes already have a column "staypoint_id", we drop it
+    if "staypoint_id" in pfs:
+        pfs.drop(columns="staypoint_id", inplace=True)
+
     elevation_flag = "elevation" in pfs.columns  # if there is elevation data
 
     geo_col = pfs.geometry.name
@@ -214,6 +218,10 @@ def generate_triplegs(pfs_input, stps_input, method="between_staypoints", gap_th
     # copy the original pfs for adding 'staypoint_id' column
     pfs = pfs_input.copy()
 
+    # if the positionfixes already have a column "tripleg_id", we drop it
+    if "tripleg_id" in pfs:
+        pfs.drop(columns="tripleg_id", inplace=True)
+
     if method == "between_staypoints":
 
         # get case:
diff --git a/trackintel/preprocessing/triplegs.py b/trackintel/preprocessing/triplegs.py
index 066c95b..f599a38 100644
--- a/trackintel/preprocessing/triplegs.py
+++ b/trackintel/preprocessing/triplegs.py
@@ -91,6 +91,15 @@ def generate_trips(stps_input, tpls_input, gap_threshold=15, print_progress=Fals
     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"
 


Test patch:
diff --git a/tests/analysis/test_location_identification.py b/tests/analysis/test_location_identification.py
new file mode 100644
index 0000000..7d47d72
--- /dev/null
+++ b/tests/analysis/test_location_identification.py
@@ -0,0 +1,232 @@
+import datetime
+
+import geopandas as gpd
+from geopandas.testing import assert_geodataframe_equal
+import numpy as np
+import pandas as pd
+import pytest
+from shapely.geometry import Point
+
+import trackintel as ti
+from trackintel.analysis.location_identification import (
+    pre_filter_locations,
+    _freq_transform,
+    freq_method,
+    _freq_assign,
+    location_identifier,
+)
+
+
+@pytest.fixture
+def example_staypoints():
+    """Staypoints to test pre_filter."""
+    p1 = Point(8.5067847, 47.4)
+    p2 = Point(8.5067847, 47.5)
+
+    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-01 07:00:00", tz="utc")
+    t4 = pd.Timestamp("1971-01-02 23:00:00", tz="utc")  # for time testin bigger gap
+    one_hour = datetime.timedelta(hours=1)
+
+    list_dict = [
+        {"user_id": 0, "started_at": t1, "finished_at": t2, "geometry": p1, "location_id": 0},
+        {"user_id": 0, "started_at": t2, "finished_at": t3, "geometry": p1, "location_id": 0},
+        {"user_id": 0, "started_at": t3, "finished_at": t4, "geometry": p2, "location_id": 1},  # longest duration
+        {"user_id": 1, "started_at": t4, "finished_at": t4 + one_hour, "geometry": p1, "location_id": 0},
+    ]
+    spts = gpd.GeoDataFrame(data=list_dict, geometry="geometry", crs="EPSG:4326")
+    spts.index.name = "id"
+    assert spts.as_staypoints
+    assert "location_id" in spts.columns
+    return spts
+
+
+@pytest.fixture
+def default_kwargs():
+    kw = {
+        "agg_level": "dataset",
+        "thresh_sp": 0,
+        "thresh_loc": 0,
+        "thresh_sp_at_loc": 0,
+        "thresh_loc_time": "0h",
+        "thresh_loc_period": "0h",
+    }
+    return kw
+
+
+class TestPre_Filter:
+    """Tests for the function `pre_filter_locations()`."""
+
+    def test_no_kw(self, example_staypoints, default_kwargs):
+        """Test that nothing gets filtered if all parameters are set to zero."""
+        assert all(pre_filter_locations(example_staypoints, **default_kwargs))
+
+    def test_thresh_sp(self, example_staypoints, default_kwargs):
+        """Test the minimum staypoint per user parameter."""
+        default_kwargs["thresh_sp"] = 2
+        f = pre_filter_locations(example_staypoints, **default_kwargs)
+        assert all(f == (example_staypoints["user_id"] == 0))
+
+    def test_thresh_loc(self, example_staypoints, default_kwargs):
+        """Test the minimum location per user parameter."""
+        default_kwargs["thresh_loc"] = 2
+        f = pre_filter_locations(example_staypoints, **default_kwargs)
+        assert all(f == (example_staypoints["user_id"] == 0))
+
+    def test_thresh_sp_at_loc(self, example_staypoints, default_kwargs):
+        """Test the minimum staypoint per location parameter."""
+        default_kwargs["thresh_sp_at_loc"] = 2
+        f = pre_filter_locations(example_staypoints, **default_kwargs)
+        assert all(f == (example_staypoints["location_id"] == 0))
+
+    def test_tresh_loc_time(self, example_staypoints, default_kwargs):
+        """Test the minimum duration per location parameter."""
+        default_kwargs["thresh_loc_time"] = "14h"
+        f = pre_filter_locations(example_staypoints, **default_kwargs)
+        assert all(f == (example_staypoints["location_id"] == 1))
+
+    def test_loc_period(self, example_staypoints, default_kwargs):
+        """Test the minimum period per location parameter."""
+        default_kwargs["thresh_loc_period"] = "2d"
+        f = pre_filter_locations(example_staypoints, **default_kwargs)
+        assert all(f == (example_staypoints["location_id"] == 0))
+
+    def test_agg_level(self, example_staypoints, default_kwargs):
+        """Test if different aggregation works."""
+        default_kwargs["agg_level"] = "user"
+        default_kwargs["thresh_loc_period"] = pd.Timedelta("1d")
+        f = pre_filter_locations(example_staypoints, **default_kwargs)
+        assert all(f == (example_staypoints[["user_id", "location_id"]] == [0, 1]).all(axis=1))
+
+    def test_agg_level_error(self, example_staypoints, default_kwargs):
+        """Test if ValueError is raised for unknown agg_level."""
+        default_kwargs["agg_level"] = "unknown"
+        with pytest.raises(ValueError):
+            pre_filter_locations(example_staypoints, **default_kwargs)
+
+
+@pytest.fixture
+def example_freq():
+    """Example staypoints with 4 location for 2 users with [3, 2, 1, 1] staypoint per location."""
+    list_dict = [
+        {"user_id": 0, "location_id": 0},
+        {"user_id": 0, "location_id": 0},
+        {"user_id": 0, "location_id": 0},
+        {"user_id": 0, "location_id": 1},
+        {"user_id": 0, "location_id": 1},
+        {"user_id": 0, "location_id": 2},
+        {"user_id": 0, "location_id": 3},
+        {"user_id": 1, "location_id": 0},
+        {"user_id": 1, "location_id": 0},
+        {"user_id": 1, "location_id": 0},
+        {"user_id": 1, "location_id": 1},
+        {"user_id": 1, "location_id": 1},
+        {"user_id": 1, "location_id": 2},
+        {"user_id": 1, "location_id": 3},
+    ]
+    p1 = Point(8.5067847, 47.1)  # geometry isn't used
+    t1 = pd.Timestamp("1971-01-01 00:00:00", tz="utc")
+    t2 = pd.Timestamp("1971-01-01 01:00:00", tz="utc")
+    for d in list_dict:
+        d["started_at"] = t1
+        d["finished_at"] = t2
+        d["geom"] = p1
+
+    spts = gpd.GeoDataFrame(data=list_dict, geometry="geom", crs="EPSG:4326")
+    spts.index.name = "id"
+    assert "location_id" in spts.columns
+    assert spts.as_staypoints
+    return spts
+
+
+class TestFreq_method:
+    """Test freq_method."""
+
+    def test_default_labels(self, example_freq):
+        """Test method with default labels"""
+        freq = freq_method(example_freq)
+        example_freq["activity_label"] = None
+        example_freq.loc[example_freq["location_id"] == 0, "activity_label"] = "home"
+        example_freq.loc[example_freq["location_id"] == 1, "activity_label"] = "work"
+        assert freq["activity_label"].count() == example_freq["activity_label"].count()
+        assert_geodataframe_equal(example_freq, freq)
+
+    def test_custom_labels(self, example_freq):
+        """Test method with custom label of a different length"""
+        custom_label = "doing_nothing"
+        freq = freq_method(example_freq, "doing_nothing")
+        example_freq["activity_label"] = None
+        example_freq.loc[example_freq["location_id"] == 0, "activity_label"] = custom_label
+        assert freq["activity_label"].count() == example_freq["activity_label"].count()
+        assert_geodataframe_equal(example_freq, freq)
+
+    def test_duration(self, example_freq):
+        """Test if function can handle only "duration" column and no columns "started_at", "finished_at"."""
+        example_freq["duration"] = example_freq["finished_at"] - example_freq["started_at"]
+        del example_freq["finished_at"]
+        del example_freq["started_at"]
+        freq = freq_method(example_freq)
+        example_freq["activity_label"] = None
+        example_freq.loc[example_freq["location_id"] == 0, "activity_label"] = "home"
+        example_freq.loc[example_freq["location_id"] == 1, "activity_label"] = "work"
+        assert freq["activity_label"].count() == example_freq["activity_label"].count()
+        assert_geodataframe_equal(example_freq, freq)
+
+
+class Test_Freq_Transform:
+    """Test help function _freq_transform."""
+
+    def test_function(self):
+        """Test if groupby assign works."""
+        list_dict = [
+            {"location_id": 0, "duration": 1},
+            {"location_id": 0, "duration": 1},
+            {"location_id": 1, "duration": 1},
+        ]
+        df = pd.DataFrame(list_dict)
+        freq = _freq_transform(df, "work")
+        sol = pd.Series(["work", "work", None])
+        assert freq.equals(sol)
+
+
+class Test_Freq_Assign:
+    """Test help function _freq_assign."""
+
+    def test_function(self):
+        """Test function with simple input."""
+        dur = pd.Series([9, 0, 8, 1, 7, 6, 5])
+        labels = ("label1", "label2", "label3")
+        freq_sol = np.array([labels[0], None, labels[1], None, labels[2], None, None])
+        freq = _freq_assign(dur, *labels)
+        assert all(freq == freq_sol)
+
+
+class TestLocation_Identifier:
+    """Test function `location_identifier`"""
+
+    def test_unkown_method(self, example_staypoints):
+        """Test if ValueError is risen if method is unknown"""
+        with pytest.raises(ValueError):
+            location_identifier(example_staypoints, method="UNKNOWN", pre_filter=False)
+
+    def test_no_location_column(self, example_staypoints):
+        """Test if key error is risen if no column `location_id`."""
+        with pytest.raises(KeyError):
+            del example_staypoints["location_id"]
+            location_identifier(example_staypoints)
+
+    def test_pre_filter(self, example_freq, default_kwargs):
+        """Test if function calls pre_filter correctly."""
+        default_kwargs["agg_level"] = "user"
+        default_kwargs["thresh_sp_at_loc"] = 2
+        li = location_identifier(example_freq, method="FREQ", pre_filter=True, **default_kwargs)
+        f = pre_filter_locations(example_freq, **default_kwargs)
+        example_freq.loc[f, "activity_label"] = freq_method(example_freq[f])["activity_label"]
+        assert_geodataframe_equal(li, example_freq)
+
+    def test_freq_method(self, example_freq):
+        """Test if function calls freq method correctly."""
+        li = location_identifier(example_freq, method="FREQ", pre_filter=False)
+        fr = freq_method(example_freq)
+        assert_geodataframe_equal(li, fr)
diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py
index 3dbc29f..f94e5d4 100644
--- a/tests/examples/test_examples.py
+++ b/tests/examples/test_examples.py
@@ -5,6 +5,6 @@ import os
 
 class Test_examples:
     def test_example_preprocess_trajectories(self):
-        """checks if the example 'preprocess_trajectories' runs without errors """
+        """checks if the example 'preprocess_trajectories' runs without errors"""
 
         exec(open(os.path.join("examples", "preprocess_trajectories.py")).read())
diff --git a/tests/preprocessing/test_positionfixes.py b/tests/preprocessing/test_positionfixes.py
index b4664ca..e16b9a9 100644
--- a/tests/preprocessing/test_positionfixes.py
+++ b/tests/preprocessing/test_positionfixes.py
@@ -23,6 +23,20 @@ def geolife_pfs_stps_long():
 class TestGenerate_staypoints:
     """Tests for generate_staypoints() method."""
 
+    def test_duplicate_columns(self):
+        """Test if running the function twice, the generated column does not yield exception in join statement"""
+
+        # we run generate_staypoints twice in order to check that the extra column(tripleg_id) does
+        # not cause any problems in the second run
+        pfs, _ = ti.io.dataset_reader.read_geolife(os.path.join("tests", "data", "geolife"))
+        pfs_run_1, _ = pfs.as_positionfixes.generate_staypoints(
+            method="sliding", dist_threshold=100, time_threshold=5.0, include_last=True
+        )
+        pfs_run_2, _ = pfs_run_1.as_positionfixes.generate_staypoints(
+            method="sliding", dist_threshold=100, time_threshold=5.0, include_last=True
+        )
+        assert set(pfs_run_1.columns) == set(pfs_run_2.columns)
+
     def test_sliding_min(self):
         """Test if using small thresholds, stp extraction yields each pfs."""
         pfs, _ = ti.io.dataset_reader.read_geolife(os.path.join("tests", "data", "geolife"))
@@ -123,6 +137,17 @@ class TestGenerate_staypoints:
 class TestGenerate_triplegs:
     """Tests for generate_triplegs() method."""
 
+    def test_duplicate_columns(self, geolife_pfs_stps_long):
+        """Test if running the function twice, the generated column does not yield exception in join statement"""
+
+        # we run generate_triplegs twice in order to check that the extra column (tripleg_id) does
+        # not cause any problems in the second run
+        pfs, stps = geolife_pfs_stps_long
+
+        pfs_run_1, _ = pfs.as_positionfixes.generate_triplegs(stps, method="between_staypoints")
+        pfs_run_2, _ = pfs_run_1.as_positionfixes.generate_triplegs(stps, method="between_staypoints")
+        assert set(pfs_run_1.columns) == set(pfs_run_2.columns)
+
     def test_user_without_stps(self, geolife_pfs_stps_long):
         """Check if it is safe to have users that have pfs but no stps."""
         pfs, stps = geolife_pfs_stps_long
diff --git a/tests/preprocessing/test_triplegs.py b/tests/preprocessing/test_triplegs.py
index 76d793a..40e3388 100644
--- a/tests/preprocessing/test_triplegs.py
+++ b/tests/preprocessing/test_triplegs.py
@@ -29,6 +29,24 @@ class TestSmoothen_triplegs:
 class TestGenerate_trips:
     """Tests for generate_trips() method."""
 
+    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)
+        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_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)
+
+        assert set(tpls_run_1.columns) == set(tpls_run_2.columns)
+        assert set(stps_run_1.columns) == set(stps_run_2.columns)
+
     def test_generate_trips(self):
         """Test if we can generate the example trips based on example data."""
         # load pregenerated trips