Repository: mne-tools/mne-python

Issue:
inconsistent behavior: `annotate_*` funcs return annotations with different `orig_time` (`None`, or set)
`annotate_flat` returns annotations with a proper `orig_time` inherited from `raw`, whereas `annotate_muscle_zscore` returns annotations w ith `orig_time = None`.

In my pipeline I run both functions and then want to "join" both annotation objects like so `annots_bad = annots_flat + annots_muscle` to then later set `raw.set_annotations(annots_bad)`. However, with the current setup, this makes me run into an error:

> ValueError: orig_time should be the same to add/concatenate 2 annotations (got None != 1912-06-22 14:01:10.497486+00:00)

Would the correct fix be to set `annots_flat.orig_time = None` to then enable concatenation? And should we do that in MNE-Python directly, rather than have users do it themselves?

(EDIT: also, I can't set the orig_time directly. It leads to "AttributeError: can't set attribute")

https://github.com/mne-tools/mne-python/blob/6e60792e4f0bffdd66a9fc1fb85c0c45ed802d78/mne/preprocessing/flat.py#L103-L105

https://github.com/mne-tools/mne-python/blob/6e60792e4f0bffdd66a9fc1fb85c0c45ed802d78/mne/preprocessing/artifact_detection.py#L118-L120


Discussion:
I think they should both contain `orig_time = None`. When attaching this to a `Raw`, the attribute will be set correctly if I'm not mistaken. So I don't see any use in setting it in the returned `Annotations`
> I think they should both contain orig_time = None. When attaching this to a Raw, the attribute will be set correctly if I'm not mistaken. So I don't see any use in setting it in the returned Annotations

I agree, same issue for annotate_breaks (which I just used for real data and it's awesome, btw :wink:): https://github.com/mne-tools/mne-python/blob/6e60792e4f0bffdd66a9fc1fb85c0c45ed802d78/mne/preprocessing/artifact_detection.py#L525-L531

(annotate_movement has orig_time=None, similar as annotate_muscle_zscore)

Resolving patch:
diff --git a/mne/preprocessing/annotate_nan.py b/mne/preprocessing/annotate_nan.py
--- a/mne/preprocessing/annotate_nan.py
+++ b/mne/preprocessing/annotate_nan.py
@@ -31,5 +31,6 @@ def annotate_nan(raw, *, verbose=None):
         onsets.extend(annot.onset)
         durations.extend(annot.duration)
         ch_names.extend([[ch_name]] * len(annot))
-    annot = Annotations(onsets, durations, 'BAD_NAN', ch_names=ch_names)
+    annot = Annotations(onsets, durations, 'BAD_NAN', ch_names=ch_names,
+                        orig_time=raw.info['meas_date'])
     return annot
diff --git a/mne/preprocessing/artifact_detection.py b/mne/preprocessing/artifact_detection.py
--- a/mne/preprocessing/artifact_detection.py
+++ b/mne/preprocessing/artifact_detection.py
@@ -4,6 +4,7 @@
 
 
 import numpy as np
+
 from ..io.base import BaseRaw
 from ..annotations import (Annotations, _annotations_starts_stops,
                            annotations_from_events)
@@ -115,7 +116,8 @@ def annotate_muscle_zscore(raw, threshold=4, ch_type=None, min_length_good=0.1,
         if len(l_idx) < min_samps:
             art_mask[l_idx] = True
 
-    annot = _annotations_from_mask(raw_copy.times, art_mask, 'BAD_muscle')
+    annot = _annotations_from_mask(raw_copy.times, art_mask, 'BAD_muscle',
+                                   orig_time=raw.info['meas_date'])
 
     return annot, scores_muscle
 
@@ -162,11 +164,13 @@ def annotate_movement(raw, pos, rotation_velocity_limit=None,
     """
     sfreq = raw.info['sfreq']
     hp_ts = pos[:, 0].copy()
-    hp_ts -= raw.first_samp / sfreq
+    orig_time = raw.info['meas_date']
+    if orig_time is None:
+        hp_ts -= raw.first_samp / sfreq
     dt = np.diff(hp_ts)
     hp_ts = np.concatenate([hp_ts, [hp_ts[-1] + 1. / sfreq]])
 
-    annot = Annotations([], [], [], orig_time=None)  # rel to data start
+    annot = Annotations([], [], [], orig_time=orig_time)
 
     # Annotate based on rotational velocity
     t_tot = raw.times[-1]
@@ -183,7 +187,8 @@ def annotate_movement(raw, pos, rotation_velocity_limit=None,
                     u'ω >= %5.1f°/s (max: %0.1f°/s)'
                     % (bad_pct, len(onsets), rotation_velocity_limit,
                        np.rad2deg(r.max())))
-        annot += _annotations_from_mask(hp_ts, bad_mask, 'BAD_mov_rotat_vel')
+        annot += _annotations_from_mask(
+            hp_ts, bad_mask, 'BAD_mov_rotat_vel', orig_time=orig_time)
 
     # Annotate based on translational velocity limit
     if translation_velocity_limit is not None:
@@ -198,7 +203,8 @@ def annotate_movement(raw, pos, rotation_velocity_limit=None,
                     u'v >= %5.4fm/s (max: %5.4fm/s)'
                     % (bad_pct, len(onsets), translation_velocity_limit,
                        v.max()))
-        annot += _annotations_from_mask(hp_ts, bad_mask, 'BAD_mov_trans_vel')
+        annot += _annotations_from_mask(
+            hp_ts, bad_mask, 'BAD_mov_trans_vel', orig_time=orig_time)
 
     # Annotate based on displacement from mean head position
     disp = []
@@ -241,7 +247,8 @@ def annotate_movement(raw, pos, rotation_velocity_limit=None,
         logger.info(u'Omitting %5.1f%% (%3d segments): '
                     u'disp >= %5.4fm (max: %5.4fm)'
                     % (bad_pct, len(onsets), mean_distance_limit, disp.max()))
-        annot += _annotations_from_mask(hp_ts, bad_mask, 'BAD_mov_dist')
+        annot += _annotations_from_mask(
+            hp_ts, bad_mask, 'BAD_mov_dist', orig_time=orig_time)
     return annot, disp
 
 
@@ -319,7 +326,7 @@ def compute_average_dev_head_t(raw, pos):
     return dev_head_t
 
 
-def _annotations_from_mask(times, mask, annot_name):
+def _annotations_from_mask(times, mask, annot_name, orig_time=None):
     """Construct annotations from boolean mask of the data."""
     from scipy.ndimage.morphology import distance_transform_edt
     from scipy.signal import find_peaks
@@ -349,7 +356,7 @@ def _annotations_from_mask(times, mask, annot_name):
     ends = times[ends_index]
     durations = ends - onsets
     desc = [annot_name] * len(durations)
-    return Annotations(onsets, durations, desc)
+    return Annotations(onsets, durations, desc, orig_time=orig_time)
 
 
 @verbose
@@ -541,7 +548,7 @@ def annotate_break(raw, events=None,
         onset=break_onsets,
         duration=break_durations,
         description=['BAD_break'],
-        orig_time=raw.info['meas_date']
+        orig_time=raw.info['meas_date'],
     )
 
     # Log some info
diff --git a/mne/preprocessing/flat.py b/mne/preprocessing/flat.py
--- a/mne/preprocessing/flat.py
+++ b/mne/preprocessing/flat.py
@@ -101,5 +101,5 @@ def annotate_flat(raw, bad_percent=5., min_duration=0.005, picks=None,
     onsets = (starts + raw.first_samp) / raw.info['sfreq']
     durations = (stops - starts) / raw.info['sfreq']
     annot = Annotations(onsets, durations, ['BAD_flat'] * len(onsets),
-                        orig_time=raw.annotations.orig_time)
+                        orig_time=raw.info['meas_date'])
     return annot, bads


Test patch:
diff --git a/mne/io/nirx/tests/test_nirx.py b/mne/io/nirx/tests/test_nirx.py
--- a/mne/io/nirx/tests/test_nirx.py
+++ b/mne/io/nirx/tests/test_nirx.py
@@ -220,7 +220,9 @@ def test_nirsport_v1_w_bad_sat(preload):
     assert np.isnan(data_nan).any()
     assert not np.allclose(raw_nan.get_data(), data)
     raw_nan_annot = raw_ignore.copy()
-    raw_nan_annot.set_annotations(annotate_nan(raw_nan))
+    nan_annots = annotate_nan(raw_nan)
+    assert nan_annots.orig_time == raw_nan.info["meas_date"]
+    raw_nan_annot.set_annotations(nan_annots)
     use_mask = np.where(raw.annotations.description == 'BAD_SATURATED')
     for key in ('onset', 'duration'):
         a = getattr(raw_nan_annot.annotations, key)[::2]  # one ch in each
diff --git a/mne/preprocessing/tests/test_artifact_detection.py b/mne/preprocessing/tests/test_artifact_detection.py
--- a/mne/preprocessing/tests/test_artifact_detection.py
+++ b/mne/preprocessing/tests/test_artifact_detection.py
@@ -6,13 +6,15 @@
 import os.path as op
 import numpy as np
 import pytest
-from numpy.testing import assert_allclose
+
+from numpy.testing import assert_allclose, assert_array_equal
 from mne.chpi import read_head_pos
 from mne.datasets import testing
 from mne.io import read_raw_fif
 from mne.preprocessing import (annotate_movement, compute_average_dev_head_t,
                                annotate_muscle_zscore, annotate_break)
 from mne import Annotations, events_from_annotations
+from mne.tests.test_annotations import _assert_annotations_equal
 
 data_path = testing.data_path(download=False)
 sss_path = op.join(data_path, 'SSS')
@@ -21,25 +23,42 @@
 
 
 @testing.requires_testing_data
-def test_movement_annotation_head_correction():
+@pytest.mark.parametrize('meas_date', (None, 'orig'))
+def test_movement_annotation_head_correction(meas_date):
     """Test correct detection movement artifact and dev_head_t."""
     raw = read_raw_fif(raw_fname, allow_maxshield='yes').load_data()
     pos = read_head_pos(pos_fname)
+    if meas_date is None:
+        raw.set_meas_date(None)
+    else:
+        assert meas_date == 'orig'
 
     # Check 5 rotation segments are detected
     annot_rot, [] = annotate_movement(raw, pos, rotation_velocity_limit=5)
-    assert(annot_rot.duration.size == 5)
+    assert annot_rot.orig_time == raw.info["meas_date"]
+    assert annot_rot.duration.size == 5
 
     # Check 2 translation vel. segments are detected
     annot_tra, [] = annotate_movement(raw, pos, translation_velocity_limit=.05)
-    assert(annot_tra.duration.size == 2)
+    assert annot_tra.duration.size == 2
 
     # Check 1 movement distance segment is detected
-    annot_dis, disp = annotate_movement(raw, pos, mean_distance_limit=.02)
-    assert(annot_dis.duration.size == 1)
+    annot_dis, _ = annotate_movement(raw, pos, mean_distance_limit=.02)
+    assert annot_dis.duration.size == 1
 
     # Check correct trans mat
-    raw.set_annotations(annot_rot + annot_tra + annot_dis)
+    annot_all_2 = annotate_movement(
+        raw, pos, rotation_velocity_limit=5,
+        translation_velocity_limit=.05,
+        mean_distance_limit=.02)[0]
+    assert (annot_rot.orig_time ==
+            annot_tra.orig_time ==
+            annot_dis.orig_time ==
+            raw.info['meas_date'])
+    annot_all = annot_rot + annot_tra + annot_dis
+    _assert_annotations_equal(annot_all_2, annot_all)
+    assert annot_all.orig_time == raw.info['meas_date']
+    raw.set_annotations(annot_all)
     dev_head_t = compute_average_dev_head_t(raw, pos)
 
     dev_head_t_ori = np.array([
@@ -52,8 +71,8 @@ def test_movement_annotation_head_correction():
 
     # Smoke test skipping time due to previous annotations.
     raw.set_annotations(Annotations([raw.times[0]], 0.1, 'bad'))
-    annot_dis, disp = annotate_movement(raw, pos, mean_distance_limit=.02)
-    assert(annot_dis.duration.size == 1)
+    annot_dis, _ = annotate_movement(raw, pos, mean_distance_limit=.02)
+    assert annot_dis.duration.size == 1
 
 
 @testing.requires_testing_data
@@ -64,11 +83,11 @@ def test_muscle_annotation():
     # Check 2 muscle segments are detected
     annot_muscle, scores = annotate_muscle_zscore(raw, ch_type='mag',
                                                   threshold=10)
+    assert annot_muscle.orig_time == raw.info["meas_date"]
     onset = annot_muscle.onset * raw.info['sfreq']
     onset = onset.astype(int)
-    np.testing.assert_array_equal(scores[onset].astype(int), np.array([23,
-                                                                       10]))
-    assert(annot_muscle.duration.size == 2)
+    assert_array_equal(scores[onset].astype(int), np.array([23, 10]))
+    assert annot_muscle.duration.size == 2
 
 
 @testing.requires_testing_data
@@ -78,7 +97,7 @@ def test_muscle_annotation_without_meeg_data():
     raw.crop(0, .1).load_data()
     raw.pick_types(meg=False, stim=True)
     with pytest.raises(ValueError, match="No M/EEG channel types found"):
-        annot_muscle, scores = annotate_muscle_zscore(raw, threshold=10)
+        annotate_muscle_zscore(raw, threshold=10)
 
 
 @testing.requires_testing_data
@@ -121,6 +140,7 @@ def test_annotate_breaks():
         t_stop_before_next=t_stop_before_next
     )
 
+    assert break_annots.orig_time == raw.info["meas_date"]
     assert_allclose(break_annots.onset, expected_onsets)
     assert_allclose(break_annots.duration, expected_durations)
     assert all(description == 'BAD_break'
diff --git a/mne/preprocessing/tests/test_flat.py b/mne/preprocessing/tests/test_flat.py
--- a/mne/preprocessing/tests/test_flat.py
+++ b/mne/preprocessing/tests/test_flat.py
@@ -41,6 +41,7 @@ def test_annotate_flat(first_samp):
             (dict(), [raw.ch_names[0]], n_times)]:  # default (1)
         raw_time = raw_0.copy()
         annot, got_bads = annotate_flat(raw_0, verbose='debug', **kwargs)
+        assert annot.orig_time == raw.info["meas_date"]
         assert got_bads == bads
         raw_time.set_annotations(raw_time.annotations + annot)
         raw_time.info['bads'] += got_bads