Repository: mie-lab/trackintel
Pull request #509 (2023-08-16 09:58:13)

ENH: enable subclassing for Staypoints
Here comes the next class :)
For more info see #490 .

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?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.05%`** :tada:
> Comparison is base [(`e2bc973`)](https://app.codecov.io/gh/mie-lab/trackintel/commit/e2bc97355eede67af4f12a1a052024fdd7c9bdb2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 92.71% compared to head [(`eac46d7`)](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 92.76%.

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


```diff
@@            Coverage Diff             @@
##           master     #509      +/-   ##
==========================================
+ Coverage   92.71%   92.76%   +0.05%     
==========================================
  Files          40       40              
  Lines        2086     2102      +16     
  Branches      374      381       +7     
==========================================
+ Hits         1934     1950      +16     
  Misses        133      133              
  Partials       19       19              
```


| [Files Changed](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?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/509?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/analysis/tracking\_quality.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9hbmFseXNpcy90cmFja2luZ19xdWFsaXR5LnB5) | `100.00% <100.00%> (ø)` | |
| [trackintel/io/postgis.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?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/509?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/staypoints.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9tb2RlbC9zdGF5cG9pbnRzLnB5) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mie-lab/trackintel/pull/509?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/docs/modules/model.rst b/docs/modules/model.rst
index fea69ff..dd41dde 100644
--- a/docs/modules/model.rst
+++ b/docs/modules/model.rst
@@ -62,10 +62,10 @@ Positionfixes
 .. autoclass:: trackintel.model.positionfixes.Positionfixes
 	:members:
 
-StaypointsAccessor
+Staypoints
 ------------------
 
-.. autoclass:: trackintel.model.staypoints.StaypointsAccessor
+.. autoclass:: trackintel.model.staypoints.Staypoints
 	:members:
 
 TriplegsAccessor
diff --git a/trackintel/__init__.py b/trackintel/__init__.py
index cbc4de2..3bb238d 100644
--- a/trackintel/__init__.py
+++ b/trackintel/__init__.py
@@ -11,6 +11,7 @@ from trackintel.io.file import read_trips_csv
 from trackintel.io.file import read_tours_csv
 
 from trackintel.model.positionfixes import Positionfixes
+from trackintel.model.staypoints import Staypoints
 
 from trackintel.__version__ import __version__
 from .core import print_version
diff --git a/trackintel/analysis/tracking_quality.py b/trackintel/analysis/tracking_quality.py
index 7d5215d..43774c7 100644
--- a/trackintel/analysis/tracking_quality.py
+++ b/trackintel/analysis/tracking_quality.py
@@ -195,7 +195,7 @@ def _split_overlaps(source, granularity="day"):
     gdf = source.copy()
     gdf[["started_at", "finished_at"]] = gdf.apply(_get_times, axis="columns", result_type="expand", freq=freq)
     # must call DataFrame.explode directly because GeoDataFrame.explode cannot be used on multiple columns
-    gdf = super(type(gdf), gdf).explode(["started_at", "finished_at"], ignore_index=True)
+    gdf = pd.DataFrame.explode(gdf, ["started_at", "finished_at"], ignore_index=True)
     if "duration" in gdf.columns:
         gdf["duration"] = gdf["finished_at"] - gdf["started_at"]
     return gdf
diff --git a/trackintel/io/postgis.py b/trackintel/io/postgis.py
index 69b0d5b..f92a01e 100644
--- a/trackintel/io/postgis.py
+++ b/trackintel/io/postgis.py
@@ -323,7 +323,8 @@ def read_staypoints_postgis(
 def write_staypoints_postgis(
     staypoints, name, con, schema=None, if_exists="fail", index=True, index_label=None, chunksize=None, dtype=None
 ):
-    staypoints.to_postgis(
+    gpd.GeoDataFrame.to_postgis(
+        staypoints,
         name,
         con,
         schema=schema,
diff --git a/trackintel/model/__init__.py b/trackintel/model/__init__.py
index 7af7bac..ce01a00 100644
--- a/trackintel/model/__init__.py
+++ b/trackintel/model/__init__.py
@@ -1,5 +1,5 @@
 from .positionfixes import Positionfixes
-from .staypoints import StaypointsAccessor
+from .staypoints import Staypoints
 from .triplegs import TriplegsAccessor
 from .locations import LocationsAccessor
 from .trips import TripsAccessor
diff --git a/trackintel/model/staypoints.py b/trackintel/model/staypoints.py
index de374d5..4dd2ae9 100644
--- a/trackintel/model/staypoints.py
+++ b/trackintel/model/staypoints.py
@@ -1,18 +1,26 @@
 import pandas as pd
+
 import trackintel as ti
 from trackintel.analysis.labelling import create_activity_flag
 from trackintel.analysis.tracking_quality import temporal_tracking_quality
 from trackintel.io.file import write_staypoints_csv
 from trackintel.io.postgis import write_staypoints_postgis
-from trackintel.model.util import _copy_docstring, _register_trackintel_accessor
+from trackintel.model.util import (
+    TrackintelBase,
+    TrackintelGeoDataFrame,
+    _copy_docstring,
+    _register_trackintel_accessor,
+)
 from trackintel.preprocessing.filter import spatial_filter
 from trackintel.preprocessing.staypoints import generate_locations, merge_staypoints
 from trackintel.visualization.staypoints import plot_staypoints
 
+_required_columns = ["user_id", "started_at", "finished_at"]
+
 
 @_register_trackintel_accessor("as_staypoints")
-class StaypointsAccessor(object):
-    """A pandas accessor to treat (Geo)DataFrames as collections of `Staypoints`.
+class Staypoints(TrackintelBase, TrackintelGeoDataFrame):
+    """A pandas accessor to treat a GeoDataFrame as collections of `Staypoints`.
 
     This will define certain methods and accessors, as well as make sure that the DataFrame
     adheres to some requirements.
@@ -41,27 +49,23 @@ class StaypointsAccessor(object):
     >>> df.as_staypoints.generate_locations()
     """
 
-    required_columns = ["user_id", "started_at", "finished_at"]
+    def __init__(self, *args, validate_geometry=True, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._validate(self, validate_geometry=validate_geometry)
 
-    def __init__(self, pandas_obj):
-        self._validate(pandas_obj)
-        self._obj = pandas_obj
+    # create circular reference directly -> avoid second call of init via accessor
+    @property
+    def as_staypoints(self):
+        return self
 
     @staticmethod
-    def _validate(obj):
+    def _validate(obj, validate_geometry=True):
         # check columns
-        if any([c not in obj.columns for c in StaypointsAccessor.required_columns]):
+        if any([c not in obj.columns for c in _required_columns]):
             raise AttributeError(
                 "To process a DataFrame as a collection of staypoints, it must have the properties"
-                f" {StaypointsAccessor.required_columns}, but it has {', '.join(obj.columns)}."
+                f" {_required_columns}, but it has {', '.join(obj.columns)}."
             )
-        # 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 != "Point":
-            raise AttributeError("The geometry must be a Point (only first checked).")
-
         # check timestamp dtypes
         assert pd.api.types.is_datetime64tz_dtype(
             obj["started_at"]
@@ -70,11 +74,32 @@ class StaypointsAccessor(object):
             obj["finished_at"]
         ), f"dtype of finished_at is {obj['finished_at'].dtype} but has to be tz aware datetime64"
 
+        if validate_geometry:
+            # 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 != "Point":
+                raise AttributeError("The geometry must be a Point (only first checked).")
+
+    @staticmethod
+    def _check(obj, validate_geometry=True):
+        """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"]):
+            return False
+        if not pd.api.types.is_datetime64tz_dtype(obj["finished_at"]):
+            return False
+        if validate_geometry:
+            return obj.geometry.is_valid.all() and obj.geometry.iloc[0].geom_type == "Point"
+        return True
+
     @property
     def center(self):
         """Return the center coordinate of this collection of staypoints."""
-        lat = self._obj.geometry.y
-        lon = self._obj.geometry.x
+        lat = self.geometry.y
+        lon = self.geometry.x
         return (float(lon.mean()), float(lat.mean()))
 
     @_copy_docstring(generate_locations)
@@ -84,7 +109,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.preprocessing.staypoints.generate_locations`.
         """
-        return ti.preprocessing.staypoints.generate_locations(self._obj, *args, **kwargs)
+        return ti.preprocessing.staypoints.generate_locations(self, *args, **kwargs)
 
     @_copy_docstring(merge_staypoints)
     def merge_staypoints(self, *args, **kwargs):
@@ -93,7 +118,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.preprocessing.staypoints.merge_staypoints`.
         """
-        return ti.preprocessing.staypoints.merge_staypoints(self._obj, *args, **kwargs)
+        return ti.preprocessing.staypoints.merge_staypoints(self, *args, **kwargs)
 
     @_copy_docstring(create_activity_flag)
     def create_activity_flag(self, *args, **kwargs):
@@ -102,7 +127,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.analysis.labelling.create_activity_flag`.
         """
-        return ti.analysis.labelling.create_activity_flag(self._obj, *args, **kwargs)
+        return ti.analysis.labelling.create_activity_flag(self, *args, **kwargs)
 
     @_copy_docstring(spatial_filter)
     def spatial_filter(self, *args, **kwargs):
@@ -111,7 +136,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.preprocessing.filter.spatial_filter`.
         """
-        return ti.preprocessing.filter.spatial_filter(self._obj, *args, **kwargs)
+        return ti.preprocessing.filter.spatial_filter(self, *args, **kwargs)
 
     @_copy_docstring(plot_staypoints)
     def plot(self, *args, **kwargs):
@@ -120,7 +145,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.visualization.staypoints.plot_staypoints`.
         """
-        ti.visualization.staypoints.plot_staypoints(self._obj, *args, **kwargs)
+        ti.visualization.staypoints.plot_staypoints(self, *args, **kwargs)
 
     @_copy_docstring(write_staypoints_csv)
     def to_csv(self, filename, *args, **kwargs):
@@ -129,7 +154,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.io.file.write_staypoints_csv`.
         """
-        ti.io.file.write_staypoints_csv(self._obj, filename, *args, **kwargs)
+        ti.io.file.write_staypoints_csv(self, filename, *args, **kwargs)
 
     @_copy_docstring(write_staypoints_postgis)
     def to_postgis(
@@ -140,9 +165,7 @@ class StaypointsAccessor(object):
 
         See :func:`trackintel.io.postgis.write_staypoints_postgis`.
         """
-        ti.io.postgis.write_staypoints_postgis(
-            self._obj, name, con, schema, if_exists, index, index_label, chunksize, dtype
-        )
+        ti.io.postgis.write_staypoints_postgis(self, name, con, schema, if_exists, index, index_label, chunksize, dtype)
 
     @_copy_docstring(temporal_tracking_quality)
     def temporal_tracking_quality(self, *args, **kwargs):
@@ -151,4 +174,4 @@ class StaypointsAccessor(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)


Test patch:
diff --git a/tests/model/test_staypoints.py b/tests/model/test_staypoints.py
index 5f4131e..a21c233 100644
--- a/tests/model/test_staypoints.py
+++ b/tests/model/test_staypoints.py
@@ -4,6 +4,7 @@ import pytest
 from shapely.geometry import LineString
 
 import trackintel as ti
+from trackintel import Staypoints
 
 
 @pytest.fixture
@@ -15,7 +16,7 @@ def testdata_sp():
 
 
 class TestStaypoints:
-    """Tests for the StaypointsAccessor."""
+    """Tests for the Staypoints class."""
 
     def test_accessor_columns(self, testdata_sp):
         """Test if the as_staypoints accessor checks the required column for staypoints."""
@@ -46,3 +47,34 @@ class TestStaypoints:
         """Check if sp has center method and returns (lat, lon) pairs as geometry."""
         sp = testdata_sp.copy()
         assert len(sp.as_staypoints.center) == 2
+
+    def test_check_suceeding(self, testdata_sp):
+        """Test if check returns True on valid sp"""
+        assert Staypoints._check(testdata_sp)
+
+    def test_check_missing_columns(self, testdata_sp):
+        """Test if check returns False if column is missing"""
+        assert not Staypoints._check(testdata_sp.drop(columns="user_id"))
+
+    def test_check_no_tz(self, testdata_sp):
+        """Test if check returns False if datetime columns have no tz"""
+        tmp = testdata_sp["started_at"]
+        testdata_sp["started_at"] = testdata_sp["started_at"].dt.tz_localize(None)
+        assert not Staypoints._check(testdata_sp)
+        testdata_sp["started_at"] = tmp
+        testdata_sp["finished_at"] = testdata_sp["finished_at"].dt.tz_localize(None)
+        assert not Staypoints._check(testdata_sp)
+
+    def test_check_false_geometry_type(self, testdata_sp):
+        """Test if check returns False if geometry type is wrong"""
+        testdata_sp["geom"] = LineString(
+            [(13.476808430, 48.573711823), (13.506804, 48.939008), (13.4664690, 48.5706414)]
+        )
+        assert not Staypoints._check(testdata_sp)
+
+    def test_check_ignore_false_geometry_type(self, testdata_sp):
+        """Test if check returns True if geometry type is wrong but validate_geometry is set to False"""
+        testdata_sp["geom"] = LineString(
+            [(13.476808430, 48.573711823), (13.506804, 48.939008), (13.4664690, 48.5706414)]
+        )
+        assert Staypoints._check(testdata_sp, validate_geometry=False)