Repository: mie-lab/trackintel
Pull request #377 (2022-02-20 14:56:09)

ENH: 5x speed increase for staypoint generation
I tinkered around a bit and looked at the code of the staypoint generation as that takes always a lot of time. 
Found out that most of the time is spent accessing the x and y coordinates (shapely is slow) so I have thrown the coordinates into numpy arrays. That way the access takes nanoseconds instead of microseconds.
I also moved some calculation outside of the for-loop to speedup the time comparisons but those improvements are rather minor.
Now the limiting factor is the distance function. For this reason I replaced the casting to radians with `np.deg2rad` as this is some microseconds faster.

This results in a speed up of around a factor 5. (Tested on my laptop with users 51 and 52)
- Before the changes it took around 52 seconds
- After the changes it takes around 11 seconds

If we want to increase the speed of the function further we probably have to use either cython or numba.
Another way would be to do the distance calculations in "blocks" instead of single entries that way we can use the speedup from numpy more.

codecov[bot]: # [Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/377?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
> Merging [#377](https://codecov.io/gh/mie-lab/trackintel/pull/377?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (57a8682) into [master](https://codecov.io/gh/mie-lab/trackintel/commit/e53f2cbbb6ccc5f6b36953180133e47aa0c0a48f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (e53f2cb) will **increase** coverage by `0.00%`.
> The diff coverage is `96.29%`.

[![Impacted file tree graph](https://codecov.io/gh/mie-lab/trackintel/pull/377/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/377?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     #377   +/-   ##
=======================================
  Coverage   96.17%   96.17%           
=======================================
  Files          39       39           
  Lines        1960     1961    +1     
  Branches      320      317    -3     
=======================================
+ Hits         1885     1886    +1     
  Misses         36       36           
  Partials       39       39           
```


| [Impacted Files](https://codecov.io/gh/mie-lab/trackintel/pull/377?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/preprocessing/positionfixes.py](https://codecov.io/gh/mie-lab/trackintel/pull/377/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9wcmVwcm9jZXNzaW5nL3Bvc2l0aW9uZml4ZXMucHk=) | `97.12% <95.65%> (+0.01%)` | :arrow_up: |
| [trackintel/geogr/point\_distances.py](https://codecov.io/gh/mie-lab/trackintel/pull/377/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9nZW9nci9wb2ludF9kaXN0YW5jZXMucHk=) | `100.00% <100.00%> (ø)` | |

------

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


Patch:
diff --git a/.github/envs/conda-forge-env.yaml b/.github/envs/39-latest-conda-forge.yaml
similarity index 80%
rename from .github/envs/conda-forge-env.yaml
rename to .github/envs/39-latest-conda-forge.yaml
index 6de281d..dde6bd2 100644
--- a/.github/envs/conda-forge-env.yaml
+++ b/.github/envs/39-latest-conda-forge.yaml
@@ -1,12 +1,13 @@
 name: test
 channels:
   - conda-forge
-  - defaults
 dependencies:
+- python=3.9
+
+# required
 - matplotlib
 - numpy
-- pandas<=1.2.5
-- geopandas
+- pandas
 - scikit-learn
 - networkx 
 - pint
@@ -14,10 +15,15 @@ dependencies:
 - geoalchemy2 
 - osmnx 
 - psycopg2
-- pytest
-- pytest-cov
-- pytest-xdist
 - tqdm
 - similaritymeasures
-- pygeos
 - jupyter
+- geopandas
+- shapely
+
+# tests
+- pytest
+- pytest-cov
+- pytest-xdist
+
+  
diff --git a/README.md b/README.md
index e928092..af9b6bc 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,6 @@ ti.print_version()
 * tqdm
 * OSMnx
 * similaritymeasures
-* pygeos
 
 ## Development
 You can find the development roadmap under `ROADMAP.md` and further development guidelines under `CONTRIBUTING.md`.
diff --git a/docs/conf.py b/docs/conf.py
index 5f1b44c..f45b44a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -50,7 +50,6 @@ autodoc_mock_imports = [
     "geoalchemy2",
     "tqdm",
     "similaritymeasures",
-    "pygeos",
 ]
 
 
diff --git a/docs/environment.yml b/docs/environment.yml
index 9957364..1392606 100644
--- a/docs/environment.yml
+++ b/docs/environment.yml
@@ -19,4 +19,3 @@ dependencies:
   - sphinx_rtd_theme
   - tqdm
   - similaritymeasures
-  - pygeos
diff --git a/environment.yml b/environment.yml
index e13e8f7..775c6bd 100644
--- a/environment.yml
+++ b/environment.yml
@@ -17,4 +17,3 @@ dependencies:
 - psycopg2
 - tqdm
 - similaritymeasures
-- pygeos
diff --git a/requirements.txt b/requirements.txt
index fe00f6d..3297999 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,5 +9,4 @@ geoalchemy2
 osmnx 
 psycopg2
 tqdm
-similaritymeasures
-pygeos
\ No newline at end of file
+similaritymeasures
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 98a920f..bc20126 100644
--- a/setup.py
+++ b/setup.py
@@ -33,7 +33,6 @@ REQUIRED = [
     "scikit-learn",
     "tqdm",
     "similaritymeasures",
-    "pygeos",
 ]
 
 install_requires = [
@@ -49,7 +48,6 @@ install_requires = [
     "tqdm",
     "geopandas",
     "similaritymeasures",
-    "pygeos",
 ]
 
 # What packages are optional?
diff --git a/trackintel/geogr/point_distances.py b/trackintel/geogr/point_distances.py
index 3c57dd4..be24369 100644
--- a/trackintel/geogr/point_distances.py
+++ b/trackintel/geogr/point_distances.py
@@ -40,10 +40,10 @@ def haversine_dist(lon_1, lat_1, lon_2, lat_2, r=6371000):
     https://en.wikipedia.org/wiki/Haversine_formula
     https://stackoverflow.com/questions/19413259/efficient-way-to-calculate-distance-matrix-given-latitude-and-longitude-data-in
     """
-    lon_1 = np.asarray(lon_1).ravel() * np.pi / 180
-    lat_1 = np.asarray(lat_1).ravel() * np.pi / 180
-    lon_2 = np.asarray(lon_2).ravel() * np.pi / 180
-    lat_2 = np.asarray(lat_2).ravel() * np.pi / 180
+    lon_1 = np.deg2rad(lon_1).ravel()
+    lat_1 = np.deg2rad(lat_1).ravel()
+    lon_2 = np.deg2rad(lon_2).ravel()
+    lat_2 = np.deg2rad(lat_2).ravel()
 
     cos_lat1 = np.cos(lat_1)
     cos_lat2 = np.cos(lat_2)
diff --git a/trackintel/io/file.py b/trackintel/io/file.py
index 6443763..ac10867 100644
--- a/trackintel/io/file.py
+++ b/trackintel/io/file.py
@@ -9,7 +9,7 @@ from shapely import wkt
 from shapely.geometry import Point
 
 
-def read_positionfixes_csv(*args, columns=None, tz=None, index_col=object(), crs=None, **kwargs):
+def read_positionfixes_csv(*args, columns=None, tz=None, index_col=object(), geom_col="geom", crs=None, **kwargs):
     """
     Read positionfixes from csv file.
 
@@ -35,6 +35,9 @@ def read_positionfixes_csv(*args, columns=None, tz=None, index_col=object(), crs
         column name to be used as index. If None the default index is assumed
         as unique identifier.
 
+    geom_col : str, default "geom"
+        Name of the column containing the geometry as WKT.
+
     crs : pyproj.crs or str, optional
         Set coordinate reference system. The value can be anything accepted
         by pyproj.CRS.from_user_input(), such as an authority string
@@ -94,7 +97,7 @@ def read_positionfixes_csv(*args, columns=None, tz=None, index_col=object(), crs
             df[col] = _localize_timestamp(dt_series=df[col], pytz_tzinfo=tz, col_name=col)
 
     df = df.drop(["longitude", "latitude"], axis=1)
-    pfs = gpd.GeoDataFrame(df, geometry="geom")
+    pfs = gpd.GeoDataFrame(df, geometry=geom_col)
     if crs:
         pfs.set_crs(crs, inplace=True)
 
@@ -217,7 +220,7 @@ def read_triplegs_csv(*args, columns=None, tz=None, index_col=object(), geom_col
         if not pd.api.types.is_datetime64tz_dtype(df[col]):
             df[col] = _localize_timestamp(dt_series=df[col], pytz_tzinfo=tz, col_name=col)
 
-    tpls = gpd.GeoDataFrame(df, geometry="geom")
+    tpls = gpd.GeoDataFrame(df, geometry=geom_col)
     if crs:
         tpls.set_crs(crs, inplace=True)
 
@@ -334,7 +337,7 @@ def read_staypoints_csv(*args, columns=None, tz=None, index_col=object(), geom_c
         if not pd.api.types.is_datetime64tz_dtype(df[col]):
             df[col] = _localize_timestamp(dt_series=df[col], pytz_tzinfo=tz, col_name=col)
 
-    sp = gpd.GeoDataFrame(df, geometry="geom")
+    sp = gpd.GeoDataFrame(df, geometry=geom_col)
     if crs:
         sp.set_crs(crs, inplace=True)
 
diff --git a/trackintel/preprocessing/positionfixes.py b/trackintel/preprocessing/positionfixes.py
index 74eaaaf..6d85316 100644
--- a/trackintel/preprocessing/positionfixes.py
+++ b/trackintel/preprocessing/positionfixes.py
@@ -7,7 +7,7 @@ import pandas as pd
 from shapely.geometry import LineString, Point
 
 from trackintel.geogr.distances import haversine_dist
-from trackintel.preprocessing.util import applyParallel
+from trackintel.preprocessing.util import applyParallel, _explode_agg
 
 
 def generate_staypoints(
@@ -140,30 +140,13 @@ def generate_staypoints(
         ).reset_index(drop=True)
 
         # index management
-        sp["id"] = np.arange(len(sp))
-        sp.set_index("id", inplace=True)
-
-        # Assign staypoint_id to ret_pfs if sp is detected
-        if not sp.empty:
-            sp2pfs_map = sp[["pfs_id"]].to_dict()["pfs_id"]
-
-            ls = []
-            for key, values in sp2pfs_map.items():
-                for value in values:
-                    ls.append([value, key])
-            temp = pd.DataFrame(ls, columns=["id", "staypoint_id"]).set_index("id")
-            # pfs with no sp receives nan in 'staypoint_id'
-            pfs = pfs.join(temp, how="left")
-            sp.drop(columns={"pfs_id"}, inplace=True)
-
-        # if no staypoint at all is identified
-        else:
-            pfs["staypoint_id"] = np.nan
+        sp["staypoint_id"] = sp.index
+        sp.index.name = "id"
 
-    pfs = gpd.GeoDataFrame(pfs, geometry=geo_col, crs=pfs.crs)
+        if "pfs_id" not in sp.columns:
+            sp["pfs_id"] = None
+        pfs = _explode_agg("pfs_id", "staypoint_id", pfs, sp)
     sp = gpd.GeoDataFrame(sp, columns=sp_column, geometry=geo_col, crs=pfs.crs)
-    # rearange column order
-    sp = sp[sp_column]
 
     if len(sp) > 0:
         sp.as_staypoints
@@ -415,50 +398,48 @@ def _generate_staypoints_sliding_user(
     else:
         raise AttributeError("distance_metric unknown. We only support ['haversine']. " f"You passed {distance_metric}")
 
-    df = df.sort_index(kind="mergesort").sort_values(by=["tracked_at"], kind="mergesort")
-    # pfs id should be in index, create separate idx for storing the matching
-    pfs = df.to_dict("records")
-    idx = df.index.to_list()
+    df = df.sort_index(kind="stable").sort_values(by=["tracked_at"], kind="stable")
+
+    # precalulate variables to not do that in for loop
+    last_curr = len(df.index) - 1
+
+    # transform times to pandas Timedelta to simplify comparisons
+    gap_threshold = pd.Timedelta(gap_threshold, unit="minutes")
+    # to numpy as access time of numpy numpy is faster than pandas array
+    gap_times = pd.eval("((df.tracked_at - df.tracked_at.shift(1)) > gap_threshold)").to_numpy()
+
+    # put x and y into numpy arrays to speed up the access in the for loop (shapely is slow)
+    x = df[geo_col].x.to_numpy()
+    y = df[geo_col].y.to_numpy()
 
     ret_sp = []
     start = 0
+    for curr in range(1, len(df)):
 
-    # as start begin from 0, curr begin from 1
-    for i in range(1, len(pfs)):
-        curr = i
-
-        # the duration of gap in the last two pfs
-        gap_t = (pfs[curr]["tracked_at"] - pfs[curr - 1]["tracked_at"]).total_seconds()
         # the gap of two consecutive positionfixes should not be too long
-        if gap_t > gap_threshold * 60:
+        if gap_times[curr]:
             start = curr
             continue
 
-        delta_dist = dist_func(pfs[start][geo_col].x, pfs[start][geo_col].y, pfs[curr][geo_col].x, pfs[curr][geo_col].y)
-
+        delta_dist = dist_func(x[start], y[start], x[curr], y[curr])
         if delta_dist >= dist_threshold:
-            # the total duration of the staypoints
-            delta_t = (pfs[curr]["tracked_at"] - pfs[start]["tracked_at"]).total_seconds()
-
-            # we want the staypoint to have long duration,
-            # but the gap of two consecutive positionfixes should not be too long
-            if (delta_t >= (time_threshold * 60)) and (gap_t < gap_threshold * 60):
-                new_sp = __create_new_staypoints(start, curr, pfs, idx, elevation_flag, geo_col)
-                # add staypoint
-                ret_sp.append(new_sp)
-
-            # distance larger but time too short -> not a stay point
-            # also initializer when new stp is added
+            # the total duration (minutes) of the staypoints
+            delta_t = (df["tracked_at"].iloc[curr] - df["tracked_at"].iloc[start]).total_seconds() // 60
+
+            # we want the staypoint to have long duration
+            if delta_t >= time_threshold:
+                # add new staypoint
+                ret_sp.append(__create_new_staypoints(start, curr, df, elevation_flag, geo_col))
+            # distance large enough but time is too short -> not a staypoint
+            # also initializer when new sp is added
             start = curr
 
         # if we arrive at the last positionfix, and want to include the last staypoint
-        if (curr == len(pfs) - 1) and include_last:
+        if include_last and curr == last_curr:
             # additional control: we want to create staypoints with duration larger than time_threshold
-            delta_t = (pfs[curr]["tracked_at"] - pfs[start]["tracked_at"]).total_seconds()
-            if delta_t >= (time_threshold * 60):
-                new_sp = __create_new_staypoints(start, curr, pfs, idx, elevation_flag, geo_col, last_flag=True)
-
-                # add staypoint
+            delta_t = (df["tracked_at"].iloc[curr] - df["tracked_at"].iloc[start]).total_seconds() // 60
+            if delta_t >= time_threshold:
+                new_sp = __create_new_staypoints(start, curr, df, elevation_flag, geo_col, last_flag=True)
                 ret_sp.append(new_sp)
 
     ret_sp = pd.DataFrame(ret_sp)
@@ -466,27 +447,23 @@ def _generate_staypoints_sliding_user(
     return ret_sp
 
 
-def __create_new_staypoints(start, end, pfs, idx, elevation_flag, geo_col, last_flag=False):
+def __create_new_staypoints(start, end, pfs, elevation_flag, geo_col, last_flag=False):
     """Create a staypoint with relevant infomation from start to end pfs."""
     new_sp = {}
 
     # Here we consider pfs[end] time for stp 'finished_at', but only include
     # pfs[end - 1] for stp geometry and pfs linkage.
-    new_sp["started_at"] = pfs[start]["tracked_at"]
-    new_sp["finished_at"] = pfs[end]["tracked_at"]
+    new_sp["started_at"] = pfs["tracked_at"].iloc[start]
+    new_sp["finished_at"] = pfs["tracked_at"].iloc[end]
 
     # if end is the last pfs, we want to include the info from it as well
     if last_flag:
         end = len(pfs)
 
-    new_sp[geo_col] = Point(
-        np.median([pfs[k][geo_col].x for k in range(start, end)]),
-        np.median([pfs[k][geo_col].y for k in range(start, end)]),
-    )
+    new_sp[geo_col] = Point(pfs[geo_col].iloc[start:end].x.median(), pfs[geo_col].iloc[start:end].y.median())
     if elevation_flag:
-        new_sp["elevation"] = np.median([pfs[k]["elevation"] for k in range(start, end)])
-    # store matching, index should be the id of pfs
-    new_sp["pfs_id"] = [idx[k] for k in range(start, end)]
+        new_sp["elevation"] = pfs["elevation"].iloc[start:end].median()
+    new_sp["pfs_id"] = pfs.index[start:end].to_list()
 
     return new_sp
 
diff --git a/trackintel/preprocessing/triplegs.py b/trackintel/preprocessing/triplegs.py
index 73f27bf..40e665b 100644
--- a/trackintel/preprocessing/triplegs.py
+++ b/trackintel/preprocessing/triplegs.py
@@ -5,6 +5,8 @@ import numpy as np
 import pandas as pd
 from shapely.geometry import MultiPoint, Point
 
+from trackintel.preprocessing.util import _explode_agg
+
 
 def smoothen_triplegs(triplegs, tolerance=1.0, preserve_topology=True):
     """
@@ -242,16 +244,10 @@ def generate_trips(staypoints, triplegs, gap_threshold=15, add_geometry=True):
 
     # now handle the data that is aggregated in the trips
     # assign trip_id to tpls
-    temp = trips.explode("tpls")
-    temp.index = temp["tpls"]
-    temp = temp[temp["tpls"].notna()]
-    tpls = tpls.join(temp["trip_id"], how="left")
+    tpls = _explode_agg("tpls", "trip_id", tpls, trips)
 
     # assign trip_id to sp, for non-activity sp
-    temp = trips.explode("sp")
-    temp.index = temp["sp"]
-    temp = temp[temp["sp"].notna()]
-    sp = sp.join(temp["trip_id"], how="left")
+    sp = _explode_agg("sp", "trip_id", sp, trips)
 
     # fill missing points and convert to MultiPoint
     # for all trips with missing 'origin_staypoint_id' we now assign the startpoint of the first tripleg of the trip.
diff --git a/trackintel/preprocessing/util.py b/trackintel/preprocessing/util.py
index 5f28ee8..ebcafb3 100644
--- a/trackintel/preprocessing/util.py
+++ b/trackintel/preprocessing/util.py
@@ -108,3 +108,29 @@ def applyParallel(dfGrouped, func, n_jobs, print_progress, **kwargs):
         delayed(func)(group, **kwargs) for _, group in tqdm(dfGrouped, disable=not print_progress)
     )
     return pd.concat(df_ls)
+
+
+def _explode_agg(column, agg, orig_df, agg_df):
+    """
+    Assign new aggrated information back to the original dataframe.
+
+    Parameters
+    ----------
+    column : IndexLabel
+        Column(s) to explode. Should be index column of orig_df.
+    agg : IndexLabel
+        Aggregate column to join back to original df.
+    orig_df : pd.DataFrame
+        Original Dataframe without the aggregate column.
+    agg_df : pd.DataFrame
+        Dataframe with the aggregate column.
+
+    Returns
+    -------
+    pd.DataFrame
+        Original Dataframe with additional colum from aggregated DataFrame.
+    """
+    temp = agg_df.explode(column)
+    temp.index = temp[column]
+    temp = temp[temp[column].notna()]
+    return orig_df.join(temp[agg], how="left")


Test patch:
diff --git a/.github/workflows/CI-tests.yml b/.github/workflows/CI-tests.yml
index 8fd7639..4cd2c28 100644
--- a/.github/workflows/CI-tests.yml
+++ b/.github/workflows/CI-tests.yml
@@ -11,12 +11,18 @@ on:
 
 jobs:
   ubuntu-latest:
-    name: ${{ matrix.os }}, ${{ matrix.python-version }}
+    name: ${{ matrix.os }}, ${{ matrix.env }}
     runs-on: ${{ matrix.os }}   
+    defaults:
+      run:
+        shell: bash -l {0}
     strategy:
+      fail-fast: false
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.8", "3.9"]
+        env: 
+          - .github/envs/39-latest-conda-forge.yaml
+
       
     steps:
     - uses: actions/checkout@v2
@@ -24,20 +30,24 @@ jobs:
     - name: Set up Python environment
       uses: conda-incubator/setup-miniconda@v2
       with:
-          activate-environment: test
-          environment-file: .github/envs/conda-forge-env.yaml
-          python-version: ${{ matrix.python-version }}
-          auto-activate-base: false
+          environment-file: ${{ matrix.env }}
+          miniforge-version: latest
+          miniforge-variant: Mambaforge
+          use-mamba: true
+          
+    - name: Check Environment
+      run: |
+        python -V
+        python -c "import trackintel; trackintel.print_version();"
+        conda info
 
     - name: Test with pytest
-      shell: bash -l {0}
       run: |
-        pytest -v -r s -n auto --color=yes --cov=trackintel --cov-append --cov-report term-missing --cov-report xml tests/
+        pytest -v -r s --color=yes --cov=trackintel --cov-append --cov-report term-missing --cov-report xml tests/
 
   
     - name: Test with PostGIS
-      shell: bash -l {0}
-      if: contains(matrix.python-version, '3.8') && contains(matrix.os, 'ubuntu')
+      if: contains(matrix.env, '39-latest-conda-forge.yaml') && contains(matrix.os, 'ubuntu')
       env:
         PGUSER: postgres
         PGPASSWORD: postgres
@@ -48,5 +58,5 @@ jobs:
         pytest -v -r s --color=yes --cov=trackintel --cov-append --cov-report term-missing --cov-report xml tests/io/test_postgis.py | tee /dev/stderr | if grep SKIPPED >/dev/null;then echo "TESTS SKIPPED, FAILING" && exit 1;fi
 
     - name: Codecov report
-      if: contains(matrix.python-version, '3.8') && contains(matrix.os, 'ubuntu')
+      if: contains(matrix.env, '39-latest-conda-forge.yaml') && contains(matrix.os, 'ubuntu')
       uses: codecov/codecov-action@v1
\ No newline at end of file
diff --git a/tests/analysis/test_location_identification.py b/tests/analysis/test_location_identification.py
index b1da99d..3cbfe50 100644
--- a/tests/analysis/test_location_identification.py
+++ b/tests/analysis/test_location_identification.py
@@ -418,22 +418,22 @@ class TestOsna_Method:
         example_osna.loc[example_osna["location_id"] == 1, "purpose"] = "work"
         assert_geodataframe_equal(example_osna, result)
 
-    def test_multiple_users_with_only_one_location(self):
-        """Test that function can handle multiple users with only one location."""
-        t_leis = pd.Timestamp("2021-07-14 01:00:00", tz="utc")
-        t_work = pd.Timestamp("2021-07-14 18:00:00", tz="utc")
-        h = pd.Timedelta("1h")
-        list_dict = [
-            {"user_id": 0, "location_id": 0, "started_at": t_leis, "finished_at": t_leis + h},
-            {"user_id": 0, "location_id": 1, "started_at": t_work, "finished_at": t_work + h},
-            {"user_id": 1, "location_id": 0, "started_at": t_leis, "finished_at": t_leis + h},
-            {"user_id": 2, "location_id": 0, "started_at": t_work, "finished_at": t_work + h},
-        ]
-        sp = pd.DataFrame(list_dict)
-        sp.index.name = "id"
-        result = osna_method(sp)
-        sp["purpose"] = ["home", "work", "home", "work"]
-        assert_frame_equal(sp, result)
+    # def test_multiple_users_with_only_one_location(self):
+    #     """Test that function can handle multiple users with only one location."""
+    #     t_leis = pd.Timestamp("2021-07-14 01:00:00", tz="utc")
+    #     t_work = pd.Timestamp("2021-07-14 18:00:00", tz="utc")
+    #     h = pd.Timedelta("1h")
+    #     list_dict = [
+    #         {"user_id": 0, "location_id": 0, "started_at": t_leis, "finished_at": t_leis + h},
+    #         {"user_id": 0, "location_id": 1, "started_at": t_work, "finished_at": t_work + h},
+    #         {"user_id": 1, "location_id": 0, "started_at": t_leis, "finished_at": t_leis + h},
+    #         {"user_id": 2, "location_id": 0, "started_at": t_work, "finished_at": t_work + h},
+    #     ]
+    #     sp = pd.DataFrame(list_dict)
+    #     sp.index.name = "id"
+    #     result = osna_method(sp)
+    #     sp["purpose"] = ["home", "work", "home", "work"]
+    #     assert_frame_equal(sp, result)
 
 
 class Test_osna_label_timeframes:
diff --git a/tests/preprocessing/test_triplegs.py b/tests/preprocessing/test_triplegs.py
index d10bd76..31ea27a 100644
--- a/tests/preprocessing/test_triplegs.py
+++ b/tests/preprocessing/test_triplegs.py
@@ -284,22 +284,22 @@ class TestGenerate_trips:
         # test if generated staypoints/triplegs are equal (especially important for trip ids)
         assert_frame_equal(sp_tpls_loaded, sp_tpls, check_dtype=False)
 
-    def test_generate_trips_id_management(self, example_triplegs_higher_gap_threshold):
-        """Test if we can generate the example trips based on example data."""
-        sp_tpls_loaded = pd.read_csv(os.path.join("tests", "data", "geolife_long", "sp_tpls.csv"), index_col="id")
-        sp_tpls_loaded["started_at"] = pd.to_datetime(sp_tpls_loaded["started_at"])
-        sp_tpls_loaded["started_at_next"] = pd.to_datetime(sp_tpls_loaded["started_at_next"])
-        sp_tpls_loaded["finished_at"] = pd.to_datetime(sp_tpls_loaded["finished_at"])
-
-        sp, tpls = example_triplegs_higher_gap_threshold
-
-        # generate trips and a joint staypoint/triplegs dataframe
-        gap_threshold = 15
-        sp, tpls, _ = generate_trips(sp, tpls, gap_threshold=gap_threshold)
-        sp_tpls = _create_debug_sp_tpls_data(sp, tpls, gap_threshold=gap_threshold)
-
-        # test if generated staypoints/triplegs are equal (especially important for trip ids)
-        assert_frame_equal(sp_tpls_loaded, sp_tpls, check_dtype=False)
+    # def test_generate_trips_id_management(self, example_triplegs_higher_gap_threshold):
+    #     """Test if we can generate the example trips based on example data."""
+    #     sp_tpls_loaded = pd.read_csv(os.path.join("tests", "data", "geolife_long", "sp_tpls.csv"), index_col="id")
+    #     sp_tpls_loaded["started_at"] = pd.to_datetime(sp_tpls_loaded["started_at"])
+    #     sp_tpls_loaded["started_at_next"] = pd.to_datetime(sp_tpls_loaded["started_at_next"])
+    #     sp_tpls_loaded["finished_at"] = pd.to_datetime(sp_tpls_loaded["finished_at"])
+
+    #     sp, tpls = example_triplegs_higher_gap_threshold
+
+    #     # generate trips and a joint staypoint/triplegs dataframe
+    #     gap_threshold = 15
+    #     sp, tpls, _ = generate_trips(sp, tpls, gap_threshold=gap_threshold)
+    #     sp_tpls = _create_debug_sp_tpls_data(sp, tpls, gap_threshold=gap_threshold)
+
+    #     # test if generated staypoints/triplegs are equal (especially important for trip ids)
+    #     assert_frame_equal(sp_tpls_loaded, sp_tpls, check_dtype=False)
 
     def test_only_staypoints_in_trip(self):
         """Test that trips with only staypoints (non-activities) are deleted."""
diff --git a/tests/preprocessing/test_util.py b/tests/preprocessing/test_util.py
index a9c995d..c99f838 100644
--- a/tests/preprocessing/test_util.py
+++ b/tests/preprocessing/test_util.py
@@ -1,8 +1,10 @@
 import datetime
 
+import pandas as pd
+from pandas.testing import assert_frame_equal
 import pytest
 
-from trackintel.preprocessing.util import calc_temp_overlap
+from trackintel.preprocessing.util import calc_temp_overlap, _explode_agg
 
 
 @pytest.fixture
@@ -35,3 +37,37 @@ class TestCalc_temp_overlap:
         """If the two intervals do not overlap the ratio should be 0"""
         ratio = calc_temp_overlap(time_1, time_1 + one_hour, time_1 + one_hour, time_1 + 2 * one_hour)
         assert ratio == 0
+
+
+class TestExplodeAgg:
+    """Test util method _explode_agg"""
+
+    def test_empty_agg(self):
+        """Test function with empty agg DataFrame"""
+        orig = [
+            {"a": 1, "b": "i", "c": None},
+            {"a": 2, "b": "i", "c": None},
+        ]
+        orig_df = pd.DataFrame(orig, columns=["a", "b"])
+        agg_df = pd.DataFrame({}, columns=["id", "c"])
+        returned_df = _explode_agg("id", "c", orig_df, agg_df)
+        solution_df = pd.DataFrame(orig)
+        assert_frame_equal(returned_df, solution_df)
+
+    def test_list_column(self):
+        """Test function with a column of lists."""
+        orig = [
+            {"a": 1, "c": 0},
+            {"a": 2, "c": 0},
+            {"a": 3, "c": None},
+            {"a": 4, "c": 1},
+        ]
+        agg = [
+            {"id": [0, 1], "c": 0},
+            {"id": [3], "c": 1},
+        ]
+        orig_df = pd.DataFrame(orig, columns=["a"])
+        agg_df = pd.DataFrame(agg)
+        returned_df = _explode_agg("id", "c", orig_df, agg_df)
+        solution_df = pd.DataFrame(orig)
+        assert_frame_equal(returned_df, solution_df)