Repository: mie-lab/trackintel

Issue:
Location identification fails for user with a single significant location
There are edge cases where the function `pre_filter_locations` can result in filtering all but 1 location of a user. This leads to the following error in `_freq_assign` in [line 230](https://github.com/mie-lab/trackintel/blob/master/trackintel/analysis/location_identification.py#L230):
`ValueError: could not broadcast input array from shape (2,) into shape (1,)`
because it tries to assign 2 labels to 1 location.

Unfortunately, the filter is relatively complex and therefore I failed creating an independent script using artificial data but I have some test data available for download [here](https://1drv.ms/u/s!Ar5qZtQfLW_Mmq95kMzADK9ZhmWQdg?e=OhpPju) that can be used to reproduce the error.

```python
import pandas as pd
import geopandas as gpd
import trackintel as ti


path_to_testdata = r"D:\Geolife\geolife_all_spts.geojson"
spts = gpd.read_file(path_to_testdata)
spts["started_at"] = pd.to_datetime(spts["started_at"], utc=True)
spts["finished_at"] = pd.to_datetime(spts["finished_at"], utc=True)

print("extract locations")
spts, _ = spts.as_staypoints.generate_locations(
    method="dbscan", epsilon=50, num_samples=1, distance_metric="haversine", agg_level="user"
)
spts = ti.analysis.location_identifier(spts, method="FREQ", pre_filter=True)
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/275?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) Report
> Merging [#275](https://codecov.io/gh/mie-lab/trackintel/pull/275?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (a6e7193) into [master](https://codecov.io/gh/mie-lab/trackintel/commit/dc9c6efe28ec0b736ee8da5a243cb86b8b83eeb2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) (dc9c6ef) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/mie-lab/trackintel/pull/275/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/275?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     #275      +/-   ##
==========================================
+ Coverage   95.23%   95.24%   +0.01%     
==========================================
  Files          38       38              
  Lines        1637     1641       +4     
  Branches      268      270       +2     
==========================================
+ Hits         1559     1563       +4     
  Misses         39       39              
  Partials       39       39              
```


| [Impacted Files](https://codecov.io/gh/mie-lab/trackintel/pull/275?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab) | Coverage Δ | |
|---|---|---|
| [trackintel/analysis/location\_identification.py](https://codecov.io/gh/mie-lab/trackintel/pull/275/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab#diff-dHJhY2tpbnRlbC9hbmFseXNpcy9sb2NhdGlvbl9pZGVudGlmaWNhdGlvbi5weQ==) | `99.04% <100.00%> (+0.03%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mie-lab/trackintel/pull/275?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/275?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mie-lab). Last update [dc9c6ef...a6e7193](https://codecov.io/gh/mie-lab/trackintel/pull/275?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/CONTRIBUTING.md b/CONTRIBUTING.md
index f6bf977..9cf8e55 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,13 +38,20 @@ This is a place to collect conventions we agreed upon until we find the right pl
 ### Time stamps
 All timestamps are timezone aware pandas `datetime64[ns, UTC]` objects. The default timezone should be `UTC` but the user should be free to use a different one if he wants. See [Issue 101](https://github.com/mie-lab/trackintel/issues/18). 
 
-### Organization of tests
+### Tests 
+#### Organization of tests
 See [Issue 23](https://github.com/mie-lab/trackintel/issues/23)
 - The test folder copies the folder structure that the trackintel.trackintel folder has.
 - Every python module has a single test file
 - Every function has 1 test class
 - Every method of this function should test a single property
 
+#### Test data
+If possible test data should be
+- independent of unrelated preprocessing steps (e.g., avoid starting with positionfixes if you write tests for trips)
+- simple and easy to understand (e.g., try to have a short example with an isolated special case rather than a large dataset that contains a lot of special cases)
+- defined directly in the code itself (e.g, [this example](https://github.com/mie-lab/trackintel/blob/e0c0cdd0d8472ba7b113b3819d062ea8abcd8168/tests/io/test_postgis_gpd.py#L50)
+
 ### Integrety of input data
 Functions should never change the input dataframe but rather return an altered copy.
 
diff --git a/trackintel/analysis/location_identification.py b/trackintel/analysis/location_identification.py
index 399c212..7b6b73b 100644
--- a/trackintel/analysis/location_identification.py
+++ b/trackintel/analysis/location_identification.py
@@ -175,7 +175,7 @@ def freq_method(spts, *labels):
     Assigning the most visited location the label "home" and the second most visited location the label "work".
     The remaining locations get no label.
 
-    Labels can also be passes as an arguement.
+    Labels can also be given as arguments.
 
     Parameters
     ----------
@@ -241,6 +241,7 @@ def _freq_assign(duration, *labels):
     """
     kth = (-duration).argsort()[: len(labels)]  # if inefficient use partial sort.
     label_array = np.full(len(duration), fill_value=None)
+    labels = labels[: len(kth)]  # if provided with more labels than entries.
     label_array[kth] = labels
     return label_array
 
@@ -307,15 +308,18 @@ def osna_method(spts):
     spts_pivot = spts_agg.unstack()
     # get index of maximum for columns "work" and "home"
     spts_idxmax = spts_pivot.groupby(["user_id"]).idxmax()
-    # first assign "home" label
-    spts_pivot.loc[spts_idxmax["home"], "activity_label"] = "home"
+    # first assign labels
+    for col in spts_idxmax.columns:
+        spts_pivot.loc[spts_idxmax[col].dropna(), "activity_label"] = 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.
-    redo_work = spts_idxmax[spts_idxmax["home"] == spts_idxmax["work"]]
-    spts_pivot.loc[redo_work["work"], "work"] = pd.NaT
-    spts_idxmax_work = spts_pivot.groupby(["user_id"])["work"].idxmax()
-    spts_pivot.loc[spts_idxmax_work, "activity_label"] = "work"
+    if all(col in spts_idxmax.columns for col in ["work", "home"]):
+        redo_work = spts_idxmax[spts_idxmax["home"] == spts_idxmax["work"]]
+        spts_pivot.loc[redo_work["work"], "activity_label"] = "home"
+        spts_pivot.loc[redo_work["work"], "work"] = pd.NaT
+        spts_idxmax_work = spts_pivot.groupby(["user_id"])["work"].idxmax()
+        spts_pivot.loc[spts_idxmax_work.dropna(), "activity_label"] = "work"
 
     # now join it back together
     sel = spts_in.columns != "activity_label"  # no overlap with older "activity_label"


Test patch:
diff --git a/tests/analysis/test_location_identification.py b/tests/analysis/test_location_identification.py
index 9788293..a60ce42 100644
--- a/tests/analysis/test_location_identification.py
+++ b/tests/analysis/test_location_identification.py
@@ -6,16 +6,16 @@ import pandas as pd
 import pytest
 import trackintel as ti
 from geopandas.testing import assert_geodataframe_equal
-from pandas.testing import assert_index_equal
+from pandas.testing import assert_frame_equal, assert_index_equal
 from shapely.geometry import Point
 from trackintel.analysis.location_identification import (
     _freq_assign,
     _freq_transform,
+    _osna_label_timeframes,
     freq_method,
     location_identifier,
-    pre_filter_locations,
     osna_method,
-    _osna_label_timeframes,
+    pre_filter_locations,
 )
 
 
@@ -210,6 +210,13 @@ class Test_Freq_Assign:
         freq = _freq_assign(dur, *labels)
         assert all(freq == freq_sol)
 
+    def test_more_labels_than_entries(self):
+        dur = pd.Series([9, 0])
+        labels = ("label1", "label2", "label3")
+        freq_sol = np.array([labels[0], labels[1]])
+        freq = _freq_assign(dur, *labels)
+        assert all(freq == freq_sol)
+
 
 class TestLocation_Identifier:
     """Test function `location_identifier`"""
@@ -366,6 +373,42 @@ class TestOsna_Method:
         spts.loc[spts["location_id"] == 2, "activity_label"] = "work"
         assert_geodataframe_equal(spts, result)
 
+    def test_only_one_work_location(self):
+        """Test if only one work location of a user can be handled."""
+        t_work = pd.Timestamp("2021-07-14 18:00:00", tz="utc")
+        h = pd.Timedelta("1h")
+        p = Point(0.0, 0.0)  # not used
+        list_dict = [{"user_id": 0, "location_id": 0, "started_at": t_work, "finished_at": t_work + h, "g": p}]
+        spts = gpd.GeoDataFrame(data=list_dict, geometry="g")
+        spts.index.name = "id"
+        result = osna_method(spts)
+        spts["activity_label"] = "work"
+        assert_geodataframe_equal(result, spts)
+
+    def test_only_one_rest_location(self):
+        """Test if only one rest location of a user can be handled."""
+        t_rest = pd.Timestamp("2021-07-14 07:00:00", tz="utc")
+        h = pd.Timedelta("1h")
+        p = Point(0.0, 0.0)  # not used
+        list_dict = [{"user_id": 0, "location_id": 0, "started_at": t_rest, "finished_at": t_rest + h, "g": p}]
+        spts = gpd.GeoDataFrame(data=list_dict, geometry="g")
+        spts.index.name = "id"
+        result = osna_method(spts)
+        spts["activity_label"] = "home"
+        assert_geodataframe_equal(result, spts)
+
+    def test_only_one_leisure_location(self):
+        """Test if only one leisure location of a user can be handled."""
+        t_leis = pd.Timestamp("2021-07-14 01:00:00", tz="utc")
+        h = pd.Timedelta("1h")
+        p = Point(0.0, 0.0)  # not used
+        list_dict = [{"user_id": 0, "location_id": 0, "started_at": t_leis, "finished_at": t_leis + h, "g": p}]
+        spts = gpd.GeoDataFrame(data=list_dict, geometry="g")
+        spts.index.name = "id"
+        result = osna_method(spts)
+        spts["activity_label"] = "home"
+        assert_geodataframe_equal(result, spts)
+
     def test_prior_activity_label(self, example_osna):
         """Test that prior activity_label column does not corrupt output."""
         example_osna["activity_label"] = np.arange(len(example_osna))
@@ -375,6 +418,23 @@ class TestOsna_Method:
         example_osna.loc[example_osna["location_id"] == 1, "activity_label"] = "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},
+        ]
+        spts = pd.DataFrame(list_dict)
+        spts.index.name = "id"
+        result = osna_method(spts)
+        spts["activity_label"] = ["home", "work", "home", "work"]
+        assert_frame_equal(spts, result)
+
 
 class Test_osna_label_timeframes:
     """Test for the _osna_label_timeframes() function."""