Repository: mie-lab/trackintel
Pull request #518 (2023-09-05 17:08:41)

ENH: Add subclassing for trips
Adding the Trips classes to trackintel.
I changed the accessor to the `trips` function to create either the `TripsDataFrame` or `TripsGeoDataFrame` class, depending on whether a `geocolumn` is present or not.
When I implemented these changes, I sometimes thought it would be easier to implement a single class, aka a `Trips` class with no fallback mechanism, as Nina suggested in a meeting. So that it remains a `Trips` class even if it loses the geometry column. I would be open to discuss if this alternative implementation would be more appropriate.
In another PR, I should add documentation explaining this split into two classes and the complicated fallback pattern.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
Patch coverage: **`100.00%`** and project coverage change: **`+0.23%`** :tada:
> Comparison is base [(`11c36f3`)](https://app.codecov.io/gh/mie-lab/trackintel/commit/11c36f3b2567b2d1c012e284ee3f89b5723caea0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 93.05% compared to head [(`8cb5078`)](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 93.29%.
> Report is 3 commits behind head on master.

> :exclamation: Current head 8cb5078 differs from pull request most recent head 99c781d. Consider uploading reports for the commit 99c781d to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #518      +/-   ##
==========================================
+ Coverage   93.05%   93.29%   +0.23%     
==========================================
  Files          34       34              
  Lines        2074     2147      +73     
  Branches      388      408      +20     
==========================================
+ Hits         1930     2003      +73     
  Misses        130      130              
  Partials       14       14              
```


| [Files Changed](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/\_\_init\_\_.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [trackintel/io/file.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9pby9maWxlLnB5) | `100.00% <100.00%> (ø)` | |
| [trackintel/io/postgis.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9pby9wb3N0Z2lzLnB5) | `96.07% <100.00%> (ø)` | |
| [trackintel/model/\_\_init\_\_.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [trackintel/model/trips.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC90cmlwcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [trackintel/model/util.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC91dGlsLnB5) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mie-lab/trackintel/pull/518?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index fc405e8..2d6a5ec 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -13,7 +13,7 @@ ci:
 
 repos: 
 - repo: https://github.com/psf/black 
-  rev: 23.7.0 
+  rev: 23.9.1 
   hooks:
   - id: black
     args: [
diff --git a/trackintel/__init__.py b/trackintel/__init__.py
index 5df82b8..ff35ec6 100644
--- a/trackintel/__init__.py
+++ b/trackintel/__init__.py
@@ -14,6 +14,9 @@ from trackintel.model.positionfixes import Positionfixes
 from trackintel.model.locations import Locations
 from trackintel.model.triplegs import Triplegs
 from trackintel.model.staypoints import Staypoints
+from trackintel.model.trips import Trips
+from trackintel.model.trips import TripsDataFrame
+from trackintel.model.trips import TripsGeoDataFrame
 
 from trackintel.visualization import plot, plot_modal_split
 
diff --git a/trackintel/analysis/location_identification.py b/trackintel/analysis/location_identification.py
index 7581ab2..e5e02e3 100644
--- a/trackintel/analysis/location_identification.py
+++ b/trackintel/analysis/location_identification.py
@@ -143,7 +143,7 @@ def pre_filter_locations(
         groupby_loc = ["location_id"]
     else:
         raise ValueError(f"Unknown agg_level '{agg_level}' use instead {{'user', 'dataset'}}.")
-    loc = sp.groupby(groupby_loc).agg({"started_at": [min, "count"], "finished_at": max, "duration": sum})
+    loc = sp.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.
@@ -214,7 +214,7 @@ def _freq_transform(group, *labels):
     pd.Series
         dtype : object
     """
-    group_agg = group.groupby("location_id").agg({"duration": sum})
+    group_agg = group.groupby("location_id").agg({"duration": "sum"})
     group_agg["purpose"] = _freq_assign(group_agg["duration"], *labels)
     group_merge = pd.merge(
         group["location_id"], group_agg["purpose"], how="left", left_on="location_id", right_index=True
@@ -301,24 +301,35 @@ def osna_method(staypoints):
 
     # create a pivot table -> labels "home" and "work" as columns. ("user_id", "location_id" still in index.)
     sp_pivot = sp_agg.unstack()
-    # this line is here to circumvent a bug in pandas .idxmax should return NA if all values in column are
-    # empty but that doesn't work for pd.NaT -> therefor we cast to float64
-    sp_pivot /= pd.Timedelta("1ns")
     # get index of maximum for columns "work" and "home"
     # looks over locations to find maximum for columns
-    sp_idxmax = sp_pivot.groupby(["user_id"]).idxmax(axis="index")
-    # first assign labels
-    for col in sp_idxmax.columns:
-        sp_pivot.loc[sp_idxmax[col].dropna(), "purpose"] = col
-
-    # The "home" label could overlap with the "work" label
-    # we set the rows where "home" is maximum to zero (pd.NaT) and recalculate index of work maximum.
-    if all(col in sp_idxmax.columns for col in ["work", "home"]):
-        redo_work = sp_idxmax[sp_idxmax["home"] == sp_idxmax["work"]]
-        sp_pivot.loc[redo_work["work"], "purpose"] = "home"
-        sp_pivot.loc[redo_work["work"], "work"] = np.nan
-        sp_idxmax_work = sp_pivot.groupby(["user_id"])["work"].idxmax()
-        sp_pivot.loc[sp_idxmax_work.dropna(), "purpose"] = "work"
+    # use fillna such that idxmax raises no error on columns with only NaT
+    sp_idxmax = sp_pivot.fillna(pd.Timedelta(0)).groupby(["user_id"]).idxmax()
+
+    # preset dtype to avoid upcast (float64 -> object) in pandas (and the corresponding error)
+    sp_pivot["purpose"] = None
+    # assign empty index to idx_work/idx_home to have a default behavior for the intersection later
+    idx_work = idx_home = pd.Index([])
+    if "work" in sp_pivot.columns:
+        # first get all index of max entries (of work) that are not NaT
+        idx_work = sp_pivot.loc[sp_idxmax["work"], "work"].dropna().index
+        # set them to the corresponding purpose (work)
+        sp_pivot.loc[idx_work, "purpose"] = "work"
+
+    if "home" in sp_pivot.columns:
+        # get all index of max entries (of home) that are not NaT
+        idx_home = sp_pivot.loc[sp_idxmax["home"], "home"].dropna().index
+        # set them to the corresponding purpose (home overrides work!)
+        sp_pivot.loc[idx_home, "purpose"] = "home"
+
+    # if override happened recalculate work
+    overlap = idx_home.intersection(idx_work)
+    if not overlap.empty:
+        # remove overlap -> must take another location as everything is NaT on maximum
+        sp_pivot.loc[overlap, "work"] = pd.NaT
+        sp_idxmax = sp_pivot["work"].fillna(pd.Timedelta(0)).groupby(["user_id"]).idxmax()
+        idx_work = sp_pivot.loc[sp_idxmax, "work"].dropna().index
+        sp_pivot.loc[idx_work, "purpose"] = "work"
 
     # now join it back together
     sel = sp_in.columns != "purpose"  # no overlap with older "purpose"
diff --git a/trackintel/io/file.py b/trackintel/io/file.py
index aa34913..f20bd35 100644
--- a/trackintel/io/file.py
+++ b/trackintel/io/file.py
@@ -499,7 +499,8 @@ def write_trips_csv(trips, filename, *args, **kwargs):
     if isinstance(df, GeoDataFrame):
         geom_col_name = df.geometry.name
         df[geom_col_name] = df[geom_col_name].to_wkt()
-    df.to_csv(filename, index=True, *args, **kwargs)
+    # static call necessary as TripsDataFrame has a to_csv method as well.
+    pd.DataFrame.to_csv(df, filename, index=True, *args, **kwargs)
 
 
 @_index_warning_default_none
diff --git a/trackintel/io/from_geopandas.py b/trackintel/io/from_geopandas.py
index a0124d0..fb218e4 100644
--- a/trackintel/io/from_geopandas.py
+++ b/trackintel/io/from_geopandas.py
@@ -400,7 +400,7 @@ def _trackintel_model(gdf, set_names=None, geom_col=None, crs=None, tz_cols=None
 
     if tz_cols is not None:
         for col in tz_cols:
-            if not pd.api.types.is_datetime64tz_dtype(gdf[col]):
+            if not isinstance(gdf[col].dtype, pd.DatetimeTZDtype):
                 try:
                     gdf[col] = _localize_timestamp(dt_series=gdf[col], pytz_tzinfo=tz, col_name=col)
                 except ValueError:
diff --git a/trackintel/io/postgis.py b/trackintel/io/postgis.py
index 497f077..ff338b2 100644
--- a/trackintel/io/postgis.py
+++ b/trackintel/io/postgis.py
@@ -567,7 +567,8 @@ def write_trips_postgis(
         dtype = dtype or {}
         dtype.setdefault("trips", JSON)
     if isinstance(trips, gpd.GeoDataFrame):
-        trips.to_postgis(
+        gpd.GeoDataFrame.to_postgis(
+            trips,
             name,
             con,
             schema=schema,
diff --git a/trackintel/model/__init__.py b/trackintel/model/__init__.py
index 78771b7..65a4f39 100644
--- a/trackintel/model/__init__.py
+++ b/trackintel/model/__init__.py
@@ -2,5 +2,5 @@ from .positionfixes import Positionfixes
 from .locations import Locations
 from .triplegs import Triplegs
 from .staypoints import Staypoints
-from .trips import TripsAccessor
+from .trips import Trips
 from .tours import ToursAccessor
diff --git a/trackintel/model/positionfixes.py b/trackintel/model/positionfixes.py
index b64129a..f02beec 100644
--- a/trackintel/model/positionfixes.py
+++ b/trackintel/model/positionfixes.py
@@ -66,8 +66,8 @@ class Positionfixes(TrackintelBase, TrackintelGeoDataFrame, gpd.GeoDataFrame):
                 f" {_required_columns}, but it has [{', '.join(obj.columns)}]."
             )
         # check timestamp dtypes
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["tracked_at"]
+        assert isinstance(
+            obj["tracked_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of tracked_at is {obj['tracked_at'].dtype} but has to be datetime64 and timezone aware"
 
         # check geometry
@@ -86,7 +86,7 @@ class Positionfixes(TrackintelBase, TrackintelGeoDataFrame, gpd.GeoDataFrame):
             return False
         if obj.shape[0] <= 0:
             return False
-        if not pd.api.types.is_datetime64tz_dtype(obj["tracked_at"]):
+        if not isinstance(obj["tracked_at"].dtype, pd.DatetimeTZDtype):
             return False
         if validate_geometry:
             return obj.geometry.is_valid.all() and obj.geometry.iloc[0].geom_type == "Point"
diff --git a/trackintel/model/staypoints.py b/trackintel/model/staypoints.py
index 67d35de..3549d6c 100644
--- a/trackintel/model/staypoints.py
+++ b/trackintel/model/staypoints.py
@@ -66,11 +66,11 @@ class Staypoints(TrackintelBase, TrackintelGeoDataFrame):
                 f" {_required_columns}, but it has {', '.join(obj.columns)}."
             )
         # check timestamp dtypes
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["started_at"]
+        assert isinstance(
+            obj["started_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of started_at is {obj['started_at'].dtype} but has to be tz aware datetime64"
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["finished_at"]
+        assert isinstance(
+            obj["finished_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of finished_at is {obj['finished_at'].dtype} but has to be tz aware datetime64"
 
         if validate_geometry:
@@ -86,9 +86,9 @@ class Staypoints(TrackintelBase, TrackintelGeoDataFrame):
         """Check does the same as _validate but returns bool instead of potentially raising an error."""
         if any([c not in obj.columns for c in _required_columns]):
             return False
-        if not pd.api.types.is_datetime64tz_dtype(obj["started_at"]):
+        if not isinstance(obj["started_at"].dtype, pd.DatetimeTZDtype):
             return False
-        if not pd.api.types.is_datetime64tz_dtype(obj["finished_at"]):
+        if not isinstance(obj["finished_at"].dtype, pd.DatetimeTZDtype):
             return False
         if validate_geometry:
             return obj.geometry.is_valid.all() and obj.geometry.iloc[0].geom_type == "Point"
diff --git a/trackintel/model/tours.py b/trackintel/model/tours.py
index 8bf49d7..2e89834 100644
--- a/trackintel/model/tours.py
+++ b/trackintel/model/tours.py
@@ -42,11 +42,11 @@ class ToursAccessor(object):
             )
 
         # check timestamp dtypes
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["started_at"]
+        assert isinstance(
+            obj["started_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of started_at is {obj['started_at'].dtype} but has to be datetime64 and timezone aware"
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["finished_at"]
+        assert isinstance(
+            obj["finished_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of finished_at is {obj['finished_at'].dtype} but has to be datetime64 and timezone aware"
 
     def to_csv(self, filename, *args, **kwargs):
diff --git a/trackintel/model/triplegs.py b/trackintel/model/triplegs.py
index fdb3b88..d41d20f 100644
--- a/trackintel/model/triplegs.py
+++ b/trackintel/model/triplegs.py
@@ -67,11 +67,11 @@ class Triplegs(TrackintelBase, TrackintelGeoDataFrame):
             )
 
         # check timestamp dtypes
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["started_at"]
+        assert isinstance(
+            obj["started_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of started_at is {obj['started_at'].dtype} but has to be datetime64 and timezone aware"
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["finished_at"]
+        assert isinstance(
+            obj["finished_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of finished_at is {obj['finished_at'].dtype} but has to be datetime64 and timezone aware"
 
         # check geometry
@@ -89,9 +89,9 @@ class Triplegs(TrackintelBase, TrackintelGeoDataFrame):
             return False
         if obj.shape[0] <= 0:
             return False
-        if not pd.api.types.is_datetime64tz_dtype(obj["started_at"]):
+        if not isinstance(obj["started_at"].dtype, pd.DatetimeTZDtype):
             return False
-        if not pd.api.types.is_datetime64tz_dtype(obj["finished_at"]):
+        if not isinstance(obj["finished_at"].dtype, pd.DatetimeTZDtype):
             return False
         if validate_geometry:
             return obj.geometry.is_valid.all() and obj.geometry.iloc[0].geom_type == "LineString"
diff --git a/trackintel/model/trips.py b/trackintel/model/trips.py
index 2730ab3..1a6b98b 100644
--- a/trackintel/model/trips.py
+++ b/trackintel/model/trips.py
@@ -1,19 +1,24 @@
-from trackintel.analysis.tracking_quality import temporal_tracking_quality
-from trackintel.io.postgis import write_trips_postgis
-from trackintel.io.file import write_trips_csv
-from trackintel.model.util import _copy_docstring, _register_trackintel_accessor
-import pandas as pd
 import geopandas as gpd
+import pandas as pd
 
 import trackintel as ti
+from trackintel.analysis.tracking_quality import temporal_tracking_quality
+from trackintel.io.file import write_trips_csv
+from trackintel.io.postgis import write_trips_postgis
+from trackintel.model.util import (
+    _copy_docstring,
+    _register_trackintel_accessor,
+    TrackintelBase,
+    TrackintelDataFrame,
+    TrackintelGeoDataFrameWithFallback,
+)
 
 
 @_register_trackintel_accessor("as_trips")
-class TripsAccessor(object):
+class Trips:
     """A pandas accessor to treat (Geo)DataFrames as collections of trips.
 
-    This will define certain methods and accessors, as well as make sure that the DataFrame
-    adheres to some requirements.
+    The class constructor will create a TripsDataFrame or a TripsGeoDataFrame depending if a geometry column is present.
 
     Requires at least the following columns:
     ['user_id', 'started_at', 'finished_at', 'origin_staypoint_id', 'destination_staypoint_id']
@@ -43,36 +48,78 @@ class TripsAccessor(object):
     >>> df.as_trips.generate_tours()
     """
 
-    required_columns = ["user_id", "started_at", "finished_at", "origin_staypoint_id", "destination_staypoint_id"]
+    def __new__(cls, *args, **kwargs):
+        is_gdf = (
+            (len(args) > 0 and isinstance(args[0], gpd.GeoDataFrame))
+            or "geometry" in kwargs
+            or ("data" in kwargs and isinstance(kwargs["data"], gpd.GeoDataFrame))
+        )
+        if is_gdf:
+            return TripsGeoDataFrame(*args, **kwargs)
+        return TripsDataFrame(*args, **kwargs)
+
+
+_required_columns = ["user_id", "started_at", "finished_at", "origin_staypoint_id", "destination_staypoint_id"]
+
+
+class TripsDataFrame(TrackintelBase, TrackintelDataFrame):
+    """Class to treat a DataFrame as collections of trips.
+
+    Requires at least the following columns:
+    ['user_id', 'started_at', 'finished_at', 'origin_staypoint_id', 'destination_staypoint_id']
+
+    The 'index' of the DataFrame will be treated as unique identifier of the `Trips`
+
+    For several usecases, the following additional columns are required:
+    ['origin_purpose', 'destination_purpose', 'modes', 'primary_mode', 'tour_id']
+
+    Notes
+    -----
+    `Trips` are an aggregation level in transport planning that summarize all movement and all non-essential actions
+    (e.g., waiting) between two relevant activities. The following assumptions are implemented
+        - If we do not record a person for more than `gap_threshold` minutes, we assume that the person performed an \
+          activity in the recording gap and split the trip at the gap.
+        - Trips that start/end in a recording gap can have an unknown origin/destination staypoint id.
+        - If the origin (or destination) staypoint is unknown (and a geometry column exists), the origin/destination
+          geometry is set as the first coordinate of the first tripleg (or the last coordinate of the last tripleg)
+        - There are no trips without a (recorded) tripleg.
+
+    'started_at' and 'finished_at' are timezone aware pandas datetime objects.
+
+    Examples
+    --------
+    >>> df.as_trips.generate_tours()
+    """
 
-    def __init__(self, pandas_obj):
-        self._validate(pandas_obj)
-        self._obj = pandas_obj
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        TripsDataFrame._validate(self)  # static call
 
     @staticmethod
     def _validate(obj):
-        if any([c not in obj.columns for c in TripsAccessor.required_columns]):
+        if any([c not in obj.columns for c in _required_columns]):
             raise AttributeError(
                 "To process a DataFrame as a collection of trips, it must have the properties"
-                f" {TripsAccessor.required_columns}, but it has [{', '.join(obj.columns)}]."
+                f" {_required_columns}, but it has [{', '.join(obj.columns)}]."
             )
 
         # check timestamp dtypes
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["started_at"]
+        assert isinstance(
+            obj["started_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of started_at is {obj['started_at'].dtype} but has to be datetime64 and timezone aware"
-        assert pd.api.types.is_datetime64tz_dtype(
-            obj["finished_at"]
+        assert isinstance(
+            obj["finished_at"].dtype, pd.DatetimeTZDtype
         ), f"dtype of finished_at is {obj['finished_at'].dtype} but has to be datetime64 and timezone aware"
 
-        # Check geometry if Trips is a GeoDataFrame
-        if isinstance(obj, gpd.GeoDataFrame):
-            # check geometry
-            assert (
-                obj.geometry.is_valid.all()
-            ), "Not all geometries are valid. Try x[~ x.geometry.is_valid] where x is you GeoDataFrame"
-            if obj.geometry.iloc[0].geom_type != "MultiPoint":
-                raise AttributeError("The geometry must be a MultiPoint (only first checked).")
+    @staticmethod
+    def _check(obj):
+        if any([c not in obj.columns for c in _required_columns]):
+            return False
+        if not isinstance(obj["started_at"].dtype, pd.DatetimeTZDtype):
+            return False
+        if not isinstance(obj["finished_at"].dtype, pd.DatetimeTZDtype):
+            return False
+        return True
 
     @_copy_docstring(write_trips_csv)
     def to_csv(self, filename, *args, **kwargs):
@@ -81,7 +128,7 @@ class TripsAccessor(object):
 
         See :func:`trackintel.io.file.write_trips_csv`.
         """
-        ti.io.file.write_trips_csv(self._obj, filename, *args, **kwargs)
+        ti.io.file.write_trips_csv(self, filename, *args, **kwargs)
 
     @_copy_docstring(write_trips_postgis)
     def to_postgis(
@@ -92,7 +139,7 @@ class TripsAccessor(object):
 
         See :func:`trackintel.io.postgis.write_trips_postgis`.
         """
-        ti.io.postgis.write_trips_postgis(self._obj, name, con, schema, if_exists, index, index_label, chunksize, dtype)
+        ti.io.postgis.write_trips_postgis(self, name, con, schema, if_exists, index, index_label, chunksize, dtype)
 
     @_copy_docstring(temporal_tracking_quality)
     def temporal_tracking_quality(self, *args, **kwargs):
@@ -101,13 +148,70 @@ class TripsAccessor(object):
 
         See :func:`trackintel.analysis.tracking_quality.temporal_tracking_quality`.
         """
-        return ti.analysis.tracking_quality.temporal_tracking_quality(self._obj, *args, **kwargs)
+        return ti.analysis.tracking_quality.temporal_tracking_quality(self, *args, **kwargs)
 
-    def generate_tours(self, *args, **kwargs):
+    def generate_tours(self, **kwargs):
         """
         Generate tours based on trips (and optionally staypoint locations).
 
         See :func:`trackintel.preprocessing.trips.generate_tours`.
         """
-        assert len(args) == 0, "When calling 'generate_tours' via the accessor all arguments must be keyword arguments"
-        return ti.preprocessing.trips.generate_tours(trips=self._obj, **kwargs)
+        return ti.preprocessing.trips.generate_tours(trips=self, **kwargs)
+
+
+# added GeoDataFrame and DataFrame manually afterwards such that our methods always come first
+class TripsGeoDataFrame(TrackintelGeoDataFrameWithFallback, TripsDataFrame, gpd.GeoDataFrame):
+    """Class to treat a GeoDataFrame as collections of trips.
+
+    Requires at least the following columns:
+    ['user_id', 'started_at', 'finished_at', 'origin_staypoint_id', 'destination_staypoint_id']
+
+    The 'index' of the GeoDataFrame will be treated as unique identifier of the `Trips`
+
+    TripsGeoDataFrame must have a geometry of type MultiPoint which describes the start and the end point of the trip.
+
+    For several usecases, the following additional columns are required:
+    ['origin_purpose', 'destination_purpose', 'modes', 'primary_mode', 'tour_id']
+
+    Notes
+    -----
+    `Trips` are an aggregation level in transport planning that summarize all movement and all non-essential actions
+    (e.g., waiting) between two relevant activities. The following assumptions are implemented
+        - If we do not record a person for more than `gap_threshold` minutes, we assume that the person performed an \
+          activity in the recording gap and split the trip at the gap.
+        - Trips that start/end in a recording gap can have an unknown origin/destination staypoint id.
+        - If the origin (or destination) staypoint is unknown (and a geometry column exists), the origin/destination
+          geometry is set as the first coordinate of the first tripleg (or the last coordinate of the last tripleg)
+        - There are no trips without a (recorded) tripleg.
+
+    'started_at' and 'finished_at' are timezone aware pandas datetime objects.
+
+    Examples
+    --------
+    >>> df.as_trips.generate_tours()
+    """
+
+    fallback_class = TripsDataFrame
+
+    def __init__(self, *args, validate_geometry=True, **kwargs):
+        super().__init__(*args, **kwargs)
+        TripsGeoDataFrame._validate(self, validate_geometry=validate_geometry)
+
+    @staticmethod
+    def _validate(self, validate_geometry=True):
+        if not validate_geometry:
+            return
+        # _validate should not be called from the outside -> we can use fact that it is called only from __init__
+        # therefore TrackintelDataFrame validated all the columns and here we only have to validate the geometry
+        assert (
+            self.geometry.is_valid.all()
+        ), "Not all geometries are valid. Try x[~x.geometry.is_valid] where x is you GeoDataFrame"
+        if self.geometry.iloc[0].geom_type != "MultiPoint":
+            raise ValueError("The geometry must be a MultiPoint (only first checked).")
+
+    @staticmethod
+    def _check(self, validate_geometry=True):
+        val = TripsDataFrame._check(self)
+        if not validate_geometry:
+            return val
+        return val and self.geometry.is_valid.all() and self.geometry.iloc[0].geom_type == "MultiPoint"
diff --git a/trackintel/model/util.py b/trackintel/model/util.py
index 6530daf..296c95a 100644
--- a/trackintel/model/util.py
+++ b/trackintel/model/util.py
@@ -152,29 +152,109 @@ class TrackintelGeoDataFrame(GeoDataFrame):
         """Interface to subtype pandas properly"""
         # we loose access to self in inner function -> write objects to local variables
         super_cons = super()._constructor
-        class_cons = self.__class__
-        check = self._check
 
         def _constructor_with_fallback(*args, **kwargs):
             result = super_cons(*args, **kwargs)
             # cannot validate_geometry as geometry column is maybe not set
-            if isinstance(result, GeoDataFrame) and check(result, validate_geometry=False):
-                return class_cons(result, validate_geometry=False)
+            if isinstance(result, GeoDataFrame) and self._check(result, validate_geometry=False):
+                return self.__class__(result, validate_geometry=False)
             return result
 
         return _constructor_with_fallback
 
 
-class TrackintelBase(object):
-    """Class for supplying basic functionality to all Trackintel classes."""
+def _wrapped_gdf_method_fallback(func):
+    """Decorator function that downcast types to trackintel class if is (Geo)DataFrame and has the required columns."""
 
-    # so far we don't have a lot of methods here
-    # but a lot of IO code can be moved here.
+    @wraps(func)  # copy all metadata
+    def wrapper(self, *args, **kwargs):
+        result = func(self, *args, **kwargs)
+        if isinstance(result, pd.DataFrame) and self._check(result, validate_geometry=False):
+            if isinstance(result, GeoDataFrame):
+                result.__class__ = self.__class__
+            else:
+                result.__class__ = self.fallback_class
+        return result
+
+    return wrapper
+
+
+# short and memorizable name
+class TrackintelGeoDataFrameWithFallback(GeoDataFrame):
+    """
+    Helper class to subtype GeoDataFrame correctly and fallback to custom class.
+
+    Fallback to fallback_class if looses geometry but _check does still succeed.
+    Fallback to GeoDataFrame if still has geometry but _check fails.
+    Fallback to DataFrame if looses geometry and _check fails.
+    """
+
+    fallback_class = None
 
     @staticmethod
     def _check(self, validate_geometry=True):
         raise NotImplementedError
 
+    @property
+    def _constructor(self):
+        """Interface to subtype pandas properly"""
+        super_cons = super()._constructor
+
+        def _constructor_with_fallback(*args, **kwargs):
+            result = super_cons(*args, **kwargs)
+            if not self._check(result, validate_geometry=False):
+                # check fails -> not one of our classes
+                return result
+            if isinstance(result, GeoDataFrame):
+                # cannot validate geometry as geometry column is maybe not set
+                return self.__class__(result, validate_geometry=False)
+            return self.fallback_class(result)
+
+        return _constructor_with_fallback
+
+    # Following methods manually set self.__class__ fix to GeoDataFrame.
+    # Thus to properly subtype, we need to downcast them with the _wrapped_gdf_method_fallback decorator.
+    @_wrapped_gdf_method_fallback
+    def __getitem__(self, key):
+        return super().__getitem__(key)
+
+    @_wrapped_gdf_method_fallback
+    def copy(self, deep=True):
+        return super().copy(deep=deep)
+
+    @_wrapped_gdf_method_fallback
+    def merge(self, *args, **kwargs):
+        return super().merge(*args, **kwargs)
+
+
+class TrackintelDataFrame(pd.DataFrame):
+    """Helper class to subtype DataFrame and handle fallback"""
+
+    @staticmethod
+    def _check(self):  # has no geometry to check
+        raise NotImplementedError
+
+    @property
+    def _constructor(self):
+        """Interface to subtype pandas properly"""
+        super_cons = super()._constructor
+
+        def _constructor_with_fallback(*args, **kwargs):
+            result = super_cons(*args, **kwargs)
+            if not self._check(result):
+                return result
+            return self.__class__(result)
+
+        return _constructor_with_fallback
+
+
+class TrackintelBase(object):
+    """Class for supplying basic functionality to all Trackintel classes."""
+
+    # so far we don't have a lot of methods here
+    # but a lot of IO code can be moved here.
+    pass
+
 
 class NonCachedAccessor:
     def __init__(self, name: str, accessor) -> None:
diff --git a/trackintel/preprocessing/positionfixes.py b/trackintel/preprocessing/positionfixes.py
index 07af030..d648d29 100644
--- a/trackintel/preprocessing/positionfixes.py
+++ b/trackintel/preprocessing/positionfixes.py
@@ -355,14 +355,14 @@ def generate_triplegs(
 
         # fill the pd.NAs with the previously observed tripleg_id
         # pfs not belonging to tripleg are also propagated (with -1)
-        pfs["tripleg_id"] = pfs["tripleg_id"].fillna(method="ffill")
+        pfs["tripleg_id"] = pfs["tripleg_id"].ffill()
         # assign back pd.NA to -1
         pfs.loc[pfs["tripleg_id"] == -1, "tripleg_id"] = pd.NA
 
         posfix_grouper = pfs.groupby("tripleg_id")
 
         tpls = posfix_grouper.agg(
-            {"user_id": ["first"], "tracked_at": [min, max], pfs.geometry.name: list}
+            {"user_id": ["first"], "tracked_at": ["min", "max"], pfs.geometry.name: list}
         )  # could add a "number of pfs": can be any column "count"
 
         # prepare dataframe: Rename columns; read/set geometry/crs;
diff --git a/trackintel/preprocessing/triplegs.py b/trackintel/preprocessing/triplegs.py
index d15baa3..0104710 100644
--- a/trackintel/preprocessing/triplegs.py
+++ b/trackintel/preprocessing/triplegs.py
@@ -86,7 +86,8 @@ def generate_trips(staypoints, triplegs, gap_threshold=15, add_geometry=True):
     # assign an incrementing id to all triplegs that start a trip
     # temporary as empty trips are not filtered out yet.
     sp_tpls.loc[new_trip, "temp_trip_id"] = np.arange(new_trip.sum())
-    sp_tpls["temp_trip_id"].fillna(method="ffill", inplace=True)
+    # fill NA with previous entry
+    sp_tpls["temp_trip_id"].ffill(inplace=True)
 
     # exclude activities to aggregate trips together.
     # activity can be thought of as the same aggregation level as trips.
@@ -95,7 +96,7 @@ def generate_trips(staypoints, triplegs, gap_threshold=15, add_geometry=True):
 
     trips_grouper = sp_tpls_no_act.groupby("temp_trip_id")
     trips = trips_grouper.agg(
-        {"user_id": "first", "started_at": min, "finished_at": max, "type": list, "sp_tpls_id": list}
+        {"user_id": "first", "started_at": "min", "finished_at": "max", "type": list, "sp_tpls_id": list}
     )
 
     def _seperate_ids(row):


Test patch:
diff --git a/.github/envs/39-latest-conda-forge.yaml b/.github/envs/39-latest-conda-forge.yaml
index 2ec862d..3be8e80 100644
--- a/.github/envs/39-latest-conda-forge.yaml
+++ b/.github/envs/39-latest-conda-forge.yaml
@@ -3,26 +3,21 @@ channels:
   - conda-forge
 dependencies:
 - python
-
-# required
-- matplotlib
 - numpy
-- pandas
+- matplotlib
+- geopandas>=0.12.0
 - scikit-learn
-- networkx 
+- networkx
 - pint
-- pip 
-- geoalchemy2 
-- osmnx 
+- pip
+- geoalchemy2
+- osmnx
 - psycopg2
 - tqdm
 - similaritymeasures
 - jupyter
-- geopandas>=0.12.0
 
 # tests
 - pytest
 - pytest-cov
 - pytest-xdist
-
-  
diff --git a/tests/analysis/test_location_identification.py b/tests/analysis/test_location_identification.py
index 2246010..f50de46 100644
--- a/tests/analysis/test_location_identification.py
+++ b/tests/analysis/test_location_identification.py
@@ -307,6 +307,7 @@ class TestOsna_Method:
     def test_default(self, example_osna):
         """Test with no changes to test data."""
         osna = osna_method(example_osna)
+        example_osna["purpose"] = None
         example_osna.loc[example_osna["location_id"] == 0, "purpose"] = "home"
         example_osna.loc[example_osna["location_id"] == 1, "purpose"] = "work"
         assert_geodataframe_equal(example_osna, osna)
@@ -327,6 +328,7 @@ class TestOsna_Method:
         sp = pd.concat((example_osna, sp))
 
         result = osna_method(sp).iloc[:-2]
+        example_osna["purpose"] = None
         example_osna.loc[example_osna["location_id"] == 0, "purpose"] = "home"
         example_osna.loc[example_osna["location_id"] == 1, "purpose"] = "work"
         assert_geodataframe_equal(result, example_osna)
@@ -358,6 +360,7 @@ class TestOsna_Method:
         two_user = pd.concat((example_osna, example_osna))
         two_user.iloc[len(example_osna) :, 0] = 1  # second user gets id 1
         result = osna_method(two_user)
+        two_user["purpose"] = None
         two_user.loc[two_user["location_id"] == 0, "purpose"] = "home"
         two_user.loc[two_user["location_id"] == 1, "purpose"] = "work"
         assert_geodataframe_equal(result, two_user)
@@ -385,6 +388,7 @@ class TestOsna_Method:
         sp = gpd.GeoDataFrame(data=list_dict, geometry="geom", crs="EPSG:4326")
         sp.index.name = "id"
         result = osna_method(sp)
+        sp["purpose"] = None
         sp.loc[sp["location_id"] == 1, "purpose"] = "home"
         sp.loc[sp["location_id"] == 2, "purpose"] = "work"
         assert_geodataframe_equal(sp, result)
@@ -429,7 +433,7 @@ class TestOsna_Method:
         """Test that prior purpose column does not corrupt output."""
         example_osna["purpose"] = np.arange(len(example_osna))
         result = osna_method(example_osna)
-        del example_osna["purpose"]
+        example_osna["purpose"] = None
         example_osna.loc[example_osna["location_id"] == 0, "purpose"] = "home"
         example_osna.loc[example_osna["location_id"] == 1, "purpose"] = "work"
         assert_geodataframe_equal(example_osna, result)
diff --git a/tests/io/test_file.py b/tests/io/test_file.py
index 237a0b7..316e6f3 100644
--- a/tests/io/test_file.py
+++ b/tests/io/test_file.py
@@ -47,7 +47,7 @@ class TestPositionfixes:
         # check if tz is added to the datatime column
         file = os.path.join("tests", "data", "positionfixes.csv")
         pfs = ti.read_positionfixes_csv(file, sep=";", index_col="id")
-        assert pd.api.types.is_datetime64tz_dtype(pfs["tracked_at"])
+        assert isinstance(pfs["tracked_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a timezone will be set without storing the timezone
         date_format = "%Y-%m-%d %H:%M:%S"
@@ -55,7 +55,7 @@ class TestPositionfixes:
         pfs.as_positionfixes.to_csv(tmp_file, sep=";", date_format=date_format)
         pfs = ti.read_positionfixes_csv(tmp_file, sep=";", index_col="id", tz="utc")
 
-        assert pd.api.types.is_datetime64tz_dtype(pfs["tracked_at"])
+        assert isinstance(pfs["tracked_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a warning is raised if 'tz' is not provided
         with pytest.warns(UserWarning):
@@ -115,7 +115,7 @@ class TestTriplegs:
         # check if tz is added to the datatime column
         file = os.path.join("tests", "data", "triplegs.csv")
         tpls = ti.read_triplegs_csv(file, sep=";", index_col="id")
-        assert pd.api.types.is_datetime64tz_dtype(tpls["started_at"])
+        assert isinstance(tpls["started_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a timezone will be set without storing the timezone
         tmp_file = os.path.join("tests", "data", "triplegs_test_2.csv")
@@ -123,7 +123,7 @@ class TestTriplegs:
         tpls.as_triplegs.to_csv(tmp_file, sep=";", date_format=date_format)
         tpls = ti.read_triplegs_csv(tmp_file, sep=";", index_col="id", tz="utc")
 
-        assert pd.api.types.is_datetime64tz_dtype(tpls["started_at"])
+        assert isinstance(tpls["started_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a warning is raised if 'tz' is not provided
         with pytest.warns(UserWarning):
@@ -180,7 +180,7 @@ class TestStaypoints:
         # check if tz is added to the datatime column
         file = os.path.join("tests", "data", "staypoints.csv")
         sp = ti.read_staypoints_csv(file, sep=";", index_col="id")
-        assert pd.api.types.is_datetime64tz_dtype(sp["started_at"])
+        assert isinstance(sp["started_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a timezone will be without storing the timezone
         tmp_file = os.path.join("tests", "data", "staypoints_test_2.csv")
@@ -188,7 +188,7 @@ class TestStaypoints:
         sp.as_staypoints.to_csv(tmp_file, sep=";", date_format=date_format)
         sp = ti.read_staypoints_csv(tmp_file, sep=";", index_col="id", tz="utc")
 
-        assert pd.api.types.is_datetime64tz_dtype(sp["started_at"])
+        assert isinstance(sp["started_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a warning is raised if 'tz' is not provided
         with pytest.warns(UserWarning):
@@ -305,7 +305,7 @@ class TestTrips:
         # check if tz is added to the datatime column
         file = os.path.join("tests", "data", "trips.csv")
         trips = ti.read_trips_csv(file, sep=";", index_col="id")
-        assert pd.api.types.is_datetime64tz_dtype(trips["started_at"])
+        assert isinstance(trips["started_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a timezone will be set without storing the timezone
         tmp_file = os.path.join("tests", "data", "trips_test_2.csv")
@@ -313,7 +313,7 @@ class TestTrips:
         trips.as_trips.to_csv(tmp_file, sep=";", date_format=date_format)
         trips = ti.read_trips_csv(tmp_file, sep=";", index_col="id", tz="utc")
 
-        assert pd.api.types.is_datetime64tz_dtype(trips["started_at"])
+        assert isinstance(trips["started_at"].dtype, pd.DatetimeTZDtype)
 
         # check if a warning is raised if 'tz' is not provided
         with pytest.warns(UserWarning):
diff --git a/tests/model/test_trips.py b/tests/model/test_trips.py
index b6e5a1d..3678e75 100644
--- a/tests/model/test_trips.py
+++ b/tests/model/test_trips.py
@@ -3,6 +3,7 @@ import pytest
 from shapely.geometry import Point
 
 import trackintel as ti
+from trackintel import TripsDataFrame, TripsGeoDataFrame
 
 
 @pytest.fixture
@@ -14,7 +15,7 @@ def testdata_trips():
 
 
 class TestTrips:
-    """Tests for the TripsAccessor."""
+    """Tests for the Trips Classes."""
 
     def test_accessor(self, testdata_trips):
         """Test if the as_trips accessor checks the required column for trips."""
@@ -30,7 +31,7 @@ class TestTrips:
         trips_wrong_geom = testdata_trips.copy()
 
         # check geometry type
-        with pytest.raises(AttributeError, match="The geometry must be a MultiPoint"):
+        with pytest.raises(ValueError, match="The geometry must be a MultiPoint"):
             trips_wrong_geom["geom"] = Point([(13.476808430, 48.573711823)])
             trips_wrong_geom.as_trips
 
@@ -43,5 +44,40 @@ class TestTrips:
         trips_other_geom_name.drop(columns=["geom"], inplace=True)
 
         # check that it works with other geometry name
-        with pytest.raises(AttributeError, match="The geometry must be a MultiPoint"):
+        with pytest.raises(ValueError, match="The geometry must be a MultiPoint"):
             trips_other_geom_name.as_trips
+
+
+class TestTripsDataFrame:
+    """Test TripsDataFrame class"""
+
+    def test_check_suceeding(self, testdata_trips):
+        """Test if check returns True on valid trips"""
+        assert TripsDataFrame._check(testdata_trips)
+
+    def test_check_missing_columns(self, testdata_trips):
+        """Test if check returns False if column is missing"""
+        assert not TripsDataFrame._check(testdata_trips.drop(columns="user_id"))
+
+    def test_check_no_tz(self, testdata_trips):
+        """Test if check returns False if datetime columns have no tz"""
+        tmp = testdata_trips["started_at"]
+        testdata_trips["started_at"] = testdata_trips["started_at"].dt.tz_localize(None)
+        assert not TripsDataFrame._check(testdata_trips)
+        testdata_trips["started_at"] = tmp
+        testdata_trips["finished_at"] = testdata_trips["finished_at"].dt.tz_localize(None)
+        assert not TripsDataFrame._check(testdata_trips)
+
+
+class TestTripsGeoDataFrame:
+    """Test TripsGeoDataFrame class"""
+
+    def test_check_false_geometry_type(self, testdata_trips):
+        """Test if check returns False if geometry type is wrong"""
+        testdata_trips["geom"] = Point((13.476808430, 48.573711823))
+        assert not TripsGeoDataFrame._check(testdata_trips)
+
+    def test_check_ignore_false_geometry_type(self, testdata_trips):
+        """Test if check returns True if geometry type is wrong but validate_geometry is set to False"""
+        testdata_trips["geom"] = Point((13.476808430, 48.573711823))
+        assert TripsGeoDataFrame._check(testdata_trips, validate_geometry=False)
diff --git a/tests/model/test_util.py b/tests/model/test_util.py
index 90533c3..02f9d69 100644
--- a/tests/model/test_util.py
+++ b/tests/model/test_util.py
@@ -16,8 +16,11 @@ from trackintel.model.util import (
     _copy_docstring,
     _register_trackintel_accessor,
     _wrapped_gdf_method,
+    _wrapped_gdf_method_fallback,
     get_speed_positionfixes,
     TrackintelGeoDataFrame,
+    TrackintelGeoDataFrameWithFallback,
+    TrackintelDataFrame,
 )
 
 
@@ -245,10 +248,10 @@ class Test_wrapped_gdf_method:
     def test_no_geodataframe(self, example_positionfixes):
         """Test if function return value does not subclass GeoDataFrame then __class__ is not touched"""
 
+        @_wrapped_gdf_method
         def foo(gdf: GeoDataFrame) -> pd.DataFrame:
             return gdf.drop(columns=gdf.geometry.name)
 
-        foo = _wrapped_gdf_method(foo)
         assert type(foo(example_positionfixes)) is pd.DataFrame
 
     def test_failed_check(self, example_positionfixes):
@@ -282,6 +285,67 @@ class Test_wrapped_gdf_method:
         assert type(foo(a)) is A
 
 
+class Test_wrapped_gdf_method_fallback:
+    """Test if _wrapped_gdf_method_fallback conditionals work"""
+
+    def test_no_geodataframe(self, example_positionfixes):
+        """Test if function return value does not subclass DataFrame then __class__ is not touched"""
+
+        @_wrapped_gdf_method_fallback
+        def foo(gdf: GeoDataFrame) -> pd.Series:
+            return pd.Series(gdf.iloc[0])
+
+        assert type(foo(example_positionfixes)) is pd.Series
+
+    def test_failed_check(self, example_positionfixes):
+        """Test if _check fails then __class__ is not touched"""
+
+        class A(GeoDataFrame):
+            @staticmethod
+            def _check(obj, validate_geometry=True):
+                return False
+
+        @_wrapped_gdf_method_fallback
+        def foo(a: A) -> GeoDataFrame:
+            return GeoDataFrame(a)
+
+        a = A(example_positionfixes)
+        assert type(foo(a)) is GeoDataFrame
+
+    def test_keep_class(self, example_positionfixes):
+        """Test if original class is restored if return value subclasses GeoDataFarme and fulfills _check"""
+
+        class A(GeoDataFrame):
+            @staticmethod
+            def _check(obj, validate_geometry=True):
+                return True
+
+        @_wrapped_gdf_method_fallback
+        def foo(a: A) -> GeoDataFrame:
+            return GeoDataFrame(a)
+
+        a = A(example_positionfixes)
+        assert type(foo(a)) is A
+
+    def test_fallback(self, example_positionfixes):
+        class B(pd.DataFrame):
+            pass
+
+        class A(GeoDataFrame):
+            fallback_class = B
+
+            @staticmethod
+            def _check(obj, validate_geometry=True):
+                return True
+
+        @_wrapped_gdf_method_fallback
+        def foo(a: A) -> pd.DataFrame:
+            return pd.DataFrame(a)
+
+        a = A(example_positionfixes)
+        assert type(foo(a)) is B
+
+
 class TestTrackintelGeoDataFrame:
     """Test helper class TrackintelGeoDataFrame."""
 
@@ -325,6 +389,85 @@ class TestTrackintelGeoDataFrame:
         assert type(a._constructor(a)) is self.A
 
 
+class TestTrackintelGeoDataFrameWithFallback:
+    """Test helper class TrackintelGeoDataFrameWithFallback."""
+
+    class A(TrackintelGeoDataFrameWithFallback):
+        """Mimic TrackintelGeoDataFrameWithFallback subclass by taking the same arguments"""
+
+        class AFallback(TrackintelDataFrame):
+            pass
+
+        fallback_class = AFallback
+
+        def __init__(self, *args, validate_geometry=True, **kwargs):
+            super().__init__(*args, **kwargs)
+
+        @staticmethod
+        def _check(obj, validate_geometry=True):
+            return "user_id" in obj.columns
+
+    def test_getitem(self, example_positionfixes):
+        """Test if loc on all columns returns original class."""
+        a = self.A(example_positionfixes)
+        b = a.loc[[True for _ in a.columns]]
+        assert type(b) is self.A
+
+    def test_copy(self, example_positionfixes):
+        """Test if copy maintains class."""
+        a = self.A(example_positionfixes)
+        b = a.copy()
+        assert type(b) is self.A
+
+    def test_merge(self, example_positionfixes):
+        """Test if merge maintains class"""
+        a = self.A(example_positionfixes)
+        b = a.merge(a, on="user_id", suffixes=("", "_other"))
+        assert type(b) is self.A
+
+    def test_constructor_fails_check(self, example_positionfixes):
+        """Test if falls through if _check fails"""
+        a = self.A(example_positionfixes)
+        # a.drop(columns=geometryname, inplace=True) fails
+        # this is also true for geopandas their motivation can be found here:
+        # https://github.com/geopandas/geopandas/pull/2060#issuecomment-899802955
+        a = a.drop(columns="user_id")
+        assert type(a) is GeoDataFrame
+
+    def test_constructor_fallback_class(self, example_positionfixes):
+        """Test if _constructor gets DataFrame it falls through"""
+        a = self.A(example_positionfixes)
+        a = a.drop(columns=a.geometry.name)
+        assert type(a) is self.A.fallback_class
+
+    def test_constructor_calls_init(self, example_positionfixes):
+        """Test if _constructor gets GeoDataFrame and fulfills test then builds class"""
+        a = self.A(example_positionfixes)
+        assert type(a._constructor(a)) is self.A
+
+
+class TestTrackintelDataFrame:
+    """Test helper class TrackintelDataFrame."""
+
+    class A(TrackintelDataFrame):
+        """Mimic TrackintelDataFrame subclass by taking the same arguments"""
+
+        @staticmethod
+        def _check(obj):
+            return "user_id" in obj.columns
+
+    def test_constructor_fails_check(self, example_positionfixes):
+        """Test if falls through if _check fails"""
+        a = self.A(example_positionfixes)
+        a = a.drop(columns="user_id")
+        assert type(a) is pd.DataFrame
+
+    def test_constructor_calls_init(self, example_positionfixes):
+        """Test if _constructor gets GeoDataFrame and fulfills test then builds class"""
+        a = self.A(example_positionfixes)
+        assert type(a._constructor(a)) is self.A
+
+
 class TestNonCachedAccessor:
     """Test if NonCachedAccessor works"""
 
diff --git a/tests/preprocessing/test_trips.py b/tests/preprocessing/test_trips.py
index 266f9ad..d5bb6c0 100644
--- a/tests/preprocessing/test_trips.py
+++ b/tests/preprocessing/test_trips.py
@@ -254,7 +254,7 @@ class TestGenerate_tours:
         # generate tours using the accessor
         trips_acc, tours_acc = trips.as_trips.generate_tours()
 
-        pd.testing.assert_frame_equal(trips_expl, trips_acc)
+        pd.testing.assert_frame_equal(trips_expl, trips_acc, check_frame_type=False)
         pd.testing.assert_frame_equal(tours_expl, tours_acc)
 
     def test_print_progress_flag(self, example_trip_data, capsys):