Repository: SpikeInterface/spikeinterface (python)

Issue:
UnitsAggregationSorting fails to inherit property
Hi,

I saw in the `UnitsAggregationSorting` code that there is a copying of properties if they are consistent over all sortings.

However, look at the following code:

```python
sorting.annotate(name="Bonjour")
sorting.get_annotation("name")
> 'Bonjour'

sorting1 = sorting.select_units([0, 1])
sorting1.get_annotation("name")
> 'Bonjour'

sorting2 = sorting.select_units([unit_id for unit_id in sorting.unit_ids if unit_id != 0 and unit_id != 1])
sorting2.get_annotation("name")
> 'Bonjour'

new_sorting = si.UnitsAggregationSorting([sorting1, sorting2])
new_sorting.get_annotation("name")
> None
```

I believe the property should be inherited in this case.

Thanks,
Aurélien W.

Pull request description:
UnitsAggregationSorting inheritance
Fixes #947

Patch:
diff --git a/spikeinterface/core/base.py b/spikeinterface/core/base.py
index 7775dfae9..c210cc4c5 100644
--- a/spikeinterface/core/base.py
+++ b/spikeinterface/core/base.py
@@ -24,6 +24,8 @@ class BaseExtractor:
 
     """
 
+    default_missing_property_values = {"f": np.nan, "O": None, "S": "", "U": ""}
+
     # This replaces the old key_properties
     # These are annotations/properties/features that always need to be
     # dumped (for instance locations, groups, is_fileterd, etc.)
@@ -174,7 +176,6 @@ class BaseExtractor:
             it specifies the how the missing values should be filled, by default None.
             The missing_value has to be specified for types int and unsigned int.
         """
-        default_missing_values = {"f": np.nan, "O": None, "S": "", "U": ""}
         
         if values is None:
             if key in self._properties:
@@ -200,12 +201,12 @@ class BaseExtractor:
                     
         
                     if missing_value is None:
-                        if dtype_kind not in default_missing_values.keys():
-                            raise Exception("For values dtypes other than float, string or unicode, the missing value "
+                        if dtype_kind not in self.default_missing_property_values.keys():
+                            raise Exception("For values dtypes other than float, string, object or unicode, the missing value "
                                             "cannot be automatically inferred. Please specify it with the 'missing_value' "
                                             "argument.")
                         else:
-                            missing_value = default_missing_values[dtype_kind]
+                            missing_value = self.default_missing_property_values[dtype_kind]
                     else:
                         assert dtype_kind == np.array(missing_value).dtype.kind, ("Mismatch between values and "
                                                                                   "missing_value types. Provide a "
diff --git a/spikeinterface/core/unitsaggregationsorting.py b/spikeinterface/core/unitsaggregationsorting.py
index d36409145..c91bcafb9 100644
--- a/spikeinterface/core/unitsaggregationsorting.py
+++ b/spikeinterface/core/unitsaggregationsorting.py
@@ -3,6 +3,7 @@ import warnings
 import numpy as np
 
 from .core_tools import define_function_from_class
+from .base import BaseExtractor
 from .basesorting import BaseSorting, BaseSortingSegment
 
 
@@ -51,23 +52,48 @@ class UnitsAggregationSorting(BaseSorting):
 
         BaseSorting.__init__(self, sampling_frequency, unit_ids)
 
-        property_keys = sorting_list[0].get_property_keys()
+        annotation_keys = sorting_list[0].get_annotation_keys()
+        for annotation_name in annotation_keys:
+            if not all([annotation_name in sort.get_annotation_keys() for sort in sorting_list]):
+                continue
+
+            annotations = np.array([sort.get_annotation(annotation_name, copy=False) for sort in sorting_list])
+            if np.all(annotations == annotations[0]):
+                self.set_annotation(annotation_name, sorting_list[0].get_annotation(annotation_name))
+
+        property_keys = {}
         property_dict = {}
+        deleted_keys = []
+        for sort in sorting_list:
+            for prop_name in sort.get_property_keys():
+                if prop_name in deleted_keys:
+                    continue
+                if prop_name in property_keys:
+                    if property_keys[prop_name] != sort.get_property(prop_name).dtype:
+                        print(f"Skipping property '{prop_name}: difference in dtype between sortings'")
+                        del property_keys[prop_name]
+                        deleted_keys.append(prop_name)
+                else:
+                    property_keys[prop_name] = sort.get_property(prop_name).dtype
         for prop_name in property_keys:
-            if all([prop_name in sort.get_property_keys() for sort in sorting_list]):
-                for i_s, sort in enumerate(sorting_list):
-                    prop_value = sort.get_property(prop_name)
-                    if i_s == 0:
-                        property_dict[prop_name] = prop_value
-                    else:
-                        try:
-                            property_dict[prop_name] = np.concatenate((property_dict[prop_name],
-                                                                       sort.get_property(prop_name)))
-                        except Exception as e:
-                            print(f"Skipping property '{prop_name}' for shape inconsistency")
-                            del property_dict[prop_name]
-                            break
-
+            dtype = property_keys[prop_name]
+            property_dict[prop_name] = np.array([], dtype=dtype)
+
+            for sort in sorting_list:
+                if prop_name in sort.get_property_keys():
+                    values = sort.get_property(prop_name)
+                else:
+                    if dtype.kind not in BaseExtractor.default_missing_property_values:
+                        del property_dict[prop_name]
+                        break
+                    values = np.full(sort.get_num_units(), BaseExtractor.default_missing_property_values[dtype.kind], dtype=dtype)
+
+                try:
+                    property_dict[prop_name] = np.concatenate((property_dict[prop_name], values))
+                except Exception as e:
+                    print(f"Skipping property '{prop_name}' for shape inconsistency")
+                    del property_dict[prop_name]
+                    break
         for prop_name, prop_values in property_dict.items():
             self.set_property(key=prop_name, values=prop_values)
 


Test patch:
diff --git a/spikeinterface/core/tests/test_unitsaggregationsorting.py b/spikeinterface/core/tests/test_unitsaggregationsorting.py
index 77a97665d..aad533366 100644
--- a/spikeinterface/core/tests/test_unitsaggregationsorting.py
+++ b/spikeinterface/core/tests/test_unitsaggregationsorting.py
@@ -55,6 +55,27 @@ def test_unitsaggregationsorting():
     assert all(
         unit in renamed_unit_ids for unit in sorting_agg_renamed.get_unit_ids())
 
+    # test annotations
+
+    # matching annotation
+    sorting1.annotate(organ="brain")
+    sorting2.annotate(organ="brain")
+    sorting3.annotate(organ="brain")
+
+    # not matching annotation
+    sorting1.annotate(area="CA1")
+    sorting2.annotate(area="CA2")
+    sorting3.annotate(area="CA3")
+
+    # incomplete annotation
+    sorting1.annotate(date="2022-10-13")
+    sorting2.annotate(date="2022-10-13")
+
+    sorting_agg_prop = aggregate_units([sorting1, sorting2, sorting3])
+    assert sorting_agg_prop.get_annotation('organ') == "brain"
+    assert "area" not in sorting_agg_prop.get_annotation_keys()
+    assert "date" not in sorting_agg_prop.get_annotation_keys()
+
     # test properties
 
     # complete property
@@ -67,13 +88,18 @@ def test_unitsaggregationsorting():
     sorting1.set_property("template", np.zeros((num_units, 20, 50)))
     sorting1.set_property("template", np.zeros((num_units, 2, 10)))
 
-    # incomplete property
+    # incomplete property (str can't be propagated)
     sorting1.set_property("quality", ["good"]*num_units)
     sorting2.set_property("quality", ["bad"]*num_units)
 
+    # incomplete property (object can be propagated)
+    sorting1.set_property("rand", np.random.rand(num_units))
+    sorting2.set_property("rand", np.random.rand(num_units))
+
     sorting_agg_prop = aggregate_units([sorting1, sorting2, sorting3])
     assert "brain_area" in sorting_agg_prop.get_property_keys()
     assert "quality" not in sorting_agg_prop.get_property_keys()
+    assert "rand" in sorting_agg_prop.get_property_keys()
     print(sorting_agg_prop.get_property("brain_area"))