Repository: mne-tools/mne-python

Issue:
Add a method to rename annotation descriptions
#### Describe the new feature or enhancement

I find myself repeatedly needing to explain how to rename annotations to end users. I rename annotations using something like...

```python
ann = raw_intensity.annotations
ann.description = [d.replace('1.0', 'Control') for d in ann.description]
ann.description = [d.replace('2.0', 'Tapping/Left') for d in ann.description]
ann.description = [d.replace('3.0', 'Tapping/Right') for d in ann.description]
raw_intensity.set_annotations(ann)
```

Which isn't much code. But the barrier to entry is quite high. It requires that a user understands list comprehensions, which I think is an advanced topic. New users testing out MNE usually first want to load their data, rename triggers, and plot the data. So I suggest we add a method to annotations for renaming descriptions.

Thoughts?

#### Describe your proposed implementation
I would add an `annotations.rename(old_name, new_name)` method.


#### Describe possible alternatives
Leave as it is


#### Additional comments
I am happy to write this if others think its worthwhile



Discussion:
No objection 

Good idea!

Resolving patch:
diff --git a/mne/annotations.py b/mne/annotations.py
--- a/mne/annotations.py
+++ b/mne/annotations.py
@@ -589,6 +589,8 @@ def set_durations(self, mapping, verbose=None):
         -----
         .. versionadded:: 0.24.0
         """
+        _validate_type(mapping, (int, float, dict))
+
         if isinstance(mapping, dict):
             orig_annots = sorted(list(mapping))
             missing = [key not in self.description for key in orig_annots]
@@ -610,6 +612,40 @@ def set_durations(self, mapping, verbose=None):
 
         return self
 
+    @verbose
+    def rename(self, mapping, verbose=None):
+        """Rename annotation description(s). Operates inplace.
+
+        Parameters
+        ----------
+        mapping : dict
+            A dictionary mapping the old description to a new description,
+            e.g. {'1.0' : 'Control', '2.0' : 'Stimulus'}.
+        %(verbose_meth)s
+
+        Returns
+        -------
+        self : mne.Annotations
+            The modified Annotations object.
+
+        Notes
+        -----
+        .. versionadded:: 0.24.0
+        """
+        _validate_type(mapping, dict)
+
+        orig_annots = sorted(mapping.keys())
+        missing = [annot not in self.description for annot in orig_annots]
+        if any(missing):
+            raise ValueError(
+                f"Annotation description(s) in mapping missing from data: "
+                f"{np.array(orig_annots)[np.array(missing)]}")
+
+        for old, new in mapping.items():
+            self.description = [d.replace(old, new) for d in self.description]
+
+        return self
+
 
 def _combine_annotations(one, two, one_n_samples, one_first_samp,
                          two_first_samp, sfreq, meas_date):
diff --git a/mne/utils/check.py b/mne/utils/check.py
--- a/mne/utils/check.py
+++ b/mne/utils/check.py
@@ -414,7 +414,7 @@ def _validate_type(item, types=None, item_name=None, type_name=None):
                 type_name = ', '.join(type_name)
         _item_name = 'Item' if item_name is None else item_name
         raise TypeError(f"{_item_name} must be an instance of {type_name}, "
-                        f"got {type(item)} instead")
+                        f"got {type(item)} instead.")
 
 
 def _check_path_like(item):


Test patch:
diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py
--- a/mne/tests/test_annotations.py
+++ b/mne/tests/test_annotations.py
@@ -1286,6 +1286,57 @@ def test_annotation_ch_names():
     _assert_annotations_equal(raw_2.annotations, annot_pruned)
 
 
+def test_annotation_rename():
+    """Test annotation renaming works."""
+    a = Annotations([1, 2, 3], [5, 5, 8], ["a", "b", "c"])
+    assert len(a) == 3
+    assert "a" in a.description
+    assert "b" in a.description
+    assert "c" in a.description
+    assert "new_name" not in a.description
+
+    a = Annotations([1, 2, 3], [5, 5, 8], ["a", "b", "c"])
+    a.rename({"a": "new_name"})
+    assert len(a) == 3
+    assert "a" not in a.description
+    assert "new_name" in a.description
+    assert np.where([d == "new_name" for d in a.description])[0] == 0
+
+    a = Annotations([1, 2, 3], [5, 5, 8], ["a", "b", "c"])
+    a.rename({"a": "new_name", "b": "new name b"})
+    assert len(a) == 3
+    assert "a" not in a.description
+    assert "new_name" in a.description
+    assert "b" not in a.description
+    assert "new name b" in a.description
+    assert np.where([d == "new_name" for d in a.description])[0] == 0
+    assert np.where([d == "new name b" for d in a.description])[0] == 1
+
+    a = Annotations([1, 2, 3], [5, 5, 8], ["a", "b", "c"])
+    a.rename({"b": "new_name", "c": "new name c"})
+    assert len(a) == 3
+    assert "b" not in a.description
+    assert "new_name" in a.description
+    assert "c" not in a.description
+    assert "new name c" in a.description
+    assert "a" in a.description
+    assert np.where([d == "new_name" for d in a.description])[0] == 1
+    assert np.where([d == "new name c" for d in a.description])[0] == 2
+    assert len(np.where([d == "new name b" for d in a.description])[0]) == 0
+
+    a = Annotations([1, 2, 3], [5, 5, 8], ["a", "b", "c"])
+    with pytest.raises(ValueError, match="mapping missing from data"):
+        a.rename({"aaa": "does not exist"})
+    with pytest.raises(ValueError, match="[' a']"):
+        a.rename({" a": "does not exist"})
+    with pytest.raises(TypeError, match="dict, got <class 'str'> instead"):
+        a.rename("wrong")
+    with pytest.raises(TypeError, match="dict, got <class 'list'> instead"):
+        a.rename(["wrong"])
+    with pytest.raises(TypeError, match="dict, got <class 'set'> instead"):
+        a.rename({"wrong"})
+
+
 def test_annotation_duration_setting():
     """Test annotation duration setting works."""
     a = Annotations([1, 2, 3], [5, 5, 8], ["a", "b", "c"])
@@ -1309,8 +1360,10 @@ def test_annotation_duration_setting():
     a.set_durations(7.2)
     assert a.duration[0] == 7.2
     assert a.duration[2] == 7.2
+    a.set_durations(2)
+    assert a.duration[0] == 2
 
     with pytest.raises(ValueError, match="mapping missing from data"):
         a.set_durations({"aaa": 2.2})
-    with pytest.raises(ValueError, match="<class 'set'> was provided"):
+    with pytest.raises(TypeError, match=" got <class 'set'> instead"):
         a.set_durations({"aaa", 2.2})