Repository: mie-lab/trackintel
Pull request #566 (2023-11-01 20:24:34)

ENH: add `jump_length` metric

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mie-lab/trackintel/pull/566?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`1a93537`)](https://app.codecov.io/gh/mie-lab/trackintel/commit/1a935377b8e77edfed7077580a86b33c6985b4fc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 93.04% compared to head [(`7a1dc95`)](https://app.codecov.io/gh/mie-lab/trackintel/pull/566?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) 93.09%.

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


```diff
@@            Coverage Diff             @@
##           master     #566      +/-   ##
==========================================
+ Coverage   93.04%   93.09%   +0.05%     
==========================================
  Files          33       33              
  Lines        2069     2086      +17     
  Branches      372      373       +1     
==========================================
+ Hits         1925     1942      +17     
  Misses        130      130              
  Partials       14       14              
```


| [Files](https://app.codecov.io/gh/mie-lab/trackintel/pull/566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/analysis/\_\_init\_\_.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9hbmFseXNpcy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [trackintel/analysis/metrics.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9hbmFseXNpcy9tZXRyaWNzLnB5) | `100.00% <100.00%> (ø)` | |
| [trackintel/model/staypoints.py](https://app.codecov.io/gh/mie-lab/trackintel/pull/566?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/566?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/analysis.rst b/docs/modules/analysis.rst
index 0375a31..37ae89c 100644
--- a/docs/modules/analysis.rst
+++ b/docs/modules/analysis.rst
@@ -36,3 +36,5 @@ Metrics
 =======
 
 .. autofunction:: trackintel.analysis.radius_gyration
+
+.. autofunction:: trackintel.analysis.jump_length
diff --git a/trackintel/analysis/__init__.py b/trackintel/analysis/__init__.py
index 6cabcc3..3fcaf94 100644
--- a/trackintel/analysis/__init__.py
+++ b/trackintel/analysis/__init__.py
@@ -10,7 +10,7 @@ from .location_identification import location_identifier
 from .location_identification import pre_filter_locations
 from .location_identification import freq_method, osna_method
 
-from .metrics import radius_gyration
+from .metrics import radius_gyration, jump_length
 
 __all__ = [
     "temporal_tracking_quality",
@@ -23,4 +23,5 @@ __all__ = [
     "freq_method",
     "osna_method",
     "radius_gyration",
+    "jump_length",
 ]
diff --git a/trackintel/analysis/metrics.py b/trackintel/analysis/metrics.py
index a526322..51c8e6a 100644
--- a/trackintel/analysis/metrics.py
+++ b/trackintel/analysis/metrics.py
@@ -1,4 +1,5 @@
 import numpy as np
+import pandas as pd
 from tqdm import tqdm
 
 from trackintel.geogr import point_haversine_dist, check_gdf_planar
@@ -76,3 +77,52 @@ def _radius_gyration_user(sp, method):
         sq_dist = point_haversine_dist(x, y, x_center, y_center) ** 2
     square_rg = np.average(sq_dist, weights=w)
     return np.sqrt(square_rg)
+
+
+def jump_length(staypoints):
+    """
+    Jump length between consecutive staypoints per users.
+
+    Parameters
+    ----------
+    sp : Staypoints
+
+    Returns
+    -------
+    pd.Series
+        Distance between consecutive staypoints.
+        Last entry of user is NaN. Shares index with sp.
+
+    References
+    ----------
+    [1] Brockmann, D., Hufnagel, L., & Geisel, T. (2006). The scaling laws of human travel. Nature, 439(7075), 462-465.
+    """
+    geom = staypoints.geometry.name
+    staypoints = staypoints.sort_values(by=["user_id", "started_at"])[["user_id", geom]]
+    # due to bug in GeoPandas 0.14.0, we cannot use transform on grouped GeoSeries,
+    # thus we stay in GeoDataFrame and convert in the end to Series (will be fixed in next release)
+    series = staypoints.groupby(by="user_id").transform(_jump_length_user)
+    series = series[geom].rename("jump_length")
+    return series
+
+
+def _jump_length_user(g):
+    """
+    Jump length for a single user.
+
+    Parameters
+    ----------
+    g : gpd.GeoSeries
+
+    Returns
+    -------
+    np.array
+    """
+    if check_gdf_planar(g):
+        dist = g.distance(g.shift(-1))
+    else:
+        dist = np.zeros(len(g), dtype=np.float64)
+        dist[:-1] = point_haversine_dist(g.x[:-1], g.y[:-1], g.x[1:], g.y[1:])
+        dist[-1] = np.nan
+        dist = pd.Series(data=dist, index=g.index)
+    return dist
diff --git a/trackintel/model/staypoints.py b/trackintel/model/staypoints.py
index 4dfac3c..2a7c126 100644
--- a/trackintel/model/staypoints.py
+++ b/trackintel/model/staypoints.py
@@ -172,3 +172,11 @@ class Staypoints(TrackintelBase, TrackintelGeoDataFrame):
         See :func:`trackintel.analysis.radius_gyration` for full documentation.
         """
         return ti.analysis.radius_gyration(self, method, print_progress)
+
+    def jump_length(self):
+        """
+        Calculate jump length per user between consecutive staypoints.
+
+        See :func:`trackintel.analysis.radius_gyration` for full documentation.
+        """
+        return ti.analysis.jump_length(self)


Test patch:
diff --git a/tests/analysis/test_metrics.py b/tests/analysis/test_metrics.py
index 5b5f86f..3a16806 100644
--- a/tests/analysis/test_metrics.py
+++ b/tests/analysis/test_metrics.py
@@ -5,7 +5,7 @@ from pandas.testing import assert_series_equal
 from shapely.geometry import Point
 
 import trackintel as ti
-from trackintel.analysis import radius_gyration
+from trackintel.analysis import radius_gyration, jump_length
 from trackintel.geogr import point_haversine_dist
 
 
@@ -23,12 +23,12 @@ def staypoints():
     h = pd.Timedelta(hours=1)
 
     list_dict = [
-        {"id": 1, "user_id": 0, "started_at": t, "finished_at": t + 1 * h, "geom": p1},
-        {"id": 2, "user_id": 0, "started_at": t, "finished_at": t + 2 * h, "geom": p2},
-        {"id": 3, "user_id": 0, "started_at": t, "finished_at": t + 1 * h, "geom": p3},
-        {"id": 4, "user_id": 1, "started_at": t, "finished_at": t + 1 * h, "geom": p1},
-        {"id": 7, "user_id": 1, "started_at": t, "finished_at": t + 1 * h, "geom": p1},
-        {"id": 8, "user_id": 1, "started_at": t, "finished_at": t + 0 * h, "geom": p3},
+        {"id": 1, "user_id": 0, "started_at": t + 0 * h, "finished_at": t + 1 * h, "geom": p1},
+        {"id": 2, "user_id": 0, "started_at": t + 1 * h, "finished_at": t + 3 * h, "geom": p2},
+        {"id": 3, "user_id": 0, "started_at": t + 3 * h, "finished_at": t + 4 * h, "geom": p3},
+        {"id": 4, "user_id": 1, "started_at": t + 0 * h, "finished_at": t + 1 * h, "geom": p1},
+        {"id": 7, "user_id": 1, "started_at": t + 1 * h, "finished_at": t + 2 * h, "geom": p1},
+        {"id": 8, "user_id": 1, "started_at": t + 2 * h, "finished_at": t + 2 * h, "geom": p3},
     ]
     sp = ti.Staypoints(data=list_dict, geometry="geom", crs="EPSG:2056")
     return sp
@@ -80,3 +80,44 @@ class TestRadius_gyration:
         sfunc = radius_gyration(staypoints)
         smeth = staypoints.radius_gyration()
         assert_series_equal(sfunc, smeth)
+
+
+class TestJump_length:
+    def test_planar(self, staypoints):
+        """Test planar jump length implementation"""
+        s = jump_length(staypoints)
+        s_test = pd.Series([np.sqrt(18), np.sqrt(18), np.nan, 0, np.sqrt(72), np.nan])
+        assert_series_equal(s, s_test, check_names=False)
+
+    def test_haversine(self, staypoints):
+        """Test haversine jump length implementation"""
+        staypoints = staypoints.set_crs(4326, allow_override=True)
+        s = jump_length(staypoints)
+        g = staypoints.geometry
+        j1 = point_haversine_dist(g.iloc[0].x, g.iloc[0].y, g.iloc[1].x, g.iloc[1].y, float_flag=True)
+        j2 = point_haversine_dist(g.iloc[1].x, g.iloc[1].y, g.iloc[2].x, g.iloc[2].y, float_flag=True)
+        j3 = point_haversine_dist(g.iloc[-2].x, g.iloc[-2].y, g.iloc[-1].x, g.iloc[-1].y, float_flag=True)
+        s_test = pd.Series([j1, j2, np.nan, 0, j3, np.nan])
+        assert_series_equal(s, s_test, check_names=False)
+
+    def test_unordered(self, staypoints):
+        """Test if staypoints get sorted correctly"""
+        s1 = jump_length(staypoints)
+        staypoints = staypoints.sample(frac=1)
+        s2 = jump_length(staypoints)
+        s2 = s2.sort_index()
+        assert_series_equal(s1, s2)
+
+    def test_random_index(self, staypoints):
+        """Test if does not need consecutive index"""
+        s1 = jump_length(staypoints)
+        staypoints.index = [100, 1, 10, 1000, 1001, 999]
+        s2 = jump_length(staypoints)
+        s1.index = staypoints.index
+        assert_series_equal(s1, s2)
+
+    def test_staypoints_method(self, staypoints):
+        """Test if staypoint method returns same result"""
+        sfunc = jump_length(staypoints)
+        smeth = staypoints.jump_length()
+        assert_series_equal(sfunc, smeth)