Repository: mne-tools/mne-python

Issue:
BUG: Manipulating the times property of Epochs results in wrong data after average if data was not loaded.
#### Describe the bug
An `Epochs` object has a `times` property that can be modified. The effect of modifying this property is inconsistent, depending on whether the `Epochs` object has preloaded data (i.e. `load_data`) was called or not.

I created a test for this bug: https://github.com/thht/mne-python/blob/th/test_timeshift_master/mne/tests/test_timeshift.py


Discussion:
you should not be allowed to do: epochs1.times += t_shift

epochs.times should be read only as it's obtained from tmin, tmax and sfreq

can you open a PR to make sure epochs.times is read only?

ok, i can do that. however, i have a current use case in which it would be very beneficial for the logical flow of my analysis to be able to correct for a delay introduced during stimulation (auditory stimulation with tubes). this can be done for instances of `Evoked` but in my case i do single-trial analysis.

so, do you think there is a way to make times read only but introduce a method like `Evoked.shift_time` for `Epochs`?

Resolving patch:
diff --git a/mne/epochs.py b/mne/epochs.py
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -385,7 +385,7 @@ def __init__(self, info, data, events, event_id=None, tmin=-0.2, tmax=0.5,
         start_idx = int(round(tmin * sfreq))
         self._raw_times = np.arange(start_idx,
                                     int(round(tmax * sfreq)) + 1) / sfreq
-        self.times = self._raw_times.copy()
+        self._set_times(self._raw_times)
         self._decim = 1
         self.decimate(decim)
 
@@ -427,6 +427,8 @@ def _check_consistency(self):
         assert len(self.selection) == sum(
             (len(dl) == 0 for dl in self.drop_log))
         assert len(self.drop_log) >= len(self.events)
+        assert hasattr(self, '_times_readonly')
+        assert not self.times.flags['WRITEABLE']
 
     def _check_metadata(self, metadata=None, reset_index=False):
         """Check metadata consistency."""
@@ -561,10 +563,9 @@ def decimate(self, decim, offset=0, verbose=None):
                 self._data = np.ascontiguousarray(self._data)
             self._decim_slice = slice(None)
             self._decim = 1
-            self.times = self._raw_times
         else:
             self._decim_slice = decim_slice
-            self.times = self._raw_times[self._decim_slice]
+        self._set_times(self._raw_times[self._decim_slice])
         return self
 
     @verbose
@@ -1420,6 +1421,18 @@ def next(self, return_event_id=False):
 
         return epoch if not return_event_id else epoch, self.event_id
 
+    @property
+    def times(self):
+        """Time vector in seconds."""
+        return self._times_readonly
+
+    def _set_times(self, times):
+        """Set self._times_readonly (and make it read only)."""
+        # naming used to indicate that it shouldn't be
+        # changed directly, but rather via this method
+        self._times_readonly = times.copy()
+        self._times_readonly.flags['WRITEABLE'] = False
+
     @property
     def tmin(self):
         """First time point."""
@@ -1662,7 +1675,7 @@ def crop(self, tmin=None, tmax=None):
             tmax = self.tmax
 
         tmask = _time_mask(self.times, tmin, tmax, sfreq=self.info['sfreq'])
-        self.times = self.times[tmask]
+        self._set_times(self.times[tmask])
         self._raw_times = self._raw_times[tmask]
         self._data = self._data[:, :, tmask]
         return self
@@ -1674,6 +1687,7 @@ def copy(self):
         new = deepcopy(self)
         self._raw = raw
         new._raw = raw
+        new._set_times(new.times)  # sets RO
         return new
 
     @verbose
diff --git a/mne/filter.py b/mne/filter.py
--- a/mne/filter.py
+++ b/mne/filter.py
@@ -2099,12 +2099,14 @@ def resample(self, sfreq, npad='auto', window='boxcar', n_jobs=1,
         self._data = resample(self._data, sfreq, o_sfreq, npad, window=window,
                               n_jobs=n_jobs, pad=pad)
         self.info['sfreq'] = float(sfreq)
-        self.times = (np.arange(self._data.shape[-1], dtype=np.float) /
-                      sfreq + self.times[0])
+        new_times = (np.arange(self._data.shape[-1], dtype=np.float) /
+                     sfreq + self.times[0])
         # adjust indirectly affected variables
         if isinstance(self, BaseEpochs):
+            self._set_times(new_times)
             self._raw_times = self.times
         else:
+            self.times = new_times
             self.first = int(self.times[0] * self.info['sfreq'])
             self.last = len(self.times) + self.first - 1
         return self


Test patch:
diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py
--- a/mne/tests/test_epochs.py
+++ b/mne/tests/test_epochs.py
@@ -1937,6 +1937,7 @@ def make_epochs(picks, proj):
     for proj in (False, True):
         epochs = make_epochs(picks=picks, proj=proj)
         epochs_meg = make_epochs(picks=picks_meg, proj=proj)
+        assert not epochs_meg.times.flags['WRITEABLE']
         epochs_eeg = make_epochs(picks=picks_eeg, proj=proj)
         epochs.info._check_consistency()
         epochs_meg.info._check_consistency()
@@ -1957,7 +1958,10 @@ def make_epochs(picks, proj):
         assert_allclose(data1, data3, atol=1e-25)
         assert_allclose(data1, data2, atol=1e-25)
 
+    assert not epochs_meg.times.flags['WRITEABLE']
     epochs_meg2 = epochs_meg.copy()
+    assert not epochs_meg.times.flags['WRITEABLE']
+    assert not epochs_meg2.times.flags['WRITEABLE']
     epochs_meg2.info['meas_date'] = (0, 0)
     add_channels_epochs([epochs_meg2, epochs_eeg])
 
@@ -2007,12 +2011,13 @@ def make_epochs(picks, proj):
     pytest.raises(ValueError, add_channels_epochs, [epochs_meg2, epochs_eeg])
 
     epochs_meg2 = epochs_meg.copy()
-    epochs_meg2.times += 0.4
+    epochs_meg2._set_times(epochs_meg.times + 0.4)
     pytest.raises(NotImplementedError, add_channels_epochs,
                   [epochs_meg2, epochs_eeg])
 
     epochs_meg2 = epochs_meg.copy()
-    epochs_meg2.times += 0.5
+    epochs_meg2._set_times(epochs_meg2.times + 0.5)
+    assert not epochs_meg2.times.flags['WRITEABLE']
     pytest.raises(NotImplementedError, add_channels_epochs,
                   [epochs_meg2, epochs_eeg])
 
@@ -2138,7 +2143,7 @@ def test_concatenate_epochs():
         ValueError, concatenate_epochs,
         [epochs, epochs2.copy().drop_channels(epochs2.ch_names[:1])])
 
-    epochs2.times = np.delete(epochs2.times, 1)
+    epochs2._set_times(np.delete(epochs2.times, 1))
     pytest.raises(
         ValueError,
         concatenate_epochs, [epochs, epochs2])
@@ -2471,4 +2476,16 @@ def test_save_complex_data(tmpdir):
     assert len(epochs) == 0  # all dropped
 
 
+def test_readonly_times():
+    """Test that the times property is read only."""
+    raw, events = _get_data()[:2]
+    epochs = Epochs(raw, events[:1], preload=True)
+    with pytest.raises(ValueError, match='read-only'):
+        epochs._times_readonly += 1
+    with pytest.raises(ValueError, match='read-only'):
+        epochs.times += 1
+    with pytest.raises(ValueError, match='read-only'):
+        epochs.times[:] = 0.
+
+
 run_tests_if_main()