Repository: mie-lab/trackintel

Issue:
Support for horizontal aggregation/segmentation
One of the main points of trackintel is that is offers vertical aggregation/segmentation functions (e.g., positionfixes -> staypoints -> locations). A feature that I need every now and then would be support for horizontal segmentation and aggregation. This means to merge or split individual staypoints (or triplegs etc). A good example where this is useful is when we want to analyze daily mobility but have trips/staypoints/triplegs that span several days. It is then necessary to split staypoints at midnight (like we do when we calculate tracking quality for staypoints [here](https://github.com/mie-lab/trackintel/blob/master/trackintel/analysis/tracking_quality.py#L206) or like we probably should do when calculating modal split based on triplegs [here](https://github.com/mie-lab/trackintel/blob/master/trackintel/analysis/modal_split.py#L69))

Often forgotten but equally important is the functionality to merge consecutive staypoints or triplegs. Most prominently SBB Green Class data had the issue (chances are that the same is true for mobis) that a single activity is split into several consecutive staypoints. e.g., 
|staypoint id   | activity  | timestamp |
|---|---|---|
|1 | work   | 09:00-17:00 |
|2 | home  | 17:30 - 22:00 |
|3 |  home | 22:00 - 05:00 |
|4 |  home | 05:00 - 08:30 |
|5 |  work | 09:00 - 17:00 |

These artifacts could for example lead to errors when calculating features like the duration of a trip destination. A way to solve this for staypoints would be to merge all consecutive staypoints within a certain spatio-temporal radius

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/297?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
> Merging [#297](https://codecov.io/gh/mie-lab/trackintel/pull/297?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (0f88e28) into [master](https://codecov.io/gh/mie-lab/trackintel/commit/33d6205475ad8c4a859a9a5883241fe1098997b2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (33d6205) will **decrease** coverage by `2.80%`.
> The diff coverage is `81.57%`.

[![Impacted file tree graph](https://codecov.io/gh/mie-lab/trackintel/pull/297/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/297?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     #297      +/-   ##
==========================================
- Coverage   96.04%   93.23%   -2.81%     
==========================================
  Files          39       39              
  Lines        1693     1730      +37     
  Branches      266      272       +6     
==========================================
- Hits         1626     1613      -13     
- Misses         35       79      +44     
- Partials       32       38       +6     
```


| [Impacted Files](https://codecov.io/gh/mie-lab/trackintel/pull/297?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/preprocessing/staypoints.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9wcmVwcm9jZXNzaW5nL3N0YXlwb2ludHMucHk=) | `89.18% <79.41%> (-4.32%)` | :arrow_down: |
| [trackintel/model/staypoints.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC9zdGF5cG9pbnRzLnB5) | `98.14% <100.00%> (-1.86%)` | :arrow_down: |
| [trackintel/io/postgis.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9pby9wb3N0Z2lzLnB5) | `49.33% <0.00%> (-45.34%)` | :arrow_down: |
| [trackintel/io/from\_geopandas.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9pby9mcm9tX2dlb3BhbmRhcy5weQ==) | `90.00% <0.00%> (-8.00%)` | :arrow_down: |
| [trackintel/model/locations.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC9sb2NhdGlvbnMucHk=) | `96.87% <0.00%> (-3.13%)` | :arrow_down: |
| [trackintel/model/trips.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC90cmlwcy5weQ==) | `96.96% <0.00%> (-3.04%)` | :arrow_down: |
| [trackintel/model/positionfixes.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC9wb3NpdGlvbmZpeGVzLnB5) | `97.82% <0.00%> (-2.18%)` | :arrow_down: |
| [trackintel/model/triplegs.py](https://codecov.io/gh/mie-lab/trackintel/pull/297/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC90cmlwbGVncy5weQ==) | `98.27% <0.00%> (-1.73%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/297?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/297?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab). Last update [33d6205...0f88e28](https://codecov.io/gh/mie-lab/trackintel/pull/297?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).

hong2223: for the first point: splitting I think refers to temporally splitting, as we do in the [_split_overlaps](https://github.com/mie-lab/trackintel/blob/560c5f6e29cebc6ff84424c7f0f3d85eadac1e27/trackintel/analysis/tracking_quality.py#L206) function to split records that span several days. So far I can only think of that use case of `splitting` and I think you do not need to add extra functions to this PR
hong2223: for the second and third points (sorry for splitting the comments):

> Probably, it would be good if this function again is not dependent on the locations column. Should I maybe set a parameter max_dist again to use the geom column for aggregating, if the location_id column is not available?

- IMO, doing this is also a spatial clustering of staypoints as we do when generating locations, except that we do not assign an id to the result. So I think this might not be necessary and we should require location_id for the function.

> Issue Support for horizontal aggregation/segmentation #257 sounds more general, i.e. not only about staypoints. How would a merge of triplegs look like?

- To be honest, I also do not know how to merge triplegs. triplegs might be merged if they are connected spatiotemporally, but the travel mode also plays a role: we might be able to merge two triplegs with mode walk, but two triplegs with mode bus might indicate the person is travelling by two bus lines, which should not be merged. So I'd say we leave tripleg merging until we find a use case.

hong2223: Just a comment:

I am thinking of the cases in #257 and our processing pipeline (we generate staypoints from pfs and regard all the other pfs as triplegs). Setting the `max_time_gap` in `merge_staypoints(stps, max_time_gap="10min")` larger than 0 would potentially merge stps1-tpls-stps2 sequences into a single stps1, which is then not backward compatible, i.e., the correspondences of pfs-stp and pfs-tpls are now wrong. Do we need extra steps (e.g. tpls should be deleted and pfs of both tpls and stps2 assigned to stps1) to ensure compatibility or what is the use case of such a function?
henrymartin1: 
> Open questions:
> 
>     * Issue [Support for horizontal aggregation/segmentation #257](https://github.com/mie-lab/trackintel/issues/257) is about "aggregation / segmentation", but I don't understand so far how staypoints could be segmented?
As @hong2223 said. So I would only focus on aggregation for staypoints for now. 

>     * Probably, it would be good if this function again is not dependent on the locations column. Should I maybe set a parameter `max_dist` again to use the `geom` column for aggregating, if the `location_id` column is not available?
Sure, why not. If it is not too much work, it would be a plus to include this. 

>     * Issue [Support for horizontal aggregation/segmentation #257](https://github.com/mie-lab/trackintel/issues/257) sounds more general, i.e. not only about staypoints. How would a merge of triplegs look like?
Hm... for triplegs the problem is much more complicated as. Especially if the solution should be generic. Triplegs should by definition only contain a single continuous transport mode. So if we would have a way to determine that two consecutive triplegs were both of mode walk we could merge them. In practice however, this information is hard to get only based on the movement data itself. 
For splitting it is a bit easier so we did for example compare public transport triplegs with the public transport network and schedule and could identify where to split the tripleg. 


> 
> 
> Just a comment:
> 
> I am thinking of the cases in #257 and our processing pipeline (we generate staypoints from pfs and regard all the other pfs as triplegs). Setting the `max_time_gap` in `merge_staypoints(stps, max_time_gap="10min")` larger than 0 would potentially merge stps1-tpls-stps2 sequences into a single stps1, which is then not backward compatible, i.e., the correspondences of pfs-stp and pfs-tpls are now wrong. Do we need extra steps (e.g. tpls should be deleted and pfs of both tpls and stps2 assigned to stps1) to ensure compatibility or what is the use case of such a function?

Ah ok, interesting case. I don't think that every function has to be fully compatible end-to-end (e.g., from positionfixes to tours). Often it will depend on your definitions of staypoints. So if you say that you want to merge staypoints if there is only a 10 minute gap in between then that's what you get 🤷‍♂️

Hm... having potentially overlapping staypoints and triplegs are indeed be a problem for the trip generation. I am not really sure what to do about it. I would like to avoid going back to the positionfixes as it is probably a lot of work to perform this backwards assignment. 
The main use-cases that I have in mind comes from datasets such as GC1 where we work with only staypoints/triplegs (so not with positionfixes) and often have these chains staypoints with very small time gaps in between at the same location with the same activity level.
NinaWie: Okay I'm not sure whether I can follow - should we discuss it in the next meeting? Is there anything I can do in the meantime on this PR? 
If you check the code, there are some other open questions coming up, e.g. which one to use as `activity`, `next_trip_id`, `trip_id` etc when aggregating - should these all be lists?
NinaWie: I modified the function as discussed to take the triplegs as input as well and to only merge staypoints if there is not tripleg inbetween. I also added documentation saying that the pfs-stps relation is broken after staypoint aggregation, and I added some tests.
Should be ready to be reviewed again by @henrymartin1  or @hong2223 
NinaWie: Thanks for the suggestions, I changed these things! Hope it's better now, let me know what else is there to do. And sorry for all the e-mails - I forgot that it's better to answer in a review instead of commenting to each of your comments.
hong2223: > Thanks for the suggestions, I changed these things! Hope it's better now, let me know what else is there to do. And sorry for all the e-mails - I forgot that it's better to answer in a review instead of commenting to each of your comments.

Thanks for the fix 👍. I changed some typos and we are ready to merge once the tests have passed. Job done :).

Patch:
diff --git a/trackintel/model/staypoints.py b/trackintel/model/staypoints.py
index 1a7ebb1..6586e46 100644
--- a/trackintel/model/staypoints.py
+++ b/trackintel/model/staypoints.py
@@ -6,7 +6,7 @@ from trackintel.io.file import write_staypoints_csv
 from trackintel.io.postgis import write_staypoints_postgis
 from trackintel.model.util import copy_docstring
 from trackintel.preprocessing.filter import spatial_filter
-from trackintel.preprocessing.staypoints import generate_locations
+from trackintel.preprocessing.staypoints import generate_locations, merge_staypoints
 from trackintel.visualization.staypoints import plot_staypoints
 
 
@@ -87,6 +87,15 @@ class StaypointsAccessor(object):
         """
         return ti.preprocessing.staypoints.generate_locations(self._obj, *args, **kwargs)
 
+    @copy_docstring(merge_staypoints)
+    def merge_staypoints(self, *args, **kwargs):
+        """
+        Aggregate staypoints horizontally via time threshold.
+
+        See :func:`trackintel.preprocessing.staypoints.merge_staypoints`.
+        """
+        return ti.preprocessing.staypoints.merge_staypoints(self._obj, *args, **kwargs)
+
     @copy_docstring(create_activity_flag)
     def create_activity_flag(self, *args, **kwargs):
         """
diff --git a/trackintel/preprocessing/staypoints.py b/trackintel/preprocessing/staypoints.py
index 8161227..1c571f9 100644
--- a/trackintel/preprocessing/staypoints.py
+++ b/trackintel/preprocessing/staypoints.py
@@ -226,3 +226,116 @@ def _generate_locations_per_user(user_staypoints, distance_metric, db, geo_col):
     user_staypoints = gpd.GeoDataFrame(user_staypoints, geometry=geo_col)
 
     return user_staypoints
+
+
+def merge_staypoints(staypoints, triplegs, max_time_gap="10min", agg={}):
+    """
+    Aggregate staypoints horizontally via time threshold.
+
+    Parameters
+    ----------
+    staypoints : GeoDataFrame (as trackintel staypoints)
+        The staypoints have to follow the standard definition for staypoints DataFrames.
+
+    triplegs: GeoDataFrame (as trackintel triplegs)
+        The triplegs have to follow the standard definition for triplegs DataFrames.
+
+    max_time_gap : str or pd.Timedelta, default "1h"
+        Maximum duration between staypoints to still be merged.
+        If str must be parsable by pd.to_timedelta.
+
+    agg: dict, optional
+        Dictionary to aggregate the rows after merging staypoints. This dictionary is used as input to the pandas
+        aggregate function: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.agg.html
+        If empty, only the required columns of staypoints (which are ['user_id', 'started_at', 'finished_at']) are
+        aggregated and returned. In order to return for example also the geometry column of the merged staypoints, set
+        'agg={"geom":"first"}' to return the first geometry of the merged staypoints, or 'agg={"geom":"last"}' to use
+        the last one.
+
+    Returns
+    -------
+    sp: DataFrame
+        The new staypoints with the default columns and columns in `agg`, where staypoints at same location and close in
+        time are aggregated.
+
+    Notes
+    -----
+    - Due to the modification of the staypoint index, the relation between the staypoints and the corresponding
+      positionfixes **is broken** after execution of this function! In explanation, the staypoint_id column of pfs does
+      not necessarily correspond to an id in the new sp table that is returned from this function. The same holds for
+      trips (if generated yet) where the staypoints contained in a trip might be merged in this function.
+    - If there is a tripleg between two staypoints, the staypoints are not merged. If you for some reason want to merge
+      such staypoints, simply pass an empty DataFrame for the tpls argument.
+
+    Examples
+    --------
+    >>> # direct function call
+    >>> ti.preprocessing.staypoints.merge_staypoints(staypoints=sp, triplegs=tpls)
+    >>> # or using the trackintel datamodel
+    >>> sp.as_staypoints.merge_staypoints(triplegs, max_time_gap="1h", agg={"geom":"first"})
+    """
+    if isinstance(max_time_gap, str):
+        max_time_gap = pd.to_timedelta(max_time_gap)
+    # otherwise check if it's a Timedelta already, and raise error if not
+    elif not isinstance(max_time_gap, pd.Timedelta):
+        raise TypeError("Parameter max_time_gap must be either of type String or pd.Timedelta!")
+
+    sp_merge = staypoints.copy()
+    index_name = staypoints.index.name
+
+    # concat sp and tpls to get information whether there is a tripleg between to staypoints
+    tpls_merge = triplegs.copy()
+    tpls_merge["type"] = "tripleg"
+    sp_merge["type"] = "staypoint"
+    # a joined dataframe sp_tpls is constructed to add the columns 'type' and 'next_type' to the 'sp_merge' table
+    # concat and sort by time
+    sp_tpls = pd.concat([sp_merge, tpls_merge]).sort_values(by=["user_id", "started_at"])
+    sp_tpls.index.rename(index_name, inplace=True)
+    # get information whether the there is a tripleg after a staypoint
+    sp_tpls["next_type"] = sp_tpls["type"].shift(-1)
+    # get only staypoints, but with next type information
+    sp_merge = sp_tpls[sp_tpls["type"] == "staypoint"]
+
+    # reset index and make temporary index
+    sp_merge = sp_merge.reset_index()
+    # copy index to use it in the end (id is modified)
+    sp_merge["index_temp"] = sp_merge[index_name]
+
+    # roll by 1 to get next row info
+    sp_merge[["next_user_id", "next_started_at", "next_location_id"]] = sp_merge[
+        ["user_id", "started_at", "location_id"]
+    ].shift(-1)
+    # Conditions to keep on merging
+    cond = pd.Series(data=False, index=sp_merge.index)
+    cond_old = pd.Series(data=True, index=sp_merge.index)
+    cond_diff = cond != cond_old
+
+    while np.sum(cond_diff) >= 1:
+        # .values is important otherwise the "=" would imply a join via the new index
+        sp_merge["next_id"] = sp_merge["index_temp"].shift(-1).values
+
+        # identify rows to merge
+        cond0 = sp_merge["next_user_id"] == sp_merge["user_id"]
+        cond1 = sp_merge["next_started_at"] - sp_merge["finished_at"] <= max_time_gap  # time constraint
+        cond2 = sp_merge["location_id"] == sp_merge["next_location_id"]
+        cond3 = sp_merge["index_temp"] != sp_merge["next_id"]  # already merged
+        cond4 = sp_merge["next_type"] != "tripleg"  # no tripleg inbetween two staypoints
+        cond = cond0 & cond1 & cond2 & cond3 & cond4
+
+        # assign index to next row
+        sp_merge.loc[cond, "index_temp"] = sp_merge.loc[cond, "next_id"]
+        # check whether anything was changed
+        cond_diff = cond != cond_old
+        cond_old = cond.copy()
+
+    # Staypoint-required columnsare aggregated in the following manner:
+    agg_dict = {index_name: "first", "user_id": "first", "started_at": "first", "finished_at": "last"}
+    # User-defined further aggregation
+    agg_dict.update(agg)
+
+    # aggregate values
+    sp = sp_merge.groupby(by="index_temp").agg(agg_dict).sort_values(by=["user_id", "started_at"])
+
+    # clean
+    sp = sp.set_index(index_name)
+    return sp


Test patch:
diff --git a/tests/preprocessing/test_staypoints.py b/tests/preprocessing/test_staypoints.py
index d0b2c31..fdb99f9 100644
--- a/tests/preprocessing/test_staypoints.py
+++ b/tests/preprocessing/test_staypoints.py
@@ -38,8 +38,6 @@ def example_staypoints():
     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")
-    t6 = pd.Timestamp("1971-01-02 10:00:00", tz="utc")
-    t6 = pd.Timestamp("1971-01-02 10:00:00", tz="utc")
     one_hour = datetime.timedelta(hours=1)
 
     list_dict = [
@@ -58,6 +56,70 @@ def example_staypoints():
     return sp
 
 
+@pytest.fixture
+def example_staypoints_merge():
+    """
+    Staypoints for merge operation
+
+    The following staypoints should be merged: id 2,6,15 for user 0 and 7, 80 for user 1
+    Note: id 5 and 2 should not be merged because of time gap
+    It must be ensured that 15 and 7 are not merged (different user)
+    """
+    p1 = Point(8.5067847, 47.4)
+
+    t1 = pd.Timestamp("1971-01-01 00:00:00", tz="utc")
+    t2 = pd.Timestamp("1971-01-02 05:00:00", tz="utc")
+    t3 = pd.Timestamp("1971-01-02 06:45:00", tz="utc")
+    t4 = pd.Timestamp("1971-01-02 08:55:00", tz="utc")
+    t45 = pd.Timestamp("1971-01-02 08:57:00", tz="utc")
+    t5 = pd.Timestamp("1971-01-02 09:00:00", tz="utc")
+    t6 = pd.Timestamp("1971-01-02 09:20:00", tz="utc")
+    one_hour = datetime.timedelta(hours=1)
+
+    list_dict = [
+        {"id": 1, "user_id": 0, "started_at": t1, "finished_at": t2, "geom": p1, "location_id": 1},
+        {"id": 5, "user_id": 0, "started_at": t2, "finished_at": t2, "geom": p1, "location_id": 2},
+        {"id": 2, "user_id": 0, "started_at": t3, "finished_at": t4, "geom": p1, "location_id": 2},
+        {"id": 6, "user_id": 0, "started_at": t4, "finished_at": t45, "geom": p1, "location_id": 2},
+        {"id": 15, "user_id": 0, "started_at": t5, "finished_at": t6, "geom": p1, "location_id": 2},
+        {"id": 7, "user_id": 1, "started_at": t3, "finished_at": t4, "geom": p1, "location_id": 2},
+        {"id": 80, "user_id": 1, "started_at": t45, "finished_at": t5, "geom": p1, "location_id": 2},
+        {"id": 3, "user_id": 1, "started_at": t5, "finished_at": t6, "geom": p1, "location_id": 4},
+    ]
+    sp = gpd.GeoDataFrame(data=list_dict, geometry="geom", crs="EPSG:4326")
+    sp = sp.set_index("id")
+    assert sp.as_staypoints
+
+    # generate example triplegs for the merge function
+    tpls = gpd.GeoDataFrame(columns=["user_id", "started_at", "finished_at"])
+    return sp, tpls
+
+
+@pytest.fixture
+def example_triplegs_merge(example_staypoints_merge):
+    """Example triplegs for merge operation
+    If tripleg is between two staypoint, we don't merge those staypoints
+    See staypoints example data above --> tripleg between 5 and 2 and between 6 and 15
+    """
+    # get staypoints from above
+    sp, _ = example_staypoints_merge
+    # create triplegs inbetween
+    # tripleg between 5 and 2
+    t21 = pd.Timestamp("1971-01-02 05:10:00", tz="utc")
+    t22 = pd.Timestamp("1971-01-02 05:40:00", tz="utc")
+    # tripleg between 6 and 15
+    t45 = pd.Timestamp("1971-01-02 08:57:00", tz="utc")
+    t5 = pd.Timestamp("1971-01-02 09:00:00", tz="utc")
+    list_dict = [
+        {"id": 0, "user_id": 0, "started_at": t45, "finished_at": t5},
+        {"id": 1, "user_id": 0, "started_at": t21, "finished_at": t22},
+    ]
+    # geometry is not required for the merge operation, so we leave it away
+    tpls = gpd.GeoDataFrame(data=list_dict, crs="EPSG:4326")
+    tpls = tpls.set_index("id")
+    return sp, tpls
+
+
 class TestGenerate_locations:
     """Tests for generate_locations() method."""
 
@@ -332,3 +394,69 @@ class TestGenerate_locations:
         assert sp2.loc[1, "location_id"] != sp2.loc[5, "location_id"]
 
         assert sp2.loc[[2, 7], "location_id"].isnull().all()
+
+
+class TestMergeStaypoints:
+    def test_merge_staypoints(self, example_staypoints_merge):
+        """Test staypoint merging."""
+        sp, tpls = example_staypoints_merge
+        # first test with empty tpls
+        merged_sp = sp.as_staypoints.merge_staypoints(tpls, agg={"geom": "first", "location_id": "first"})
+        assert len(merged_sp) == len(sp) - 3
+        # some staypoints stay the same (not merged)
+        assert (merged_sp.loc[1] == sp.loc[1]).all()
+        assert (merged_sp.loc[5] == sp.loc[5]).all()
+        assert (merged_sp.loc[3] == sp.loc[3]).all()
+
+    def test_merge_staypoints_triplegs(self, example_triplegs_merge):
+        """Test staypoint merging with triplegs inbetween"""
+        # get triplegs inbetween
+        sp, tpls = example_triplegs_merge
+        merged_sp_with_tpls = sp.as_staypoints.merge_staypoints(tpls)
+        # assert that staypoint 6 and 15 were not merged because of tpls inbetween
+        assert len(merged_sp_with_tpls) == len(sp) - 2
+        # 15 should not be merged
+        assert 15 in merged_sp_with_tpls.index
+
+    def test_merge_staypoints_time(self, example_staypoints_merge):
+        """Test if all merged staypoints have the correct start and end time"""
+        sp, tpls = example_staypoints_merge
+        merged_sp = sp.as_staypoints.merge_staypoints(tpls)
+        # user 1 - id 7 and 80 merged
+        assert sp.loc[7, "started_at"] == merged_sp.loc[7, "started_at"]
+        assert sp.loc[80, "finished_at"] == merged_sp.loc[7, "finished_at"]
+        # user 0 - id 2,6, and 15 merged
+        assert sp.loc[2, "started_at"] == merged_sp.loc[2, "started_at"]
+        assert sp.loc[15, "finished_at"] == merged_sp.loc[2, "finished_at"]
+
+    def test_merge_staypoints_max_time_gap(self, example_staypoints_merge):
+        """Test it the max_time_gap argument works correctly"""
+        sp, tpls = example_staypoints_merge
+        merged_sp = sp.as_staypoints.merge_staypoints(tpls, max_time_gap="2h")
+        assert len(merged_sp) == len(sp) - 4
+        # user 0 - id 5, 2,6, and 15 merged
+        assert sp.loc[5, "started_at"] == merged_sp.loc[5, "started_at"]
+        assert sp.loc[15, "finished_at"] == merged_sp.loc[5, "finished_at"]
+
+    def test_merge_staypoints_time_gap_error(self, example_staypoints_merge):
+        sp, tpls = example_staypoints_merge
+        # check that an int as max time gap raises a TypeError
+        with pytest.raises(Exception) as e_info:
+            merged_sp = sp.as_staypoints.merge_staypoints(tpls, max_time_gap=2)
+            assert e_info == "Parameter max_time_gap must be either of type String or pd.Timedelta!"
+        # check that an timedelta as max time gap works
+        _ = sp.as_staypoints.merge_staypoints(tpls, max_time_gap=pd.to_timedelta("1h"))
+
+    def test_merge_staypoints_agg(self, example_staypoints_merge):
+        """Test whether the user can specify the aggregation mode"""
+        aggregation_dict = {"geom": "first", "finished_at": "first"}
+
+        sp, tpls = example_staypoints_merge
+        merged_sp = sp.as_staypoints.merge_staypoints(tpls, agg=aggregation_dict)
+
+        # in contrast to the test above, the first of (7,80) should now be used for finished at
+        assert sp.loc[7, "finished_at"] == merged_sp.loc[7, "finished_at"]
+        assert sp.loc[2, "finished_at"] == merged_sp.loc[2, "finished_at"]
+        # the geom should correspond to the first one
+        assert sp.loc[7, "geom"] == merged_sp.loc[7, "geom"]
+        assert sp.loc[2, "geom"] == merged_sp.loc[2, "geom"]