Repository: mie-lab/trackintel

Issue:
trackintel generate functions are not allowed to return empty dataframes
At the moment it is not possible to call our generate functions when the result would be empty. 
I think it is an OK use-case that you try to generate a trackintel object (e.g., by using generate_triplegs) without knowing if there are valid triplegs in the set of positionfixes. 

The reason for this behavior is that we usually call the accessor for validation within the generator function e.g. `assert tpls.as_triplegs` and the accessor asserts that the dataframe is not empty. E.g., `assert obj.shape[0] > 0, "Geodataframe is empty with shape: {}".format(obj.shape)`

Possible ways to fix this would be to not call the accessor for validation in the generator functions (maybe replace it with a validation function?) or change the validation in the accessor.

Here is a code example: 

```python
import pandas as pd
import geopandas as gpd
import trackintel as ti
from shapely.geometry import Point

p1 = Point(8.5067847, 47.4)
p2 = Point(8.5067847, 47.5)
p3 = Point(8.5067847, 47.6)

t1 = pd.Timestamp("1971-01-01 00:00:00", tz="utc")
t2 = pd.Timestamp("1971-01-02 01:01:00", tz="utc")
t3 = pd.Timestamp("1971-01-02 01:02:00", tz="utc")
t4 = pd.Timestamp("1971-01-01 03:00:00", tz="utc")

list_dict = [
    {"user_id": 0, "tracked_at": t1, "geometry": p1, "staypoint_id": 0},
    {"user_id": 0, "tracked_at": t2, "geometry": p2, "staypoint_id": pd.NA},
    {"user_id": 1, "tracked_at": t3, "geometry": p2, "staypoint_id": pd.NA},
    {"user_id": 1, "tracked_at": t4, "geometry": p3, "staypoint_id": 1}
]
pfs = gpd.GeoDataFrame(data=list_dict, geometry="geometry", crs="EPSG:4326")
pfs.index.name = "id"
assert pfs.as_positionfixes

pfs, tpls = pfs.as_positionfixes.generate_triplegs()


```


Discussion:

Patch:
diff --git a/trackintel/preprocessing/positionfixes.py b/trackintel/preprocessing/positionfixes.py
index 1ff8cf9..650b3a7 100644
--- a/trackintel/preprocessing/positionfixes.py
+++ b/trackintel/preprocessing/positionfixes.py
@@ -165,6 +165,11 @@ def generate_staypoints(
     # rearange column order
     stps = stps[stps_column]
 
+    if len(stps) > 0:
+        stps.as_staypoints
+    else:
+        warnings.warn("No staypoints can be generated, returning empty sp.")
+
     ## dtype consistency
     # stps id (generated by this function) should be int64
     stps.index = stps.index.astype("int64")
@@ -380,7 +385,11 @@ def generate_triplegs(
 
         # assert validity of triplegs
         tpls, pfs = _drop_invalid_triplegs(tpls, pfs)
-        tpls.as_triplegs
+
+        if len(tpls) > 0:
+            tpls.as_triplegs
+        else:
+            warnings.warn("No triplegs can be generated, returning empty tpls.")
 
         if case == 2:
             pfs.drop(columns="staypoint_id", inplace=True)
diff --git a/trackintel/preprocessing/staypoints.py b/trackintel/preprocessing/staypoints.py
index 00040e2..8161227 100644
--- a/trackintel/preprocessing/staypoints.py
+++ b/trackintel/preprocessing/staypoints.py
@@ -5,6 +5,7 @@ import geopandas as gpd
 import pandas as pd
 from shapely.geometry import Point
 from sklearn.cluster import DBSCAN
+import warnings
 
 from trackintel.geogr.distances import meters_to_decimal_degrees
 from trackintel.preprocessing.util import applyParallel
@@ -193,6 +194,11 @@ def generate_locations(
     # stps not linked to a location receive np.nan in 'location_id'
     ret_stps.loc[ret_stps["location_id"] == -1, "location_id"] = np.nan
 
+    if len(ret_loc) > 0:
+        ret_loc.as_locations
+    else:
+        warnings.warn("No locations can be generated, returning empty locs.")
+
     ## dtype consistency
     # locs id (generated by this function) should be int64
     ret_loc.index = ret_loc.index.astype("int64")


Test patch:
diff --git a/tests/preprocessing/test_positionfixes.py b/tests/preprocessing/test_positionfixes.py
index 2e84a7a..a6b665e 100644
--- a/tests/preprocessing/test_positionfixes.py
+++ b/tests/preprocessing/test_positionfixes.py
@@ -1,6 +1,7 @@
 import datetime
 import os
 import sys
+import re
 
 import geopandas as gpd
 import numpy as np
@@ -86,6 +87,16 @@ def example_positionfixes_isolated():
 class TestGenerate_staypoints:
     """Tests for generate_staypoints() method."""
 
+    def test_empty_generation(self, example_positionfixes):
+        """The function should run without error if the generation result is empty (no staypoint could be generated)."""
+        # the pfs would not generate staypoints with the default parameters
+        pfs = example_positionfixes
+
+        warn_string = "No staypoints can be generated, returning empty sp."
+        with pytest.warns(UserWarning, match=warn_string):
+            pfs, sp = pfs.as_positionfixes.generate_staypoints()
+        assert len(sp) == 0
+
     def test_parallel_computing(self):
         """The result obtained with parallel computing should be identical."""
         pfs, _ = ti.io.dataset_reader.read_geolife(os.path.join("tests", "data", "geolife_long"))
@@ -110,15 +121,15 @@ class TestGenerate_staypoints:
         pfs_duplicate_all = pfs_duplicate_loc.copy()
         pfs_duplicate_all.loc[0, "tracked_at"] = pfs_duplicate_all.loc[1, "tracked_at"]
 
+        warn_string = ".* duplicates were dropped from your positionfixes."
         with pytest.warns(None) as record:
             example_positionfixes.as_positionfixes.generate_staypoints()
             pfs_duplicate_loc.as_positionfixes.generate_staypoints()
             pfs_duplicate_t.as_positionfixes.generate_staypoints()
 
-            # assert that no warning is raised
-            assert len(record) == 0
+            # assert that no warning of the defined type is raised
+            assert len([x for x in record if warn_string in str(x.message)]) == 0
 
-        warn_string = ".* duplicates were dropped from your positionfixes."
         with pytest.warns(UserWarning, match=warn_string):
             pfs_duplicate_all.as_positionfixes.generate_staypoints()
 
@@ -248,6 +259,19 @@ class TestGenerate_staypoints:
 class TestGenerate_triplegs:
     """Tests for generate_triplegs() method."""
 
+    def test_empty_generation(self, example_positionfixes_isolated):
+        """The function should run without error if the generation result is empty (no tripleg could be generated)."""
+        pfs = example_positionfixes_isolated
+
+        # select subset of pfs such that no triplegs can be generated
+        pfs = pfs.loc[pfs["user_id"] == 0]
+        pfs = pfs.iloc[2:]
+
+        warn_string = "No triplegs can be generated, returning empty tpls."
+        with pytest.warns(UserWarning, match=warn_string):
+            pfs, tpls = pfs.as_positionfixes.generate_triplegs()
+        assert len(tpls) == 0
+
     def test_noncontinuous_unordered_index(self, example_positionfixes_isolated):
         """The unordered and noncontinuous index of pfs shall not affect the generate_triplegs() result."""
         pfs = example_positionfixes_isolated
diff --git a/tests/preprocessing/test_staypoints.py b/tests/preprocessing/test_staypoints.py
index 5ba08c3..d0b2c31 100644
--- a/tests/preprocessing/test_staypoints.py
+++ b/tests/preprocessing/test_staypoints.py
@@ -17,6 +17,9 @@ from trackintel.geogr.distances import calculate_distance_matrix
 def example_staypoints():
     """Staypoints for location generation.
     Staypoints have non-continous ids and should result in noise and several locations per user.
+
+    With epsilon=10, num_samples=2:
+
     The following staypoint ids should form a location (1, 15), (5,6), (80, 3)
     The following staypoint ids should be noise (2, 7)
 
@@ -51,13 +54,27 @@ def example_staypoints():
     ]
     sp = gpd.GeoDataFrame(data=list_dict, geometry="geom", crs="EPSG:4326")
     sp = sp.set_index("id")
-    assert sp.as_staypoints
+    sp.as_staypoints
     return sp
 
 
 class TestGenerate_locations:
     """Tests for generate_locations() method."""
 
+    def test_empty_generation(self, example_staypoints):
+        """The function should run without error if the generation result is empty (no locations could be generated)."""
+        # the pfs would not generate staypoints with the default parameters
+        sp = example_staypoints
+        # select subset of sp such that no locations can be generated
+        sp = sp.iloc[:3]
+
+        warn_string = "No locations can be generated, returning empty locs."
+        with pytest.warns(UserWarning, match=warn_string):
+            sp, locs = sp.as_staypoints.generate_locations(
+                method="dbscan", epsilon=10, num_samples=2, distance_metric="haversine", agg_level="user"
+            )
+        assert len(locs) == 0
+
     def test_parallel_computing(self, example_staypoints):
         """The result obtained with parallel computing should be identical."""
         stps = example_staypoints