Repository: tobac-project/tobac

Issue:
Add "minima" target to `filter_min_distance`
Current behaviour for `filter_min_distance` is to always select the feature with the largest threshold, even if the feature detection is targeting minima. This is an aspect of #230 that requires more urgent attention

Discussion:

Patch:
diff --git a/tobac/feature_detection.py b/tobac/feature_detection.py
index d72776a..d400362 100644
--- a/tobac/feature_detection.py
+++ b/tobac/feature_detection.py
@@ -942,6 +942,7 @@ def feature_detection_multithreshold(
                     dz=dz,
                     min_distance=min_distance,
                     z_coordinate_name=vertical_coord,
+                    target=target,
                 )
         list_features_timesteps.append(features_thresholds)
 
@@ -978,6 +979,7 @@ def filter_min_distance(
     x_coordinate_name=None,
     y_coordinate_name=None,
     z_coordinate_name=None,
+    target="maximum",
 ):
     """Function to remove features that are too close together.
     If two features are closer than `min_distance`, it keeps the
@@ -1008,6 +1010,9 @@ def filter_min_distance(
     z_coordinate_name: str or None
         The name of the z coordinate to calculate distance based on in meters.
         This is typically `altitude`. If `auto`, tries to auto-detect.
+    target: {'maximum', 'minimum'}, optional
+        Flag to determine if tracking is targetting minima or maxima in
+        the data. Default is 'maximum'.
 
     Returns
     -------
@@ -1052,6 +1057,11 @@ def filter_min_distance(
             "Set dz to none if you want to use altitude or set `z_coordinate_name` to None to use constant dz."
         )
 
+    if target not in ["minimum", "maximum"]:
+        raise ValueError(
+            "target parameter must be set to either 'minimum' or 'maximum'"
+        )
+
     # create list of tuples with all combinations of features at the timestep:
     indices = combinations(features.index.values, 2)
     # Loop over combinations to remove features that are closer together than min_distance and keep larger one (either higher threshold or larger area)
@@ -1092,25 +1102,56 @@ def filter_min_distance(
             if distance <= min_distance:
                 # print(distance, min_distance, index_1, index_2, features.size)
                 #                        logging.debug('distance<= min_distance: ' + str(distance))
-                if (
-                    features.loc[index_1, "threshold_value"]
-                    > features.loc[index_2, "threshold_value"]
-                ):
-                    remove_list_distance.append(index_2)
-                elif (
-                    features.loc[index_1, "threshold_value"]
-                    < features.loc[index_2, "threshold_value"]
-                ):
-                    remove_list_distance.append(index_1)
-                elif (
-                    features.loc[index_1, "threshold_value"]
-                    == features.loc[index_2, "threshold_value"]
-                ):
-                    if features.loc[index_1, "num"] > features.loc[index_2, "num"]:
+                if target == "maximum":
+                    if (
+                        features.loc[index_1, "threshold_value"]
+                        > features.loc[index_2, "threshold_value"]
+                    ):
                         remove_list_distance.append(index_2)
-                    elif features.loc[index_1, "num"] < features.loc[index_2, "num"]:
+                    elif (
+                        features.loc[index_1, "threshold_value"]
+                        < features.loc[index_2, "threshold_value"]
+                    ):
                         remove_list_distance.append(index_1)
-                    elif features.loc[index_1, "num"] == features.loc[index_2, "num"]:
+                    elif (
+                        features.loc[index_1, "threshold_value"]
+                        == features.loc[index_2, "threshold_value"]
+                    ):
+                        if features.loc[index_1, "num"] > features.loc[index_2, "num"]:
+                            remove_list_distance.append(index_2)
+                        elif (
+                            features.loc[index_1, "num"] < features.loc[index_2, "num"]
+                        ):
+                            remove_list_distance.append(index_1)
+                        elif (
+                            features.loc[index_1, "num"] == features.loc[index_2, "num"]
+                        ):
+                            remove_list_distance.append(index_2)
+                elif target == "minimum":
+                    if (
+                        features.loc[index_1, "threshold_value"]
+                        < features.loc[index_2, "threshold_value"]
+                    ):
                         remove_list_distance.append(index_2)
+                    elif (
+                        features.loc[index_1, "threshold_value"]
+                        > features.loc[index_2, "threshold_value"]
+                    ):
+                        remove_list_distance.append(index_1)
+                    elif (
+                        features.loc[index_1, "threshold_value"]
+                        == features.loc[index_2, "threshold_value"]
+                    ):
+                        if features.loc[index_1, "num"] > features.loc[index_2, "num"]:
+                            remove_list_distance.append(index_2)
+                        elif (
+                            features.loc[index_1, "num"] < features.loc[index_2, "num"]
+                        ):
+                            remove_list_distance.append(index_1)
+                        elif (
+                            features.loc[index_1, "num"] == features.loc[index_2, "num"]
+                        ):
+                            remove_list_distance.append(index_2)
+
     features = features[~features.index.isin(remove_list_distance)]
     return features


Test patch:
diff --git a/tobac/tests/test_feature_detection.py b/tobac/tests/test_feature_detection.py
index 87b2423..c01c780 100644
--- a/tobac/tests/test_feature_detection.py
+++ b/tobac/tests/test_feature_detection.py
@@ -57,70 +57,6 @@ def test_feature_detection_multithreshold_timestep(
     assert fd_output.iloc[0]["hdim_2"] == pytest.approx(test_hdim_2_pt)
 
 
-@pytest.mark.parametrize(
-    "test_threshs, min_distance, dxy", [([1, 2, 3], 100000, 10000)]
-)
-def test_filter_min_distance(test_threshs, min_distance, dxy):
-    """
-    Tests ```tobac.feature_detection.filter_min_distance```
-    """
-    # start by building a simple dataset with two features close to each other
-
-    test_dset_size = (50, 50)
-    test_hdim_1_pt = 20.0
-    test_hdim_2_pt = 20.0
-    test_hdim_1_sz = 5
-    test_hdim_2_sz = 5
-    test_amp = 5
-    test_min_num = 2
-
-    test_data = np.zeros(test_dset_size)
-    test_data = tbtest.make_feature_blob(
-        test_data,
-        test_hdim_1_pt,
-        test_hdim_2_pt,
-        h1_size=test_hdim_1_sz,
-        h2_size=test_hdim_2_sz,
-        amplitude=test_amp,
-    )
-
-    ## add another blob with smaller value
-    test_hdim_1_pt2 = 25.0
-    test_hdim_2_pt2 = 25.0
-    test_hdim_1_sz2 = 2
-    test_hdim_2_sz2 = 2
-    test_amp2 = 3
-    test_data = tbtest.make_feature_blob(
-        test_data,
-        test_hdim_1_pt2,
-        test_hdim_2_pt2,
-        h1_size=test_hdim_1_sz2,
-        h2_size=test_hdim_2_sz2,
-        amplitude=test_amp2,
-    )
-    test_data_iris = tbtest.make_dataset_from_arr(test_data, data_type="iris")
-
-    # identify these features
-    fd_output = feat_detect.feature_detection_multithreshold_timestep(
-        test_data_iris,
-        0,
-        threshold=test_threshs,
-        n_min_threshold=test_min_num,
-        min_distance=min_distance,
-        dxy=dxy,
-    )
-
-    # check if it function to filter
-    fd_filtered = feat_detect.filter_min_distance(fd_output, dxy, min_distance)
-
-    # Make sure we have only one feature (small feature in minimum distance should be removed )
-    assert len(fd_output.index) == 2
-    assert len(fd_filtered.index) == 1
-    # Make sure that the locations of the features is correct (should correspond to locations of first feature)
-    assert fd_filtered.iloc[0]["hdim_1"] == pytest.approx(test_hdim_1_pt)
-    assert fd_filtered.iloc[0]["hdim_2"] == pytest.approx(test_hdim_2_pt)
-
-
 @pytest.mark.parametrize(
     "position_threshold", [("center"), ("extreme"), ("weighted_diff"), ("weighted_abs")]
 )
@@ -154,45 +90,165 @@ def test_feature_detection_position(position_threshold):
 
 @pytest.mark.parametrize(
     "feature_1_loc, feature_2_loc, dxy, dz, min_distance,"
-    " add_x_coords, add_y_coords,"
+    "target, add_x_coords, add_y_coords,"
     "add_z_coords, expect_feature_1, expect_feature_2",
     [
-        (
+        (  # If separation greater than min_distance, keep both features
             (0, 0, 0, 4, 1),
             (1, 1, 1, 4, 1),
             1000,
             100,
             1,
+            "maximum",
             False,
             False,
             False,
             True,
             True,
         ),
-        (
+        (  # Keep feature 1 by area
             (0, 0, 0, 4, 1),
             (1, 1, 1, 3, 1),
             1000,
             100,
             5000,
+            "maximum",
             False,
             False,
             False,
             True,
             False,
         ),
-        (
+        (  # Keep feature 2 by area
+            (0, 0, 0, 4, 1),
+            (1, 1, 1, 6, 1),
+            1000,
+            100,
+            5000,
+            "maximum",
+            False,
+            False,
+            False,
+            False,
+            True,
+        ),
+        (  # Keep feature 1 by area
+            (0, 0, 0, 4, 1),
+            (1, 1, 1, 3, 1),
+            1000,
+            100,
+            5000,
+            "minimum",
+            False,
+            False,
+            False,
+            True,
+            False,
+        ),
+        (  # Keep feature 2 by area
+            (0, 0, 0, 4, 1),
+            (1, 1, 1, 6, 1),
+            1000,
+            100,
+            5000,
+            "minimum",
+            False,
+            False,
+            False,
+            False,
+            True,
+        ),
+        (  # Keep feature 1 by maximum threshold
+            (0, 0, 0, 4, 2),
+            (1, 1, 1, 10, 1),
+            1000,
+            100,
+            5000,
+            "maximum",
+            False,
+            False,
+            False,
+            True,
+            False,
+        ),
+        (  # Keep feature 2 by maximum threshold
+            (0, 0, 0, 4, 2),
+            (1, 1, 1, 10, 3),
+            1000,
+            100,
+            5000,
+            "maximum",
+            False,
+            False,
+            False,
+            False,
+            True,
+        ),
+        (  # Keep feature 1 by minimum threshold
+            (0, 0, 0, 4, -1),
+            (1, 1, 1, 10, 1),
+            1000,
+            100,
+            5000,
+            "minimum",
+            False,
+            False,
+            False,
+            True,
+            False,
+        ),
+        (  # Keep feature 2 by minimum threshold
             (0, 0, 0, 4, 2),
             (1, 1, 1, 10, 1),
             1000,
             100,
             5000,
+            "minimum",
+            False,
+            False,
+            False,
+            False,
+            True,
+        ),
+        (  # Keep feature 1 by tie-break
+            (0, 0, 0, 4, 2),
+            (1, 1, 1, 4, 2),
+            1000,
+            100,
+            5000,
+            "maximum",
             False,
             False,
             False,
             True,
             False,
         ),
+        (  # Keep feature 1 by tie-break
+            (0, 0, 0, 4, 2),
+            (1, 1, 1, 4, 2),
+            1000,
+            100,
+            5000,
+            "minimum",
+            False,
+            False,
+            False,
+            True,
+            False,
+        ),
+        (  # If target is not maximum or minimum raise ValueError
+            (0, 0, 0, 4, 1),
+            (1, 1, 1, 4, 1),
+            1000,
+            100,
+            1,
+            "chaos",
+            False,
+            False,
+            False,
+            False,
+            False,
+        ),
     ],
 )
 def test_filter_min_distance(
@@ -201,6 +257,7 @@ def test_filter_min_distance(
     dxy,
     dz,
     min_distance,
+    target,
     add_x_coords,
     add_y_coords,
     add_z_coords,
@@ -223,6 +280,8 @@ def test_filter_min_distance(
         Vertical grid spacing (constant)
     min_distance: float
         Minimum distance between features (m)
+    target: str ["maximum" | "minimum"]
+        Target maxima or minima threshold for selecting which feature to keep
     add_x_coords: bool
         Whether or not to add x coordinates
     add_y_coords: bool
@@ -296,12 +355,17 @@ def test_filter_min_distance(
         "dxy": dxy,
         "dz": dz,
         "min_distance": min_distance,
+        "target": target,
     }
+    if target not in ["maximum", "minimum"]:
+        with pytest.raises(ValueError):
+            out_feats = feat_detect.filter_min_distance(**filter_dist_opts)
 
-    out_feats = feat_detect.filter_min_distance(**filter_dist_opts)
+    else:
+        out_feats = feat_detect.filter_min_distance(**filter_dist_opts)
 
-    assert expect_feature_1 == (np.sum(out_feats["feature"] == 1) == 1)
-    assert expect_feature_2 == (np.sum(out_feats["feature"] == 2) == 1)
+        assert expect_feature_1 == (np.sum(out_feats["feature"] == 1) == 1)
+        assert expect_feature_2 == (np.sum(out_feats["feature"] == 2) == 1)
 
 
 @pytest.mark.parametrize(