Repository: mne-tools/mne-python

Issue:
ENH, DOC:  First_samp support for SourceSimulator class
#### Describe the new feature or enhancement
Currently, simulating raw data with a `SourceSimulator `object (in _simulation/raw.py : simulate_raw()_) causes extra time points at the beginning if the source events are offset in time, even when a `first_samp` argument is included to nominally account for that offset.  This could happen, for example, if an experimental raw object and associated event list are used to instantiate _simulate_raw()_.  Effectively, the `first_samp` argument only changes the simulated raw object's absolute timing attributes (.first_samp, .last_samp, .first_time, etc), but doesn't affect how sources are actually added to it.  This means that if the first event has an arbitrarily large time stamp, then the simulator object (and therefore the resulting raw object) will be arbitarily long.

The documentation should also be updated.  For example, presently there is no indication that a `SourceSimulator `object is a valid argument for the _simulate_raw()_ constructor.

#### Describe your proposed implementation
I propose to add `first_samp `support to the `SourceSimulator `class, so that event waveforms added to an instant of the class (via _.add_data(_)) are appropriately time stamped.  This can be implemented by simply subtracting the `first_samp ` value from the integer event times (which are expressed as absolute samples) when adding event data.  The `first_samp `value would be passed as an argument at the time the source object is instantiated; it would default to 0 (which would not influence existing API usage) and event times less than the value would cause an error.

I am inclined to assert that the `first_samp` argument for _simulate_raw()_ be the same as the `first_samp` attribute of the simulator object.  In any case, a suitable test function should make sure that added events in the raw object occur at the intended time samples.  (Presently there is no test for using source simulators with _simulate_raw()_.)

The alternative way to simulate raw data with _simulate_raw()_ is with a `SourceEstimate `object or an iterable of `SourceEstimate `objects.  The `first_samp` argument shouldn't affect how the raw data is created with this approach, as the STCs are simply concatenated in time.

#### Describe possible alternatives
Rather than changing the SS class definition, MNE users could be made aware of the current `first_samp` limitations via an instructive example.

#### Additional comments
With @cmista for Code Sprint 2021; I have mentioned the proposed change to @larsoner



Discussion:
> I propose to add first_samp support to the SourceSimulator class

+1

> I am inclined to assert that the first_samp argument for simulate_raw() be the same as the first_samp attribute of the simulator object.

Sure, I'd consider this a bug fix for people.
sounds good !

>


Resolving patch:
diff --git a/mne/simulation/raw.py b/mne/simulation/raw.py
--- a/mne/simulation/raw.py
+++ b/mne/simulation/raw.py
@@ -33,6 +33,7 @@
 from ..utils import (logger, verbose, check_random_state, _pl, _validate_type,
                      _check_preload)
 from ..parallel import check_n_jobs
+from .source import SourceSimulator
 
 
 def _check_cov(info, cov):
@@ -110,8 +111,11 @@ def _check_head_pos(head_pos, info, first_samp, times=None):
             raise RuntimeError('All position times must be <= t_end (%0.1f '
                                'sec), found %s/%s bad values (is this a split '
                                'file?)' % (times[-1], bad.sum(), len(bad)))
+    # If it starts close to zero, make it zero (else unique(offset) fails)
+    if len(ts) > 0 and ts[0] < (0.5 / info['sfreq']):
+        ts[0] = 0.
     # If it doesn't start at zero, insert one at t=0
-    if len(ts) == 0 or ts[0] > 0:
+    elif len(ts) == 0 or ts[0] > 0:
         ts = np.r_[[0.], ts]
         dev_head_ts.insert(0, info['dev_head_t']['trans'])
     dev_head_ts = [{'trans': d, 'to': info['dev_head_t']['to'],
@@ -140,7 +144,7 @@ def simulate_raw(info, stc=None, trans=None, src=None, bem=None, head_pos=None,
 
         .. versionchanged:: 0.18
            Support for :class:`mne.Info`.
-    stc : iterable | SourceEstimate
+    stc : iterable | SourceEstimate | SourceSimulator
         The source estimates to use to simulate data. Each must have the same
         sample rate as the raw data, and the vertices of all stcs in the
         iterable must match. Each entry in the iterable can also be a tuple of
@@ -149,7 +153,8 @@ def simulate_raw(info, stc=None, trans=None, src=None, bem=None, head_pos=None,
         See Notes for details.
 
         .. versionchanged:: 0.18
-           Support for tuple, and iterable of tuple or SourceEstimate.
+           Support for tuple, iterable of tuple or `~mne.SourceEstimate`,
+           or `~mne.simulation.SourceSimulator`.
     trans : dict | str | None
         Either a transformation filename (usually made using mne_analyze)
         or an info dict (usually opened using read_trans()).
@@ -271,8 +276,10 @@ def simulate_raw(info, stc=None, trans=None, src=None, bem=None, head_pos=None,
     logger.info('Setting up raw simulation: %s position%s, "%s" interpolation'
                 % (len(dev_head_ts), _pl(dev_head_ts), interp))
 
+    if isinstance(stc, SourceSimulator) and stc.first_samp != first_samp:
+        logger.info('SourceSimulator first_samp does not match argument.')
+
     stc_enum, stc_counted, verts = _check_stc_iterable(stc, info)
-    # del stc
     if forward is not None:
         forward = restrict_forward_to_stc(forward, verts)
         src = forward['src']
diff --git a/mne/simulation/source.py b/mne/simulation/source.py
--- a/mne/simulation/source.py
+++ b/mne/simulation/source.py
@@ -327,6 +327,10 @@ class SourceSimulator(object):
     duration : float | None
         Time interval during which the simulation takes place in seconds.
         If None, it is computed using existing events and waveform lengths.
+    first_samp : int
+        First sample from which the simulation takes place, as an integer.
+        Comparable to the ``first_samp`` property of `~mne.io.Raw` objects.
+        Default is 0.
 
     Attributes
     ----------
@@ -336,28 +340,39 @@ class SourceSimulator(object):
         The number of time samples of the simulation.
     """
 
-    def __init__(self, src, tstep=1e-3, duration=None):
+    def __init__(self, src, tstep=1e-3, duration=None, first_samp=0):
+        if duration is not None and duration < tstep:
+            raise ValueError('duration must be None or >= tstep.')
+        self.first_samp = first_samp
         self._src = src
         self._tstep = tstep
         self._labels = []
         self._waveforms = []
         self._events = np.empty((0, 3), dtype=int)
-        self._duration = duration
+        self._duration = duration  # if not None, sets # samples
         self._last_samples = []
         self._chk_duration = 1000
 
     @property
     def duration(self):
-        """Duration of the simulation."""
-        # If not, the precomputed maximum last sample is used
-        if self._duration is None:
-            return np.max(self._last_samples) * self._tstep
-        return self._duration
+        """Duration of the simulation in same units as tstep."""
+        if self._duration is not None:
+            return self._duration
+        return self.n_times * self._tstep
 
     @property
     def n_times(self):
         """Number of time samples in the simulation."""
-        return int(self.duration / self._tstep)
+        if self._duration is not None:
+            return int(self._duration / self._tstep)
+        ls = self.first_samp
+        if len(self._last_samples) > 0:
+            ls = np.max(self._last_samples)
+        return ls - self.first_samp + 1  # >= 1
+
+    @property
+    def last_samp(self):
+        return self.first_samp + self.n_times - 1
 
     def add_data(self, label, waveform, events):
         """Add data to the simulation.
@@ -394,39 +409,43 @@ def add_data(self, label, waveform, events):
             raise ValueError('Number of waveforms and events should match or '
                              'there should be a single waveform (%d != %d).' %
                              (len(waveform), len(events)))
-        # Update the maximum duration possible based on the events
+        # Update the last sample possible based on events + waveforms
         self._labels.extend([label] * len(events))
         self._waveforms.extend(waveform)
         self._events = np.vstack([self._events, events])
         # First sample per waveform is the first column of events
         # Last is computed below
-        self._last_samples = np.array([self._events[i, 0] + len(w)
+        self._last_samples = np.array([self._events[i, 0] + len(w) - 1
                                       for i, w in enumerate(self._waveforms)])
 
     def get_stim_channel(self, start_sample=0, stop_sample=None):
         """Get the stim channel from the provided data.
 
         Returns the stim channel data according to the simulation parameters
-        which should be added through function add_data. If both start_sample
+        which should be added through the add_data method. If both start_sample
         and stop_sample are not specified, the entire duration is used.
 
         Parameters
         ----------
         start_sample : int
-            First sample in chunk. Default is 0.
+            First sample in chunk. Default is the value of the ``first_samp``
+            attribute.
         stop_sample : int | None
-            The stop sample of the returned stc. This sample is not part of the
-            output to follow slicing semantics. If None, then all samples past
-            start_sample is returned.
+            The final sample of the returned stc. If None, then all samples
+            from start_sample onward are returned.
 
         Returns
         -------
         stim_data : ndarray of int, shape (n_samples,)
             The stimulation channel data.
         """
+        if start_sample is None:
+            start_sample = self.first_samp
         if stop_sample is None:
-            stop_sample = self.n_times
-        n_samples = stop_sample - start_sample
+            stop_sample = start_sample + self.n_times - 1
+        elif stop_sample < start_sample:
+            raise ValueError('Argument start_sample must be >= stop_sample.')
+        n_samples = stop_sample - start_sample + 1
 
         # Initialize the stim data array
         stim_data = np.zeros(n_samples, dtype=int)
@@ -442,7 +461,7 @@ def get_stim_channel(self, start_sample=0, stop_sample=None):
 
         return stim_data
 
-    def get_stc(self, start_sample=0, stop_sample=None):
+    def get_stc(self, start_sample=None, stop_sample=None):
         """Simulate a SourceEstimate from the provided data.
 
         Returns a SourceEstimate object constructed according to the simulation
@@ -452,12 +471,12 @@ def get_stc(self, start_sample=0, stop_sample=None):
 
         Parameters
         ----------
-        start_sample : int
-            First sample in chunk. Default is 0.
+        start_sample : int | None
+            First sample in chunk. If ``None`` the value of the ``first_samp``
+            attribute is used. Defaults to ``None``.
         stop_sample : int | None
-            The stop sample of the returned stc. This sample is not part of the
-            output to follow slicing semantics. If None, then all samples past
-            start_sample is returned.
+            The final sample of the returned STC. If ``None``, then all samples
+            past ``start_sample`` are returned.
 
         Returns
         -------
@@ -467,19 +486,22 @@ def get_stc(self, start_sample=0, stop_sample=None):
         if len(self._labels) == 0:
             raise ValueError('No simulation parameters were found. Please use '
                              'function add_data to add simulation parameters.')
+        if start_sample is None:
+            start_sample = self.first_samp
         if stop_sample is None:
-            stop_sample = self.n_times
-
-        n_samples = stop_sample - start_sample
+            stop_sample = start_sample + self.n_times - 1
+        elif stop_sample < start_sample:
+            raise ValueError('start_sample must be >= stop_sample.')
+        n_samples = stop_sample - start_sample + 1
 
-        # Initialize the stc_data array
+        # Initialize the stc_data array to span all possible samples
         stc_data = np.zeros((len(self._labels), n_samples))
 
-        # Select only the indices that have events in the time chunk
+        # Select only the events that fall within the span
         ind = np.where(np.logical_and(self._last_samples >= start_sample,
-                                      self._events[:, 0] < stop_sample))[0]
+                                      self._events[:, 0] <= stop_sample))[0]
 
-        # Loop only over the items that are in the time chunk
+        # Loop only over the items that are in the time span
         subset_waveforms = [self._waveforms[i] for i in ind]
         for i, (waveform, event) in enumerate(zip(subset_waveforms,
                                                   self._events[ind])):
@@ -489,16 +511,17 @@ def get_stc(self, start_sample=0, stop_sample=None):
             wf_stop = self._last_samples[ind[i]]
 
             # Recover the indices of the event that should be in the chunk
-            waveform_ind = np.in1d(np.arange(wf_start, wf_stop),
-                                   np.arange(start_sample, stop_sample))
+            waveform_ind = np.in1d(np.arange(wf_start, wf_stop + 1),
+                                   np.arange(start_sample, stop_sample + 1))
 
             # Recover the indices that correspond to the overlap
-            stc_ind = np.in1d(np.arange(start_sample, stop_sample),
-                              np.arange(wf_start, wf_stop))
+            stc_ind = np.in1d(np.arange(start_sample, stop_sample + 1),
+                              np.arange(wf_start, wf_stop + 1))
 
             # add the resulting waveform chunk to the corresponding label
             stc_data[ind[i]][stc_ind] += waveform[waveform_ind]
 
+        start_sample -= self.first_samp  # STC sample ref is 0
         stc = simulate_stc(self._src, self._labels, stc_data,
                            start_sample * self._tstep, self._tstep,
                            allow_overlap=True)
@@ -507,11 +530,13 @@ def get_stc(self, start_sample=0, stop_sample=None):
 
     def __iter__(self):
         """Iterate over 1 second STCs."""
-        # Arbitrary chunk size, can be modified later to something else
+        # Arbitrary chunk size, can be modified later to something else.
         # Loop over chunks of 1 second - or, maximum sample size.
         # Can be modified to a different value.
-        n_times = self.n_times
-        for start_sample in range(0, n_times, self._chk_duration):
-            stop_sample = min(start_sample + self._chk_duration, n_times)
+        last_sample = self.last_samp
+        for start_sample in range(self.first_samp, last_sample + 1,
+                                  self._chk_duration):
+            stop_sample = min(start_sample + self._chk_duration - 1,
+                              last_sample)
             yield (self.get_stc(start_sample, stop_sample),
                    self.get_stim_channel(start_sample, stop_sample))


Test patch:
diff --git a/mne/simulation/tests/test_raw.py b/mne/simulation/tests/test_raw.py
--- a/mne/simulation/tests/test_raw.py
+++ b/mne/simulation/tests/test_raw.py
@@ -26,6 +26,8 @@
 from mne.simulation import (simulate_sparse_stc, simulate_raw, add_eog,
                             add_ecg, add_chpi, add_noise)
 from mne.source_space import _compare_source_spaces
+from mne.simulation.source import SourceSimulator
+from mne.label import Label
 from mne.surface import _get_ico_surface
 from mne.io import read_raw_fif, RawArray
 from mne.io.constants import FIFF
@@ -325,7 +327,7 @@ def test_degenerate(raw_data):
 @pytest.mark.slowtest
 def test_simulate_raw_bem(raw_data):
     """Test simulation of raw data with BEM."""
-    raw, src, stc, trans, sphere = raw_data
+    raw, src_ss, stc, trans, sphere = raw_data
     src = setup_source_space('sample', 'oct1', subjects_dir=subjects_dir)
     for s in src:
         s['nuse'] = 3
@@ -366,6 +368,31 @@ def test_simulate_raw_bem(raw_data):
         diffs = np.sqrt(np.sum((locs - fits) ** 2, axis=-1)) * 1000
         med_diff = np.median(diffs)
         assert med_diff < tol, '%s: %s' % (bem, med_diff)
+    # also test event timings with SourceSimulator
+    first_samp = raw.first_samp
+    events = find_events(raw, initial_event=True, verbose=False)
+    evt_times = events[:, 0]
+    assert len(events) == 3
+    labels_sim = [[], [], []]  # random l+r hemisphere points
+    labels_sim[0] = Label([src_ss[0]['vertno'][1]], hemi='lh')
+    labels_sim[1] = Label([src_ss[0]['vertno'][4]], hemi='lh')
+    labels_sim[2] = Label([src_ss[1]['vertno'][2]], hemi='rh')
+    wf_sim = np.array([2, 1, 0])
+    for this_fs in (0, first_samp):
+        ss = SourceSimulator(src_ss, 1. / raw.info['sfreq'],
+                             first_samp=this_fs)
+        for i in range(3):
+            ss.add_data(labels_sim[i], wf_sim, events[np.newaxis, i])
+        assert ss.n_times == evt_times[-1] + len(wf_sim) - this_fs
+    raw_sim = simulate_raw(raw.info, ss, src=src_ss, bem=bem_fname,
+                           first_samp=first_samp)
+    data = raw_sim.get_data()
+    amp0 = data[:, evt_times - first_samp].max()
+    amp1 = data[:, evt_times + 1 - first_samp].max()
+    amp2 = data[:, evt_times + 2 - first_samp].max()
+    assert_allclose(amp0 / amp1, wf_sim[0] / wf_sim[1], rtol=1e-5)
+    assert amp2 == 0
+    assert raw_sim.n_times == ss.n_times
 
 
 def test_simulate_round_trip(raw_data):
diff --git a/mne/simulation/tests/test_source.py b/mne/simulation/tests/test_source.py
--- a/mne/simulation/tests/test_source.py
+++ b/mne/simulation/tests/test_source.py
@@ -14,8 +14,7 @@
 from mne import (read_label, read_forward_solution, pick_types_forward,
                  convert_forward_solution)
 from mne.label import Label
-from mne.simulation.source import simulate_stc, simulate_sparse_stc
-from mne.simulation.source import SourceSimulator
+from mne.simulation import simulate_stc, simulate_sparse_stc, SourceSimulator
 from mne.utils import run_tests_if_main, check_version
 
 
@@ -339,12 +338,12 @@ def test_source_simulator(_get_fwd_labels):
     stc = ss.get_stc()
     stim_channel = ss.get_stim_channel()
 
-    # Stim channel data must have the same size as stc time samples
-    assert len(stim_channel) == stc.data.shape[1]
-
-    stim_channel = ss.get_stim_channel(0, 0)
-    assert len(stim_channel) == 0
+    # Make some size checks.
+    assert ss.duration == 1.0
+    assert ss.n_times == 6
+    assert ss.last_samp == 5
 
+    assert len(stim_channel) == stc.data.shape[1]
     assert np.all(stc.vertices[0] == verts_lh)
     assert np.all(stc.vertices[1] == verts_rh)
     assert_array_almost_equal(stc.lh_data, output_data_lh)
@@ -357,23 +356,50 @@ def test_source_simulator(_get_fwd_labels):
         counter += 1
     assert counter == 1
 
+    # Check validity of setting duration and start/stop parameters.
     half_ss = SourceSimulator(src, tstep, duration=0.5)
     for i in range(3):
         half_ss.add_data(mylabels[i], wfs[i], events[i])
     half_stc = half_ss.get_stc()
     assert_array_almost_equal(stc.data[:, :3], half_stc.data)
 
-    ss = SourceSimulator(src)
+    part_stc = ss.get_stc(start_sample=1, stop_sample=4)
+    assert part_stc.shape == (24, 4)
+    assert part_stc.times[0] == tstep
 
+    # Check validity of other arguments.
+    with pytest.raises(ValueError, match='start_sample must be'):
+        ss.get_stc(2, 0)
+    ss = SourceSimulator(src)
     with pytest.raises(ValueError, match='No simulation parameters'):
         ss.get_stc()
-
     with pytest.raises(ValueError, match='label must be a Label'):
         ss.add_data(1, wfs, events)
-
     with pytest.raises(ValueError, match='Number of waveforms and events '
                        'should match'):
         ss.add_data(mylabels[0], wfs[:2], events)
+    with pytest.raises(ValueError, match='duration must be None or'):
+        ss = SourceSimulator(src, tstep, tstep / 2)
+
+    # Verify first_samp functionality.
+    ss = SourceSimulator(src, tstep)
+    offset = 50
+    for i in range(3):  # events are offset, but first_samp = 0
+        events[i][:, 0] += offset
+        ss.add_data(mylabels[i], wfs[i], events[i])
+    offset_stc = ss.get_stc()
+    assert ss.n_times == 56
+    assert ss.first_samp == 0
+    assert offset_stc.data.shape == (stc.data.shape[0],
+                                     stc.data.shape[1] + offset)
+    ss = SourceSimulator(src, tstep, first_samp=offset)
+    for i in range(3):  # events still offset, but first_samp > 0
+        ss.add_data(mylabels[i], wfs[i], events[i])
+    offset_stc = ss.get_stc()
+    assert ss.n_times == 6
+    assert ss.first_samp == offset
+    assert ss.last_samp == offset + 5
+    assert offset_stc.data.shape == stc.data.shape
 
     # Verify that the chunks have the correct length.
     source_simulator = SourceSimulator(src, tstep=tstep, duration=10 * tstep)