diff --git a/doc/changes/dev/14216.bugfix.rst b/doc/changes/dev/14216.bugfix.rst new file mode 100644 index 00000000000..da8a39acfaa --- /dev/null +++ b/doc/changes/dev/14216.bugfix.rst @@ -0,0 +1 @@ +Fix bug where :func:`mne.io.read_raw_ant` and :func:`mne.io.read_raw_curry` ignored projections and non-boolean ``preload`` values respectively when reading data lazily, by `Bruno Aristimunha`_. diff --git a/doc/changes/dev/14216.newfeature.rst b/doc/changes/dev/14216.newfeature.rst new file mode 100644 index 00000000000..1a69f50d896 --- /dev/null +++ b/doc/changes/dev/14216.newfeature.rst @@ -0,0 +1 @@ +Speed up repeated preloading of any file-backed :class:`~mne.io.Raw` with ``preload="auto"``, which persists decoded data in a copy-on-write cache below :func:`mne.set_cache_dir`, by `Bruno Aristimunha`_. diff --git a/mne/channels/channels.py b/mne/channels/channels.py index d0e9ae73d6e..6262208b1ba 100644 --- a/mne/channels/channels.py +++ b/mne/channels/channels.py @@ -748,6 +748,7 @@ def add_channels(self, add_list, force_update_info=False): # Now update the attributes if ( isinstance(self._data, np.memmap) + and self._data.mode != "c" and con_axis == 0 and sys.platform != "darwin" ): # resizing not available--no mremap diff --git a/mne/io/_preload_cache.py b/mne/io/_preload_cache.py new file mode 100644 index 00000000000..4500d75c10e --- /dev/null +++ b/mne/io/_preload_cache.py @@ -0,0 +1,114 @@ +"""Persistent decoded-data cache for Raw readers.""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import hashlib +import os +import pickle +from pathlib import Path + +import numpy as np + +from .. import __version__ as MNE_VERSION # ty: ignore[unresolved-import] +from ..utils import get_config, logger + +_RAW_PRELOAD_CACHE_VERSION = 1 + + +def _raw_preload_cache_info(raw): + """Return the cache path and decoded array description.""" + cache_root = get_config("MNE_CACHE_DIR", None) + if cache_root is None: + raise ValueError( + 'preload="auto" requires a configured cache directory; use ' + "mne.set_cache_dir(...) first" + ) + cache_dir = Path(cache_root).expanduser().resolve() + cache_dir = cache_dir / f"raw-preload-v{_RAW_PRELOAD_CACHE_VERSION}" + cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + + sources = [] + for filename in raw.filenames: + if filename is None: + raise ValueError( + 'preload="auto" requires stable source files; use preload=True ' + "or an explicit memory-map path" + ) + path = Path(filename).resolve(strict=True) + # some formats (e.g., CTF) name a directory rather than a single file + members = sorted(path.rglob("*")) if path.is_dir() else [path] + for member in members: + if not member.is_file(): + continue + result = member.stat() + sources.append((str(member), int(result.st_size), int(result.st_mtime_ns))) + + dtype = np.dtype(raw._dtype) + shape = (int(raw.info["nchan"]), int(raw.n_times)) + identity = ( + _RAW_PRELOAD_CACHE_VERSION, + MNE_VERSION, + type(raw).__module__, + type(raw).__qualname__, + sources, + raw._raw_extras, + raw._cals, + dtype.str, + shape, + ) + try: + key = hashlib.sha256(pickle.dumps(identity, protocol=5)).hexdigest() + except Exception as exc: + raise ValueError( + f'preload="auto" cannot identify this {type(raw).__name__} source' + ) from exc + return cache_dir / f"{key}.data", sources, shape, dtype + + +def _raw_preload_cache_read(path, shape, dtype): + """Map a complete decoded-data cache entry.""" + try: + nbytes = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize + if path.stat().st_size != nbytes: + return None + return np.memmap(path, mode="c", dtype=dtype, shape=shape) + except OSError: + return None + + +def _raw_preload_auto(raw): + """Reuse or create an automatic decoded-data cache entry.""" + path, sources, shape, dtype = _raw_preload_cache_info(raw) + data = _raw_preload_cache_read(path, shape, dtype) + if data is not None: + logger.info(f"Reusing decoded data from {path}") + return data + + # The temporary is per-process and os.replace is atomic, so concurrent + # misses need no lock; they at worst decode the same entry twice. + logger.info(f"Creating decoded data cache in {path.parent}") + temporary = path.with_suffix(f".{os.getpid()}.tmp") + try: + data = np.memmap(temporary, mode="w+", dtype=dtype, shape=shape) + try: + raw._read_segment(data_buffer=data) + data.flush() + finally: + data._mmap.close() # ty: ignore[unresolved-attribute] + if _raw_preload_cache_info(raw)[1] != sources: + raise RuntimeError( + "Source data changed while decoded cache was created; retry" + ) + try: + os.replace(temporary, path) + except OSError: + # Windows refuses to replace an entry another process already mapped. + pass + finally: + temporary.unlink(missing_ok=True) + data = _raw_preload_cache_read(path, shape, dtype) + if data is None: + raise RuntimeError(f"Could not read back the decoded data cache at {path}") + return data diff --git a/mne/io/ant/ant.py b/mne/io/ant/ant.py index 8d42ec84e4d..eeab827f31c 100644 --- a/mne/io/ant/ant.py +++ b/mne/io/ant/ant.py @@ -11,6 +11,7 @@ from ..._fiff.constants import FIFF from ..._fiff.meas_info import create_info +from ..._fiff.utils import _mult_cal_one from ...annotations import Annotations from ...utils import ( _check_fname, @@ -185,11 +186,7 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult): one = read_data(cnt, i_start, i_stop) _scale_data(one, ch_units) data_view = data[:, i_start - start : i_stop - start] - if isinstance(idx, slice): - data_view[:] = one[idx] - else: - # faster than doing one = one[idx] - np.take(one, idx, axis=0, out=data_view) + _mult_cal_one(data_view, one, idx, cals, mult) def _handle_bipolar_channels( diff --git a/mne/io/ant/tests/test_ant.py b/mne/io/ant/tests/test_ant.py index 6a39469d929..a6fdc2d8006 100644 --- a/mne/io/ant/tests/test_ant.py +++ b/mne/io/ant/tests/test_ant.py @@ -14,6 +14,7 @@ from mne.datasets import testing from mne.io import BaseRaw, read_raw, read_raw_ant, read_raw_brainvision from mne.io.ant.ant import RawANT +from mne.io.tests.test_raw import _test_raw_reader pytest.importorskip("antio", minversion="0.5.0") data_path = testing.data_path(download=False) / "antio" @@ -428,6 +429,12 @@ def test_annotations_and_preload(ca_208: TypeDataset): assert raw_cnt.annotations.description[0] == "impedance" +@testing.requires_testing_data +def test_ant_raw_reader(ca_208: TypeDataset): + """Test the generic reader checks, including projected lazy reads.""" + _test_raw_reader(read_raw_ant, fname=ca_208["cnt"]["short"]) + + @testing.requires_testing_data def test_read_raw(ca_208: TypeDataset): """Test loading through read_raw.""" diff --git a/mne/io/base.py b/mne/io/base.py index 7d41bb20be6..587c9c084df 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -103,6 +103,7 @@ warn, ) from ..utils._typing import Color, Self +from ._preload_cache import _raw_preload_auto if TYPE_CHECKING: # Heavy/optional deps kept out of the runtime import path (see @@ -143,8 +144,11 @@ class BaseRaw( freshly created memory-mapped file used to store the data on the hard drive (slower, requires less memory). An existing file is overwritten. The caller owns the file and is responsible for removing it after the - Raw object is no longer in use. If preload is an ndarray, the data are - taken from that array. If False, data are not read until save. + Raw object is no longer in use. For supported file readers, the exact + string ``"auto"`` instead reuses decoded data below the directory + configured by :func:`mne.set_cache_dir`. Use ``Path("auto")`` for a + literal filename. If preload is an ndarray, the data are taken from that + array. If False, data are not read until save. first_samps : sequence Sequence of the first sample number from each raw file. For unsplit raw files this should be a length-one list or tuple. @@ -601,11 +605,14 @@ def load_data( Parameters ---------- - memmap : path-like | None + memmap : path-like | str | None If not ``None``, preload data into a freshly created memory-mapped file at this path. An existing file is overwritten. The caller owns the file and is responsible for removing it after the Raw object is no longer in - use. If ``None`` (default), preload data into RAM. + use. The exact string ``"auto"`` instead means the same as + ``preload="auto"``: reuse decoded data below the directory configured by + :func:`mne.set_cache_dir`. Use ``Path("auto")`` for a literal filename. + If ``None`` (default), preload data into RAM. .. versionadded:: 1.13 %(verbose)s @@ -623,13 +630,23 @@ def load_data( .. versionadded:: 0.10.0 """ if not self.preload: - if memmap is not None: + if isinstance(memmap, str) and memmap == "auto": + pass # sentinel, resolved in _preload_data + elif memmap is not None: _validate_type(memmap, "path-like", "memmap") + memmap = Path(memmap) self._preload_data(memmap if memmap is not None else True) return self def _preload_data(self, preload): """Actually preload the data.""" + if isinstance(preload, str) and preload == "auto": + self._data = _raw_preload_auto(self) + assert len(self._data) == self.info["nchan"] + self.preload = True + self._comp = None + self.close() + return data_buffer = preload if isinstance(preload, bool | np.bool_) and not preload: data_buffer = None @@ -793,17 +810,18 @@ def set_annotations( "of the raw object." ) - delta = 1.0 / self.info["sfreq"] + # This is algebraically ``self.times[-1] + 1 / sfreq`` without + # allocating the full time vector for large file-backed recordings. + sfreq = self.info["sfreq"] + annotation_end = (self.n_times - 1) / sfreq + 1.0 / sfreq new_annotations = annotations.copy() new_annotations._prune_ch_names(self.info, on_missing) if annotations.orig_time is None: - new_annotations.crop( - 0, self.times[-1] + delta, emit_warning=emit_warning - ) + new_annotations.crop(0, annotation_end, emit_warning=emit_warning) new_annotations.onset += self._first_time else: tmin = meas_date + timedelta(0, self._first_time) - tmax = tmin + timedelta(seconds=self.times[-1] + delta) + tmax = tmin + timedelta(seconds=annotation_end) new_annotations.crop(tmin=tmin, tmax=tmax, emit_warning=emit_warning) new_annotations.onset -= ( meas_date - new_annotations.orig_time diff --git a/mne/io/curry/curry.py b/mne/io/curry/curry.py index 50b87381f8e..c39471694db 100644 --- a/mne/io/curry/curry.py +++ b/mne/io/curry/curry.py @@ -807,8 +807,9 @@ def __init__(self, fname, preload=False, on_bad_hpi_match="warn", verbose=None): # scale data to SI units self._cals = np.array(cals) - if isinstance(preload, bool | np.bool_) and preload: - self.load_data() + if not isinstance(preload, bool | np.bool_) or preload: + # preload can also be a memory-map path or the "auto" sentinel + self._preload_data(preload) # set events / annotations # format from curryreader: sample, etype, startsample, endsample diff --git a/mne/io/fiff/raw.py b/mne/io/fiff/raw.py index 95c6db5dbec..93cdaecc312 100644 --- a/mne/io/fiff/raw.py +++ b/mne/io/fiff/raw.py @@ -98,6 +98,12 @@ def __init__( on_split_missing: str = "raise", verbose: bool | str | int | None = None, ): + if isinstance(preload, str) and preload == "auto": + if _file_like(fname): + raise ValueError( + 'preload="auto" requires stable source files and is not ' + "supported for file-like FIF inputs" + ) raws = [] do_check_ext = not _file_like(fname) next_fname = fname @@ -198,7 +204,9 @@ def _read_raw_file( check_fname(fname, "raw", endings) # filename fname = _check_fname(fname, "read", True, "fname") - whole_file = preload if fname.suffix == ".gz" else False + whole_file = ( + preload if preload != "auto" and fname.suffix == ".gz" else False + ) else: # file-like if not preload: diff --git a/mne/io/fiff/tests/test_raw_fiff.py b/mne/io/fiff/tests/test_raw_fiff.py index c996f187015..201ccd1afbd 100644 --- a/mne/io/fiff/tests/test_raw_fiff.py +++ b/mne/io/fiff/tests/test_raw_fiff.py @@ -2120,6 +2120,15 @@ def test_file_like(kind, preload, split, tmp_path): assert file_fid.closed +def test_file_like_auto_preload_rejected(tmp_path, monkeypatch): + """Test that automatic caching cannot misidentify a named stream.""" + monkeypatch.setenv("MNE_CACHE_DIR", str(tmp_path)) + stream = BytesIO(test_fif_fname.read_bytes()) + stream.name = str(test_fif_fname) + with pytest.raises(ValueError, match="stable source files"): + read_raw_fif(stream, preload="auto") + + def test_str_like(): """Test handling with str-like objects.""" fname = pathlib.Path(test_fif_fname) diff --git a/mne/io/fil/tests/test_fil.py b/mne/io/fil/tests/test_fil.py index af1a63303dd..1ff3692620c 100644 --- a/mne/io/fil/tests/test_fil.py +++ b/mne/io/fil/tests/test_fil.py @@ -13,6 +13,7 @@ from mne.datasets import testing from mne.io import read_raw_fil from mne.io.fil.sensors import _get_pos_units +from mne.io.tests.test_raw import _test_raw_reader from mne.utils import copytree_rw fil_path = testing.data_path(download=False) / "FIL" @@ -155,6 +156,7 @@ def test_fil_complete(): _fil_megmag(raw, mat) _fil_stim(raw, mat) _fil_sensorpos(raw, mat) + _test_raw_reader(read_raw_fil, binfile=binname) @testing.requires_testing_data diff --git a/mne/io/tests/test_preload_cache.py b/mne/io/tests/test_preload_cache.py new file mode 100644 index 00000000000..e87311cb7fe --- /dev/null +++ b/mne/io/tests/test_preload_cache.py @@ -0,0 +1,150 @@ +"""Tests for persistent Raw preload caching.""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import gc +import hashlib +import os +import shutil +from concurrent.futures import ProcessPoolExecutor +from contextlib import chdir +from pathlib import Path + +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +import mne +from mne._fiff.pick import pick_info +from mne.io import RawArray, _preload_cache +from mne.io.tests.test_raw import _read_raw_arange + +_IO_DATA_DIR = Path(mne.io.__file__).parent + + +def _auto_preload_process(reader_name, source, cache_dir): + """Read one cache entry in an isolated process.""" + os.environ["MNE_CACHE_DIR"] = cache_dir + raw = getattr(mne.io, reader_name)(source, preload="auto", verbose="error") + digest = hashlib.sha256(raw.get_data().tobytes()).hexdigest() + return raw._data.mode, str(raw._data.filename), digest + + +@pytest.fixture +def cache_root(tmp_path, monkeypatch): + """Configure an isolated cache directory.""" + cache_root = tmp_path / "cache" + cache_root.mkdir() + monkeypatch.setenv("MNE_CACHE_DIR", str(cache_root)) + return cache_root + + +def test_auto_preload_api(tmp_path, monkeypatch): + """Test cache configuration and the literal-path escape.""" + source = _IO_DATA_DIR / "edf/tests/data/test.edf" + monkeypatch.setattr(_preload_cache, "get_config", lambda *args, **kwargs: None) + with pytest.raises(ValueError, match="set_cache_dir"): + mne.io.read_raw_edf(source, preload="auto", verbose="error") + + with chdir(tmp_path): + literal = mne.io.read_raw_edf(source, preload=Path("auto"), verbose="error") + assert literal._data.mode == "w+" + assert (tmp_path / "auto").is_file() + + # load_data(memmap="auto") resolves the same sentinel as preload="auto" + lazy = mne.io.read_raw_edf(source, preload=False, verbose="error") + with pytest.raises(ValueError, match="set_cache_dir"): + lazy.load_data(memmap="auto") + + +@pytest.mark.parametrize("fname", ("test_raw.fif", "test_raw.fif.gz")) +def test_auto_preload_fif(fname, cache_root): + """Test cache reuse for FIF, whose reader tests skip test_preloading.""" + source = _IO_DATA_DIR / "tests/data" / fname + expected = mne.io.read_raw_fif(source, preload=True, verbose="error").get_data() + raw = mne.io.read_raw_fif(source, preload="auto", verbose="error") + generation = Path(raw._data.filename) + assert raw._data.mode == "c" + assert_array_equal(raw.get_data(), expected) + del raw + gc.collect() + + other = mne.io.read_raw_fif(source, preload="auto", verbose="error") + assert Path(other._data.filename) == generation + assert_array_equal(other.get_data(), expected) + + +def test_auto_preload_identity(tmp_path, cache_root): + """Test reader options and source modification invalidate the cache.""" + data_dir = _IO_DATA_DIR / "brainvision/tests/data" + for name in ("test.vhdr", "test.vmrk", "test.eeg"): + shutil.copy(data_dir / name, tmp_path / name) + source = tmp_path / "test.vhdr" + raw = mne.io.read_raw_brainvision(source, preload="auto", verbose="error") + scaled = mne.io.read_raw_brainvision( + source, scale=2.0, preload="auto", verbose="error" + ) + assert Path(scaled._data.filename) != Path(raw._data.filename) + assert_array_equal(scaled.get_data(), 2.0 * raw.get_data()) + + source = tmp_path / "copy.edf" + shutil.copy(_IO_DATA_DIR / "edf/tests/data/test.edf", source) + raw = mne.io.read_raw_edf(source, preload="auto", verbose="error") + generation = Path(raw._data.filename) + result = source.stat() + os.utime(source, ns=(result.st_atime_ns, result.st_mtime_ns + 1_000_000_000)) + other = mne.io.read_raw_edf(source, preload="auto", verbose="error") + assert Path(other._data.filename) != generation + + +def test_auto_preload_recovers_corruption(cache_root): + """Test that a truncated deterministic cache entry is rebuilt.""" + source = _IO_DATA_DIR / "edf/tests/data/test.edf" + raw = mne.io.read_raw_edf(source, preload="auto", verbose="error") + expected = raw.get_data().copy() + generation = Path(raw._data.filename) + del raw + gc.collect() + generation.write_bytes(b"short") + + other = mne.io.read_raw_edf(source, preload="auto", verbose="error") + assert Path(other._data.filename) == generation + assert_array_equal(other.get_data(), expected) + + +def test_auto_preload_concurrent_misses(cache_root): + """Test that concurrent misses publish one exact cache entry.""" + source = _IO_DATA_DIR / "edf/tests/data/test.edf" + args = ("read_raw_edf", str(source), str(cache_root)) + with ProcessPoolExecutor(max_workers=4) as pool: + results = list(pool.map(_auto_preload_process, *zip(*(args,) * 4))) + + assert {result[0] for result in results} == {"c"} + assert len({result[1] for result in results}) == 1 + assert len({result[2] for result in results}) == 1 + + +def test_add_channels_copy_on_write_memmap(tmp_path, monkeypatch): + """Test adding channels to a copy-on-write memmap.""" + from mne.channels import channels as channels_module + + memmap_fname = tmp_path / "raw-copy-on-write-memmap.dat" + raw = _read_raw_arange(preload=memmap_fname) + shape = raw._data.shape + raw._data._mmap.close() + raw._data = np.memmap(memmap_fname, mode="c", dtype=np.float64, shape=shape) + raw._data[0, 0] = 99.0 + + info = pick_info(raw.info, [0]) + mne.rename_channels(info, {info["ch_names"][0]: "extra"}) + extra = RawArray(np.zeros((1, raw.n_times)), info) + monkeypatch.setattr(channels_module.sys, "platform", "linux") + raw.add_channels([extra]) + + assert raw._data.shape == (shape[0] + 1, shape[1]) + assert raw._data[0, 0] == 99.0 + stored = np.memmap(memmap_fname, mode="r", dtype=np.float64, shape=shape) + assert stored[0, 0] != 99.0 + stored._mmap.close() diff --git a/mne/io/tests/test_raw.py b/mne/io/tests/test_raw.py index 0f4b47d6e80..b6593046df8 100644 --- a/mne/io/tests/test_raw.py +++ b/mne/io/tests/test_raw.py @@ -6,11 +6,13 @@ import gc import math +import os import re from contextlib import chdir, redirect_stdout from io import StringIO from os import path as op from pathlib import Path +from unittest import mock import numpy as np import pytest @@ -48,6 +50,10 @@ ) +def _fail_if_times_materialized(*args, **kwargs): + pytest.fail("The full Raw.times vector was materialized") + + def assert_named_constants(info): """Assert that info['chs'] has named constants.""" # for now we just check one @@ -99,6 +105,15 @@ def test_orig_units(): BaseRaw(info, last_samps=[1], orig_units=True) +def test_set_annotations_does_not_materialize_times(monkeypatch): + """Test annotation bounds use the scalar recording endpoint.""" + raw = read_raw_fif(raw_fname, preload=False, verbose="error") + annotations = Annotations([0.0], [0.1], ["test"]) + monkeypatch.setattr("mne.io.base._arange_div", _fail_if_times_materialized) + raw.set_annotations(annotations) + assert len(raw.annotations) == 1 + + def _test_raw_reader( reader, test_preloading=True, @@ -169,6 +184,22 @@ def _test_raw_reader( assert_allclose(data1, data2, err_msg="Data mismatch with preload") assert_allclose(times1, times2) + # preload="auto" decodes once into a reusable cache entry (gh-14216) + if None not in raw.filenames: # e.g. RawArray has no source file + with mock.patch.dict(os.environ, {"MNE_CACHE_DIR": tempdir}): + entries = set() + for _ in range(2): # miss, then hit + auto = reader(preload="auto", **kwargs) + assert_allclose(auto[picks, :][0], raw[picks, :][0]) + # readers that hand BaseRaw an in-memory array (e.g. EEGLAB + # with embedded data) never reach the cache + if isinstance(auto._data, np.memmap): + assert auto._data.mode == "c" + entries.add(str(auto._data.filename)) + del auto + gc.collect() + assert len(entries) in (0, 1) + # test projection vs cals and data units other_raw = reader(preload=False, **kwargs) other_raw.del_proj() @@ -466,6 +497,7 @@ def _test_raw_reader( "pdf_fname", # BTi "directory", # CTF "filename", # nedf + "binfile", # FIL ): try: fname = kwargs[key] diff --git a/mne/utils/config.py b/mne/utils/config.py index ba0cb50af9d..5664b00c5ce 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -40,17 +40,20 @@ class UnknownPlatformError(Exception): def set_cache_dir(cache_dir): - """Set the directory to be used for temporary file storage. + """Set the directory used for temporary and managed cache storage. - This directory is used by joblib to store memmapped arrays, - which reduces memory requirements and speeds up parallel - computation. + This directory is used by joblib to store temporary memmapped arrays and, + when requested by supported Raw readers, to persist decoded preload data. Parameters ---------- cache_dir : str or None - Directory to use for temporary file storage. None disables - temporary file storage. + Directory to use for cache storage. None disables cache storage. + + Notes + ----- + Persistent decoded Raw entries are not automatically size-limited. They are + stored below ``cache_dir`` in a versioned ``raw-preload`` directory. """ if cache_dir is not None and not op.exists(cache_dir): raise OSError(f"Directory {cache_dir} does not exist") @@ -109,7 +112,7 @@ def set_memmap_min_size(memmap_min_size): "MNE_BROWSER_USE_OPENGL": ( "bool, whether to use OpenGL for rendering in the raw browser" ), - "MNE_CACHE_DIR": "str, path to the cache directory for parallel execution", + "MNE_CACHE_DIR": "str, path to the temporary and managed cache directory", "MNE_COREG_ADVANCED_RENDERING": ( "bool, whether to use advanced OpenGL rendering in coreg" ), diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 7143b6a1d28..e98b69bd54a 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -3727,7 +3727,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): freshly created memory-mapped file used to store the data on the hard drive (slower, requires less memory). An existing file is overwritten. The caller owns the file and is responsible for removing it after the - Raw object is no longer in use.""" + Raw object is no longer in use. For supported Raw readers, the exact string + ``"auto"`` instead reuses decoded data below the directory configured by + :func:`mne.set_cache_dir`. Entries persist without a size limit and are mapped + copy-on-write. Use ``Path("auto")`` for a literal filename. + + .. versionchanged:: 1.13 + Support for the ``"auto"`` decoded-data cache was added.""" docdict["preload_concatenate"] = """ preload : bool | str | None