Repository: mne-tools/mne-python

Issue:
Crop-like option for TFR with `fmin` and `fmax`
I've found out during classes where I teach mne that `.crop`-like operations are usually much more intuitive for students than having to dive into the data array and index it. With tfr they usually want to, for example selecte a time range and frequency range and then average (or not) and use in further computations/visualisations. If you agree it would be useful, there are two options:

## adding `fmin` and `fmax` to `TFR.crop()`
This would allow for example for
```python
tfr.crop(tmin=0.2, tmax=0.4, fmin=5, fmax=7)
```
@larsoner mentioned that he does not like this solution very much because `.crop()` sounds more time-related and it would stay to mean "crop in time" if we instead add another method for freq-cropping (Eric, please correct me if I misrepresent). I actually like putting it all into `.crop()` since I much prefer using kwargs anyway for clarity. Also crop sounds to me equally well suited for time, space or frequency (like trim, for example, but I'm not a native so my argument is weaker here :) ). I also think it would be easier for new mne users (like my students) to digest - one method for selection instead of multiple methods depending on the dimension type.

## adding another method for frequency cropping
Another method that would work like `.crop()` but for frequency dimension. I don't have a good idea for the method name in this case but just assuming it would be called `.frop()` the example from previous section would be:
```python
tfr.crop(tmin=0.2, tmax=0.4).frop(fmin=5, fmax=7)
```
obviously I'm not suggesting to actually call it `.frop()` :)



Discussion:
I like the first approach better, because I don't associate crop with time.
I am fine with first solution too

Thinking about it more, I'm fine with `crop`. If we think of a "crop" operation as "trimming the current raw / average / epoch / source data for all channels/sources", then you can crop in freq just as easily as time, and it's different from other forms of subselection we have, such as trimming the set of epochs with `epochs[10:20]`, subselecting source vertices with `stc.in_label`, or the set of channels with `epochs.pick_types`, etc.
I might be wrong but *cropping in frequency* with `fmin=5, fmax=7` isn't this doing a bandpass selection?

If we should be able to do that I would do `time_crop` and `frequency_crop` and remove `crop`. but this is not gonna fly.

So, when I read this issue I found it great, now I've mixed feelings.
> I might be wrong but cropping in frequency with fmin=5, fmax=7 isn't this doing a bandpass selection?

Yes but in principle this isn't all that different from cropping in time. You (intentionally) lose information in either case, just in different ways.
crop is a standard mne function name. Let's stick to this.

> crop is a standard mne function name. Let's stick to this.

That's exactly why we should not change it and having `crop` and `frequency_crop` is horrible, which takes us to `crop( tmin.. fmax)`. But when I put myself into position of doing `bla.crop+Tab` in iphython I find surprising `fmin=None, fmax=None`. That's why I was asking myself (outloud) how far was that from `tfr.crop(tmin=0.2, tmax=0.4).filter( l_freq=5, h_freq=7, type='crop')`
@massich
Do you mean that when doing for example `raw.crop(` the fmin and fmax would pop up? Can we subclass the crop mixin (if there is one) specifically for TFR? When doing `tfr.crop(`  finding fmin and fmax would be intuitive, I think.
Yes we should just override it in the TFR classes.
Btw I have a different intuitions for `filter` - that's an operation that requires additional processing and `crop` feels more like the only thing it does is dropping some data. 
OK that's fine :)

On Tue, Jan 29, 2019, 19:04 Mikolaj Magnuski <notifications@github.com>
wrote:

> Btw I have a different intuitions for filter - that's an operation that
> requires additional processing and crop feels more like the only thing it
> does is dropping some data.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/mne-tools/mne-python/issues/5868#issuecomment-458644018>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AGt-49qPyB9R0wSctrF0V6gaYptprjFMks5vII0jgaJpZM4aRqJQ>
> .
>

> Yes we should just override it in the TFR classes.

+1

>

I agree `frequency_crop` is terrible, but `crop_frequency` is not bad. Though `tfr.crop(fmin=` is also fine.

Resolving patch:
diff --git a/mne/time_frequency/tfr.py b/mne/time_frequency/tfr.py
--- a/mne/time_frequency/tfr.py
+++ b/mne/time_frequency/tfr.py
@@ -21,10 +21,10 @@
 
 from ..baseline import rescale
 from ..parallel import parallel_func
-from ..utils import (logger, verbose, _time_mask, check_fname, sizeof_fmt,
-                     GetEpochsMixin, _prepare_read_metadata, fill_doc,
-                     _prepare_write_metadata, _check_event_id, _gen_events,
-                     SizeMixin, _is_numeric, _check_option)
+from ..utils import (logger, verbose, _time_mask, _freq_mask, check_fname,
+                     sizeof_fmt, GetEpochsMixin, _prepare_read_metadata,
+                     fill_doc, _prepare_write_metadata, _check_event_id,
+                     _gen_events, SizeMixin, _is_numeric, _check_option)
 from ..channels.channels import ContainsMixin, UpdateChannelsMixin
 from ..channels.layout import _pair_grad_sensors
 from ..io.pick import (pick_info, _picks_to_idx, channel_type, _pick_inst,
@@ -869,7 +869,7 @@ def ch_names(self):
         """Channel names."""
         return self.info['ch_names']
 
-    def crop(self, tmin=None, tmax=None):
+    def crop(self, tmin=None, tmax=None, fmin=None, fmax=None):
         """Crop data to a given time interval in place.
 
         Parameters
@@ -878,15 +878,36 @@ def crop(self, tmin=None, tmax=None):
             Start time of selection in seconds.
         tmax : float | None
             End time of selection in seconds.
+        fmin : float | None
+            Lowest frequency of selection in Hz.
+
+            .. versionadded:: 0.18.0
+        fmax : float | None
+            Highest frequency of selection in Hz.
+
+            .. versionadded:: 0.18.0
 
         Returns
         -------
         inst : instance of AverageTFR
             The modified instance.
         """
-        mask = _time_mask(self.times, tmin, tmax, sfreq=self.info['sfreq'])
-        self.times = self.times[mask]
-        self.data = self.data[..., mask]
+        if tmin is not None or tmax is not None:
+            time_mask = _time_mask(self.times, tmin, tmax,
+                                   sfreq=self.info['sfreq'])
+
+        else:
+            time_mask = slice(None)
+
+        if fmin is not None or fmax is not None:
+            freq_mask = _freq_mask(self.freqs, sfreq=self.info['sfreq'],
+                                   fmin=fmin, fmax=fmax)
+        else:
+            freq_mask = slice(None)
+
+        self.times = self.times[time_mask]
+        self.freqs = self.freqs[freq_mask]
+        self.data = self.data[..., freq_mask, :][..., time_mask]
         return self
 
     def copy(self):
diff --git a/mne/utils/__init__.py b/mne/utils/__init__.py
--- a/mne/utils/__init__.py
+++ b/mne/utils/__init__.py
@@ -42,10 +42,10 @@
                        _reg_pinv, random_permutation, _reject_data_segments,
                        compute_corr, _get_inst_data, array_split_idx,
                        sum_squared, split_list, _gen_events, create_slices,
-                       _time_mask, grand_average, object_diff, object_hash,
-                       object_size, _apply_scaling_cov, _undo_scaling_cov,
-                       _apply_scaling_array, _undo_scaling_array,
-                       _scaled_array, _replace_md5, _PCA,
+                       _time_mask, _freq_mask, grand_average, object_diff,
+                       object_hash, object_size, _apply_scaling_cov,
+                       _undo_scaling_cov, _apply_scaling_array,
+                       _undo_scaling_array, _scaled_array, _replace_md5, _PCA,
                        _mask_to_onsets_offsets)
 from .mixin import (SizeMixin, GetEpochsMixin, _prepare_read_metadata,
-                    _prepare_write_metadata, _FakeNoPandas)
+_prepare_write_metadata, _FakeNoPandas)
\ No newline at end of file
diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py
--- a/mne/utils/numerics.py
+++ b/mne/utils/numerics.py
@@ -507,6 +507,34 @@ def _time_mask(times, tmin=None, tmax=None, sfreq=None, raise_error=True):
     return mask
 
 
+def _freq_mask(freqs, sfreq, fmin=None, fmax=None, raise_error=True):
+    """Safely find frequency boundaries."""
+    orig_fmin = fmin
+    orig_fmax = fmax
+    fmin = -np.inf if fmin is None else fmin
+    fmax = np.inf if fmax is None else fmax
+    if not np.isfinite(fmin):
+        fmin = freqs[0]
+    if not np.isfinite(fmax):
+        fmax = freqs[-1]
+    if sfreq is None:
+        raise ValueError('sfreq can not be None')
+    # Push 0.5/sfreq past the nearest frequency boundary first
+    sfreq = float(sfreq)
+    fmin = int(round(fmin * sfreq)) / sfreq - 0.5 / sfreq
+    fmax = int(round(fmax * sfreq)) / sfreq + 0.5 / sfreq
+    if raise_error and fmin > fmax:
+        raise ValueError('fmin (%s) must be less than or equal to fmax (%s)'
+                         % (orig_fmin, orig_fmax))
+    mask = (freqs >= fmin)
+    mask &= (freqs <= fmax)
+    if raise_error and not mask.any():
+        raise ValueError('No frequencies remain when using fmin=%s and '
+                         'fmax=%s (original frequency bounds are [%s, %s])'
+                         % (orig_fmin, orig_fmax, freqs[0], freqs[-1]))
+    return mask
+
+
 def grand_average(all_inst, interpolate_bads=True, drop_bads=True):
     """Make grand average of a list evoked or AverageTFR data.
 


Test patch:
diff --git a/mne/time_frequency/tests/test_tfr.py b/mne/time_frequency/tests/test_tfr.py
--- a/mne/time_frequency/tests/test_tfr.py
+++ b/mne/time_frequency/tests/test_tfr.py
@@ -355,16 +355,27 @@ def test_tfr_multitaper():
 
 def test_crop():
     """Test TFR cropping."""
-    data = np.zeros((3, 2, 3))
-    times = np.array([.1, .2, .3])
-    freqs = np.array([.10, .20])
+    data = np.zeros((3, 4, 5))
+    times = np.array([.1, .2, .3, .4, .5])
+    freqs = np.array([.10, .20, .30, .40])
     info = mne.create_info(['MEG 001', 'MEG 002', 'MEG 003'], 1000.,
                            ['mag', 'mag', 'mag'])
     tfr = AverageTFR(info, data=data, times=times, freqs=freqs,
                      nave=20, comment='test', method='crazy-tfr')
-    tfr.crop(0.2, 0.3)
-    assert_array_equal(tfr.times, [0.2, 0.3])
+
+    tfr.crop(tmin=0.2)
+    assert_array_equal(tfr.times, [0.2, 0.3, 0.4, 0.5])
+    assert_equal(tfr.data.shape[-1], 4)
+
+    tfr.crop(fmax=0.3)
+    assert_equal(tfr.freqs, [0.1, 0.2, 0.3])
+    assert_equal(tfr.data.shape[-2], 3)
+
+    tfr.crop(tmin=0.3, tmax=0.4, fmin=0.1, fmax=0.2)
+    assert_array_equal(tfr.times, [0.3, 0.4])
     assert_equal(tfr.data.shape[-1], 2)
+    assert_equal(tfr.freqs, [0.1, 0.2])
+    assert_equal(tfr.data.shape[-2], 2)
 
 
 @requires_h5py
diff --git a/mne/utils/tests/test_numerics.py b/mne/utils/tests/test_numerics.py
--- a/mne/utils/tests/test_numerics.py
+++ b/mne/utils/tests/test_numerics.py
@@ -15,7 +15,7 @@
 from mne.time_frequency import tfr_morlet
 from mne.utils import (_get_inst_data, md5sum, hashfunc,
                        sum_squared, compute_corr, create_slices, _time_mask,
-                       random_permutation, _reg_pinv, object_size,
+                       _freq_mask, random_permutation, _reg_pinv, object_size,
                        object_hash, object_diff, _apply_scaling_cov,
                        _undo_scaling_cov, _apply_scaling_array,
                        _undo_scaling_array, _PCA, requires_sklearn)
@@ -171,8 +171,40 @@ def test_time_mask():
     assert _time_mask(x, tmin=4.4999, sfreq=1).sum() == 2
     assert _time_mask(x, tmin=4, sfreq=1).sum() == 2
     # degenerate cases
-    pytest.raises(ValueError, _time_mask, x[:1], tmin=11, tmax=12)
-    pytest.raises(ValueError, _time_mask, x[:1], tmin=10, sfreq=1)
+    with pytest.raises(ValueError, match='No samples remain'):
+        _time_mask(x[:1], tmin=11, tmax=12)
+    with pytest.raises(ValueError, match='must be less than or equal to tmax'):
+        _time_mask(x[:1], tmin=10, sfreq=1)
+
+
+def test_freq_mask():
+    """Test safe frequency masking."""
+    N = 10
+    x = np.arange(N).astype(float)
+    assert _freq_mask(x, 1000., fmin=0, fmax=N - 1).sum() == N
+    assert _freq_mask(x - 1e-10, 1000., fmin=0, fmax=N - 1).sum() == N
+    assert _freq_mask(x - 1e-10, 1000., fmin=None, fmax=N - 1).sum() == N
+    assert _freq_mask(x - 1e-10, 1000., fmin=None, fmax=None).sum() == N
+    assert _freq_mask(x - 1e-10, 1000., fmin=-np.inf, fmax=None).sum() == N
+    assert _freq_mask(x - 1e-10, 1000., fmin=None, fmax=np.inf).sum() == N
+    # non-uniformly spaced inputs
+    x = np.array([4, 10])
+    assert _freq_mask(x[:1], 1, fmin=10, raise_error=False).sum() == 0
+    assert _freq_mask(x[:1], 1, fmin=11, fmax=12,
+                      raise_error=False).sum() == 0
+    assert _freq_mask(x, sfreq=1, fmin=10).sum() == 1
+    assert _freq_mask(x, sfreq=1, fmin=6).sum() == 1
+    assert _freq_mask(x, sfreq=1, fmin=5).sum() == 1
+    assert _freq_mask(x, sfreq=1, fmin=4.5001).sum() == 1
+    assert _freq_mask(x, sfreq=1, fmin=4.4999).sum() == 2
+    assert _freq_mask(x, sfreq=1, fmin=4).sum() == 2
+    # degenerate cases
+    with pytest.raises(ValueError, match='sfreq can not be None'):
+        _freq_mask(x[:1], sfreq=None, fmin=3, fmax=5)
+    with pytest.raises(ValueError, match='No frequencies remain'):
+        _freq_mask(x[:1], sfreq=1, fmin=11, fmax=12)
+    with pytest.raises(ValueError, match='must be less than or equal to fmax'):
+        _freq_mask(x[:1], sfreq=1, fmin=10)
 
 
 def test_random_permutation():