Repository: mne-tools/mne-bids

Issue:
Asserting equal times in Raw and Original results in unwarranted failure for (some) unstructured files
Hi, thanks for creating and maintaining this package :) I think I may be stumbling across a "it's a feature!-bug".

**100-feet overview**
I have inherited a badly structured MEG dataset and use mne-bids to create a well-structured version from it. 
The raw file names are not consistent, and split files will thus not be read in consecutively automatically. My scripts read in the raw data split-wise and concatenate them. I don't want to touch/modify/rename the raw data - many people use it, and I want my scripts to be reproducible from the very start.
 
When saving this manually read-in raw file with ``write_raw_bids``, mne-bids  performs an internal check that the ``.times`` of the passed ``Raw`` instance (manually read-in, concatenated file) and the ``.times`` of the "original data" (read in automatically from the internal filepath of the ``Raw`` object) match. As the internal filepath points to a file that will not be read with all consecutive splits automatically, this check fails.

## Example
The files I am reading in look for example like this:
```
1_memento_001_ml83.fif  
2_memento_001_ml83-1.fif  
memento_001_ml83-2.fif
```
or 
```
1_memento_005_dk76.fif
2_memento_005_DK76.fif  
3_memento_005_DK76-1.fif
```

[My code](https://github.com/adswa/pymento/blob/19cc9de3a85a7ecb2287c8d48ae500613bbbb9d4/pymento_meg/orig/restructure.py#L111) reads in the data and eventually calls ``write_raw_bids`` like this:

```py
    bids_path = _get_BIDSPath(subject, bidsdir)
    print(
        f"Saving BIDS-compliant raw data from subject "
        f"{subject} into " f"{bids_path}"
    )
    # save raw fif data and events
    events_data, event_dict = _events(raw, subject, figdir)
    write_raw_bids(raw, bids_path, events_data=events_data,
                   event_id=event_dict, overwrite=True)

    # save crosstalk and calibration file
    _elektas_extras(crosstalk_file=ctfile,
                    fine_cal_file=fcfile,
                    bids_path=bids_path)
```

Within ``write_raw_bids``, this check is causing the operation to fail:

https://github.com/mne-tools/mne-bids/blob/a45695ed43394f067a776f0f51441723915bcbf5/mne_bids/write.py#L1134-L1152


I get why this check exists, but could I interest you in the thought of for example giving it a ``on_fail='ignore'/'warn'/'raise'`` parameter, just like many functions in mne, or a ``be_extra_sure=True/False`` argument for ``write_raw_bids``? (or something similar - whatever it may be, I'd be happy to attempt a PR). To me, being able to override this check would be preferable over the solution of "rename your raw files".

Granted, renaming the raw files would solve it, but this would modify pristine raw data that for many years has been used by dozens of researchers. My scripts wouldn't run on this data, but require a personal copy, and a separate step in my script to first copy and rename the data in a temporary directory would double the amount of required disk space storage.

Cheers,
Adina

<details><summary>Full traceback</summary>

```
pymento-restructure --subject '005' --raw_data_dir Data_MEG/RawData_MEG_memento/ --bids_dir /tmp/anotherbids --calfiles_dir  Data_MEG/RawData_MEG_memento/ --diagnostics_dir /tmp/anotherbidsfigs
Starting to restructure original memento data into BIDS for subject sub-005.
Reading files for subject sub-005 from Data_MEG/RawData_MEG_memento/memento_005/Raw/*.fif.
Order the files as follows: Data_MEG/RawData_MEG_memento/memento_005/Raw/1_memento_005_dk76.fif, Data_MEG/RawData_MEG_memento/memento_005/Raw/2_memento_005_DK76.fif, Data_MEG/RawData_MEG_memento/memento_005/Raw/3_memento_005_DK76-1.fif
Opening raw data file Data_MEG/RawData_MEG_memento/memento_005/Raw/1_memento_005_dk76.fif...
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/orig/restructure.py:86: RuntimeWarning: This filename (Data_MEG/RawData_MEG_memento/memento_005/Raw/1_memento_005_dk76.fif) does not conform to MNE naming conventions. All raw files should end with raw.fif, raw_sss.fif, raw_tsss.fif, raw.fif.gz, raw_sss.fif.gz, raw_tsss.fif.gz or _meg.fif
  split1 = mne.io.read_raw_fif(first, on_split_missing="warn")
    Read a total of 8 projection items:
        grad_68.fif : PCA-v1 (1 x 306)  idle
        grad_68.fif : PCA-v2 (1 x 306)  idle
        grad_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v1 (1 x 306)  idle
        mag_68.fif : PCA-v2 (1 x 306)  idle
        mag_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v4 (1 x 306)  idle
        mag_68.fif : PCA-v5 (1 x 306)  idle
    Range : 863000 ... 1633999 =    863.000 ...  1633.999 secs
Ready.
Opening raw data file Data_MEG/RawData_MEG_memento/memento_005/Raw/2_memento_005_DK76.fif...
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/orig/restructure.py:87: RuntimeWarning: This filename (Data_MEG/RawData_MEG_memento/memento_005/Raw/2_memento_005_DK76.fif) does not conform to MNE naming conventions. All raw files should end with raw.fif, raw_sss.fif, raw_tsss.fif, raw.fif.gz, raw_sss.fif.gz, raw_tsss.fif.gz or _meg.fif
  split2 = mne.io.read_raw_fif(second, on_split_missing="warn")
    Read a total of 8 projection items:
        grad_68.fif : PCA-v1 (1 x 306)  idle
        grad_68.fif : PCA-v2 (1 x 306)  idle
        grad_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v1 (1 x 306)  idle
        mag_68.fif : PCA-v2 (1 x 306)  idle
        mag_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v4 (1 x 306)  idle
        mag_68.fif : PCA-v5 (1 x 306)  idle
    Range : 41000 ... 1637999 =     41.000 ...  1637.999 secs
Ready.
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/orig/restructure.py:87: RuntimeWarning: Split raw file detected but next file /media/adina/elements/backup/memento/data/DMS_MEMENTO/Data_MEG/RawData_MEG_memento/memento_005/Raw/2_memento_005_DK76-1.fif does not exist. Ensure all files were transferred properly and that split and original files were not manually renamed on disk (split files should be renamed by loading and re-saving with MNE-Python to preserve proper filename linkage).
  split2 = mne.io.read_raw_fif(second, on_split_missing="warn")
Opening raw data file Data_MEG/RawData_MEG_memento/memento_005/Raw/3_memento_005_DK76-1.fif...
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/orig/restructure.py:88: RuntimeWarning: This filename (Data_MEG/RawData_MEG_memento/memento_005/Raw/3_memento_005_DK76-1.fif) does not conform to MNE naming conventions. All raw files should end with raw.fif, raw_sss.fif, raw_tsss.fif, raw.fif.gz, raw_sss.fif.gz, raw_tsss.fif.gz or _meg.fif
  split3 = mne.io.read_raw_fif(third, on_split_missing="warn")
    Read a total of 8 projection items:
        grad_68.fif : PCA-v1 (1 x 306)  idle
        grad_68.fif : PCA-v2 (1 x 306)  idle
        grad_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v1 (1 x 306)  idle
        mag_68.fif : PCA-v2 (1 x 306)  idle
        mag_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v4 (1 x 306)  idle
        mag_68.fif : PCA-v5 (1 x 306)  idle
    Range : 1638000 ... 3096999 =   1638.000 ...  3096.999 secs
Ready.
/tmp/anotherbids/sub-005
Saving BIDS-compliant raw data from subject 005 into /tmp/anotherbids/sub-005/meg/sub-005_task-memento_meg.fif
Trigger channel has a non-zero initial value of 32768 (consider using initial_event=True to detect this event)
3562 events found
Event IDs: [    2    10    12    13    14    15    16    17    18    19    20    22
    25    26    27    28 32771 32790 32792]
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/utils.py:108: RuntimeWarning: 1 from event_id is not present in events.
  fig = mne.viz.plot_events(
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/utils.py:108: RuntimeWarning: 4 from event_id is not present in events.
  fig = mne.viz.plot_events(
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/utils.py:108: RuntimeWarning: 5 from event_id is not present in events.
  fig = mne.viz.plot_events(
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/utils.py:108: RuntimeWarning: 21 from event_id is not present in events.
  fig = mne.viz.plot_events(
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/utils.py:108: RuntimeWarning: 24 from event_id is not present in events.
  fig = mne.viz.plot_events(
/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/utils.py:108: RuntimeWarning: More events than default colors available. You should pass a list of unique colors.
  fig = mne.viz.plot_events(
/tmp/anotherbidsfigs/sub-005/meg
Opening raw data file Data_MEG/RawData_MEG_memento/memento_005/Raw/1_memento_005_dk76.fif...
/home/adina/repos/mne-bids/mne_bids/write.py:1148: RuntimeWarning: This filename (Data_MEG/RawData_MEG_memento/memento_005/Raw/1_memento_005_dk76.fif) does not conform to MNE naming conventions. All raw files should end with raw.fif, raw_sss.fif, raw_tsss.fif, raw.fif.gz, raw_sss.fif.gz, raw_tsss.fif.gz or _meg.fif
  raw_orig = reader[ext](**raw._init_kwargs)
    Read a total of 8 projection items:
        grad_68.fif : PCA-v1 (1 x 306)  idle
        grad_68.fif : PCA-v2 (1 x 306)  idle
        grad_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v1 (1 x 306)  idle
        mag_68.fif : PCA-v2 (1 x 306)  idle
        mag_68.fif : PCA-v3 (1 x 306)  idle
        mag_68.fif : PCA-v4 (1 x 306)  idle
        mag_68.fif : PCA-v5 (1 x 306)  idle
    Range : 863000 ... 1633999 =    863.000 ...  1633.999 secs
Ready.
Traceback (most recent call last):
  File "/home/adina/env/magma/bin/pymento-restructure", line 33, in <module>
    sys.exit(load_entry_point('pymento-meg', 'console_scripts', 'pymento-restructure')())
  File "/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/cli.py", line 194, in restructure
    restructure_to_bids(rawdir=args.raw_data_dir,
  File "/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/pymento.py", line 40, in restructure_to_bids
    raw = read_data_original(directory=rawdir,
  File "/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/orig/restructure.py", line 101, in read_data_original
    save_bids_data(raw=raw,
  File "/home/adina/repos/PhD/memento-code/code/pymento/pymento_meg/orig/restructure.py", line 135, in save_bids_data
    write_raw_bids(raw, bids_path, events_data=events_data,
  File "/home/adina/repos/mne-bids/mne_bids/write.py", line 1149, in write_raw_bids
    assert_array_equal(raw.times, raw_orig.times,
  File "/home/adina/env/magma/lib/python3.9/site-packages/numpy/testing/_private/utils.py", line 932, in assert_array_equal
    assert_array_compare(operator.__eq__, x, y, err_msg=err_msg,
  File "/home/adina/env/magma/lib/python3.9/site-packages/numpy/testing/_private/utils.py", line 761, in assert_array_compare
    raise AssertionError(msg)
AssertionError: 
Arrays are not equal
raw.times should not have changed since reading in from the file. It may have been cropped.
(shapes (3827000,), (771000,) mismatch)
 x: array([0.000000e+00, 1.000000e-03, 2.000000e-03, ..., 3.826997e+03,
       3.826998e+03, 3.826999e+03])
 y: array([0.00000e+00, 1.00000e-03, 2.00000e-03, ..., 7.70997e+02,
       7.70998e+02, 7.70999e+02])
pymento-restructure --subject '005' --raw_data_dir  --bids_dir  --calfiles_di  3.99s user 1.78s system 146% cpu 3.941 total
```
</details>


Discussion:
Hello @adswa,

thank you for this extensive report!

I've managed to reproduce the problem via our `sample` dataset:
```python
# %%
from pathlib import Path

import mne
from mne.datasets import sample

from mne_bids import write_raw_bids,  BIDSPath

data_path = Path(sample.data_path())
raw_fname = data_path / 'MEG' /  'sample' / 'sample_audvis_raw.fif'
output_path = data_path / '..' /'MNE-sample-data-bids'
bids_path = BIDSPath(subject='01',  task='audiovisual', root=output_path)

raw = mne.io.read_raw_fif(raw_fname)
raw.info['line_freq'] = 60
raw_concat = mne.concatenate_raws([raw.copy(), raw])

# %%
# Fails
write_raw_bids(raw=raw_concat, bids_path=bids_path, overwrite=True)
```

Here is a temporary workaround: save the concatenated `Raw` to a temporary file, and write this file via `write_raw_bids()`:
```python

# %%
# Workaround: save to a temporary file before writing to BIDS
from tempfile import NamedTemporaryFile

with NamedTemporaryFile(suffix='_raw.fif') as f:
    fname = f.name
    raw_concat.save(fname, overwrite=True)
    raw_concat = mne.io.read_raw_fif(fname, preload=False)
    write_raw_bids(raw=raw_concat, bids_path=bids_path, overwrite=True)
```

Personally I feel the restriction you're hitting should simply be removed … But it needs a bit of thinking. Not sure what the best solution would be.
Thanks for the quick response and temporary workaround, @hoechenberger! At the moment I have indeed simply removed this check in an editable install of mne-bids.

> Personally I feel the restriction you're hitting should simply be removed … 

I agree. Maybe, as middle ground between keeping it as is and removing it fully, it could also be sensible to convert the assert into try/except clause and issue a warning to user?
In any case, I'd be happy to contribute with a PR once a consensus is reached. :)

Resolving patch:
diff --git a/examples/write_modified_files.py b/examples/write_modified_files.py
new file mode 100644
--- /dev/null
+++ b/examples/write_modified_files.py
@@ -0,0 +1,77 @@
+"""
+========================================
+13. Writing modified files with MNE-BIDS
+========================================
+
+MNE-BIDS is designed such that it enforces good practices when working with
+BIDS data. One of the principles of creating BIDS datasets from raw data is
+that the raw data should ideally be written unmodified, as-is. To enforce
+this, :func:`mne_bids.write_raw_bids` performs some basic checks and will
+throw an exception if it believes you're doing something that you really
+shouldn't be doing (i.e., trying to store modified "raw" data as a BIDS
+raw data set.)
+
+There might be some – rare! – situations, however, when working around this
+intentional limitation in MNE-BIDS can be warranted. For example, you might
+encounter data that has manually been split across multiple files during
+recording, even though it belongs to a single experimental run. In this case,
+you might want to concatenate the data before storing them in BIDS. This
+tutorial will give you an example on how to use :func:`mne_bids.write_raw_bids`
+to store such data, despite it being modified before writing.
+
+.. warning:: Please be aware that the situations in which you would need
+             to apply the following solution are **extremely** rare. If you
+             ever find yourself wanting to apply this solution, please take a
+             step back, take a deep breath and re-consider whether this is
+             **absolutely** necessary. If even a slight doubt remains,
+             reach out to the MNE-BIDS developers.
+
+"""
+
+# Authors: Richard Höchenberger <richard.hoechenberger@gmail.com>
+# License: BSD (3-clause)
+
+###############################################################################
+# Load the ``sample`` dataset, and create a concatenated raw data object.
+
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+
+import mne
+from mne.datasets import sample
+
+from mne_bids import write_raw_bids, BIDSPath
+
+
+data_path = Path(sample.data_path())
+raw_fname = data_path / 'MEG' / 'sample' / 'sample_audvis_raw.fif'
+output_path = data_path / '..' / 'MNE-sample-data-bids'
+bids_path = BIDSPath(subject='01', task='audiovisual', root=output_path)
+
+raw = mne.io.read_raw_fif(raw_fname)
+raw.info['line_freq'] = 60
+raw_concat = mne.concatenate_raws([raw.copy(), raw])
+
+###############################################################################
+# Trying to write these data will fail.
+
+try:
+    write_raw_bids(raw=raw_concat, bids_path=bids_path, overwrite=True)
+except ValueError as e:
+    print(f'Data cannot be written. Exception message was: {e}')
+
+###############################################################################
+# We can work around this limitation by first writing the modified data to
+# a temporary file, reading it back in, and then writing it via MNE-BIDS.
+
+with NamedTemporaryFile(suffix='_raw.fif') as f:
+    fname = f.name
+    raw_concat.save(fname, overwrite=True)
+    raw_concat = mne.io.read_raw_fif(fname, preload=False)
+    write_raw_bids(raw=raw_concat, bids_path=bids_path, overwrite=True)
+
+###############################################################################
+# That's it!
+#
+# .. warning:: **Remember, this should only ever be a last resort!**
+#
diff --git a/mne_bids/write.py b/mne_bids/write.py
--- a/mne_bids/write.py
+++ b/mne_bids/write.py
@@ -15,7 +15,6 @@
 from collections import defaultdict, OrderedDict
 
 import numpy as np
-from numpy.testing import assert_array_equal
 from scipy import linalg
 import mne
 from mne.transforms import (_get_trans, apply_trans, rotation, translation)
@@ -1146,12 +1145,25 @@ def write_raw_bids(raw, bids_path, events_data=None,
         raise ValueError(f'Unrecognized file format {ext}')
 
     raw_orig = reader[ext](**raw._init_kwargs)
-    assert_array_equal(raw.times, raw_orig.times,
-                       "raw.times should not have changed since reading"
-                       " in from the file. It may have been cropped.")
+    if not np.array_equal(raw.times, raw_orig.times):
+        if len(raw.times) == len(raw_orig.times):
+            msg = ("raw.times has changed since reading from disk, but "
+                   "write_raw_bids() doesn't allow writing modified data.")
+        else:
+            msg = ("The raw data you want to write contains {comp} time "
+                   "points than the raw data on disk. It is possible that you "
+                   "{guess} your data, which write_raw_bids() won't accept.")
+            if len(raw.times) < len(raw_orig.times):
+                msg = msg.format(comp='fewer', guess='cropped')
+            elif len(raw.times) > len(raw_orig.times):
+                msg = msg.format(comp='more', guess='concatenated')
+
+        msg += (' If you believe you have a valid use case that should be '
+                'supported, please reach out to the developers at '
+                'https://github.com/mne-tools/mne-bids/issues')
+        raise ValueError(msg)
 
     datatype = _handle_datatype(raw)
-
     bids_path = bids_path.copy()
     bids_path = bids_path.update(
         datatype=datatype, suffix=datatype, extension=ext)


Test patch:
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
--- a/.github/workflows/unit_tests.yml
+++ b/.github/workflows/unit_tests.yml
@@ -115,7 +115,7 @@ jobs:
     - name: Install MNE (stable)
       if: "matrix.os != 'ubuntu-latest'"
       run: |
-        git clone --depth 1 https://github.com/mne-tools/mne-python.git -b maint/0.21
+        git clone --depth 1 https://github.com/mne-tools/mne-python.git -b maint/0.22
         pip install --no-deps -e ./mne-python
     - name: Install MNE (main)
       if: "matrix.os == 'ubuntu-latest'"
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -19,6 +19,7 @@
 import json
 from pathlib import Path
 import codecs
+from distutils.version import LooseVersion
 
 import numpy as np
 from numpy.testing import assert_array_equal, assert_array_almost_equal
@@ -1317,11 +1318,24 @@ def test_bdf(_bids_validate):
     with pytest.raises(TypeError, match="unexpected keyword argument 'foo'"):
         read_raw_bids(bids_path=bids_path, extra_params=dict(foo='bar'))
 
-    # Test cropped assertion error
+    # Test errors for modified raw.times
     raw = _read_raw_bdf(raw_fname)
-    raw.crop(0, raw.times[-2])
-    with pytest.raises(AssertionError, match='cropped'):
-        write_raw_bids(raw, bids_path)
+
+    with pytest.raises(ValueError, match='fewer time points'):
+        write_raw_bids(raw.copy().crop(0, raw.times[-2]), bids_path,
+                       overwrite=True)
+
+    with pytest.raises(ValueError, match='more time points'):
+        write_raw_bids(mne.concatenate_raws([raw.copy(), raw]), bids_path,
+                       overwrite=True)
+
+    if LooseVersion(mne.__version__) >= LooseVersion('0.23'):
+        raw.info['sfreq'] -= 10  # changes raw.times, but retains its dimension
+    else:
+        raw._times = raw._times / 5
+
+    with pytest.raises(ValueError, match='raw.times has changed'):
+        write_raw_bids(raw, bids_path, overwrite=True)
 
     # test anonymize and convert
     raw = _read_raw_bdf(raw_fname)