Repository: mne-tools/mne-python

Issue:
BUG: EDF export failing on latest edfio
Our use of private `edfio._utils` is problematic, @cbrnr see [failed CI run](https://dev.azure.com/mne-tools/mne-python/_build/results?buildId=29598&view=logs&jobId=dded70eb-633c-5c42-e995-a7f8d1f99d91&j=dded70eb-633c-5c42-e995-a7f8d1f99d91&t=1ccededa-7e7e-5a92-87ce-e531d760d025) :
```
mne\export\tests\test_export.py:305: in test_channel_label_too_long_for_edf_raises_error
    raw.export(tmp_path / "test.edf")
<decorator-gen-346>:12: in export
    ???
mne\io\base.py:1792: in export
    export_raw(
<decorator-gen-599>:12: in export_raw
    ???
mne\export\_export.py:74: in export_raw
    from ._edf import _export_raw
mne\export\_edf.py:14: in <module>
    from edfio._utils import round_float_to_8_characters  # noqa: E402
E   ModuleNotFoundError: No module named 'edfio._utils'
```

Discussion:
larsoner: This PR now also attempts to bump a few CIs to 3.12 from 3.11. I think in theory it should work

Patch:
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index b6f2fd679..09e2f2648 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -238,7 +238,7 @@ stages:
         variables:
           MNE_LOGGING_LEVEL: 'warning'
           MNE_FORCE_SERIAL: 'true'
-          OPENBLAS_NUM_THREADS: '1'  # deal with OpenBLAS conflicts safely on Windows
+          OPENBLAS_NUM_THREADS: '2'
           OMP_DYNAMIC: 'false'
           PYTHONUNBUFFERED: 1
           PYTHONIOENCODING: 'utf-8'
@@ -251,9 +251,9 @@ stages:
             3.9 pip:
               TEST_MODE: 'pip'
               PYTHON_VERSION: '3.9'
-            3.11 pip pre:
+            3.12 pip pre:
               TEST_MODE: 'pip-pre'
-              PYTHON_VERSION: '3.11'
+              PYTHON_VERSION: '3.12'
         steps:
           - task: UsePythonVersion@0
             inputs:
diff --git a/doc/changes/devel/12518.newfeature.rst b/doc/changes/devel/12518.newfeature.rst
new file mode 100644
index 000000000..306254ee6
--- /dev/null
+++ b/doc/changes/devel/12518.newfeature.rst
@@ -0,0 +1,1 @@
+Add ``exclude_after_unique`` option to :meth:`mne.io.read_raw_edf` and :meth:`mne.io.read_raw_edf` to search for exclude channels after making channels names unique, by `Michiru Kaneda`_
diff --git a/mne/export/_edf.py b/mne/export/_edf.py
index 7f7111a36..3f7e55b3d 100644
--- a/mne/export/_edf.py
+++ b/mne/export/_edf.py
@@ -4,6 +4,7 @@
 # Copyright the MNE-Python contributors.
 
 import datetime as dt
+from typing import Callable
 
 import numpy as np
 
@@ -11,7 +12,21 @@ from ..utils import _check_edfio_installed, warn
 
 _check_edfio_installed()
 from edfio import Edf, EdfAnnotation, EdfSignal, Patient, Recording  # noqa: E402
-from edfio._utils import round_float_to_8_characters  # noqa: E402
+
+
+# copied from edfio (Apache license)
+def _round_float_to_8_characters(
+    value: float,
+    round_func: Callable[[float], int],
+) -> float:
+    if isinstance(value, int) or value.is_integer():
+        return value
+    length = 8
+    integer_part_length = str(value).find(".")
+    if integer_part_length == length:
+        return round_func(value)
+    factor = 10 ** (length - 1 - integer_part_length)
+    return round_func(value * factor) / factor
 
 
 def _export_raw(fname, raw, physical_range, add_ch_type):
@@ -52,7 +67,7 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
             )
             data = np.pad(data, (0, int(pad_width)))
     else:
-        data_record_duration = round_float_to_8_characters(
+        data_record_duration = _round_float_to_8_characters(
             np.floor(sfreq) / sfreq, round
         )
         out_sfreq = np.floor(sfreq) / data_record_duration
diff --git a/mne/io/edf/edf.py b/mne/io/edf/edf.py
index 7a329b6af..ec62eee16 100644
--- a/mne/io/edf/edf.py
+++ b/mne/io/edf/edf.py
@@ -87,6 +87,7 @@ class RawEDF(BaseRaw):
     %(preload)s
     %(units_edf_bdf_io)s
     %(encoding_edf)s
+    %(exclude_after_unique)s
     %(verbose)s
 
     See Also
@@ -148,13 +149,22 @@ class RawEDF(BaseRaw):
         include=None,
         units=None,
         encoding="utf8",
+        exclude_after_unique=False,
         *,
         verbose=None,
     ):
         logger.info(f"Extracting EDF parameters from {input_fname}...")
         input_fname = os.path.abspath(input_fname)
         info, edf_info, orig_units = _get_info(
-            input_fname, stim_channel, eog, misc, exclude, infer_types, preload, include
+            input_fname,
+            stim_channel,
+            eog,
+            misc,
+            exclude,
+            infer_types,
+            preload,
+            include,
+            exclude_after_unique,
         )
         logger.info("Creating raw.info structure...")
 
@@ -473,7 +483,8 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
     return tal_data
 
 
-def _read_header(fname, exclude, infer_types, include=None):
+@fill_doc
+def _read_header(fname, exclude, infer_types, include=None, exclude_after_unique=False):
     """Unify EDF, BDF and GDF _read_header call.
 
     Parameters
@@ -495,6 +506,7 @@ def _read_header(fname, exclude, infer_types, include=None):
     include : list of str | str
         Channel names to be included. A str is interpreted as a regular
         expression. 'exclude' must be empty if include is assigned.
+    %(exclude_after_unique)s
 
     Returns
     -------
@@ -503,7 +515,9 @@ def _read_header(fname, exclude, infer_types, include=None):
     ext = os.path.splitext(fname)[1][1:].lower()
     logger.info("%s file detected" % ext.upper())
     if ext in ("bdf", "edf"):
-        return _read_edf_header(fname, exclude, infer_types, include)
+        return _read_edf_header(
+            fname, exclude, infer_types, include, exclude_after_unique
+        )
     elif ext == "gdf":
         return _read_gdf_header(fname, exclude, include), None
     else:
@@ -513,13 +527,23 @@ def _read_header(fname, exclude, infer_types, include=None):
 
 
 def _get_info(
-    fname, stim_channel, eog, misc, exclude, infer_types, preload, include=None
+    fname,
+    stim_channel,
+    eog,
+    misc,
+    exclude,
+    infer_types,
+    preload,
+    include=None,
+    exclude_after_unique=False,
 ):
     """Extract information from EDF+, BDF or GDF file."""
     eog = eog if eog is not None else []
     misc = misc if misc is not None else []
 
-    edf_info, orig_units = _read_header(fname, exclude, infer_types, include)
+    edf_info, orig_units = _read_header(
+        fname, exclude, infer_types, include, exclude_after_unique
+    )
 
     # XXX: `tal_ch_names` to pass to `_check_stim_channel` should be computed
     #      from `edf_info['ch_names']` and `edf_info['tal_idx']` but 'tal_idx'
@@ -790,7 +814,9 @@ def _edf_str_num(x):
     return _edf_str(x).replace(",", ".")
 
 
-def _read_edf_header(fname, exclude, infer_types, include=None):
+def _read_edf_header(
+    fname, exclude, infer_types, include=None, exclude_after_unique=False
+):
     """Read header information from EDF+ or BDF file."""
     edf_info = {"events": []}
 
@@ -913,8 +939,12 @@ def _read_edf_header(fname, exclude, infer_types, include=None):
         else:
             ch_types, ch_names = ["EEG"] * nchan, ch_labels
 
-        exclude = _find_exclude_idx(ch_names, exclude, include)
         tal_idx = _find_tal_idx(ch_names)
+        if exclude_after_unique:
+            # make sure channel names are unique
+            ch_names = _unique_channel_names(ch_names)
+
+        exclude = _find_exclude_idx(ch_names, exclude, include)
         exclude = np.concatenate([exclude, tal_idx])
         sel = np.setdiff1d(np.arange(len(ch_names)), exclude)
         for ch in channels:
@@ -936,8 +966,9 @@ def _read_edf_header(fname, exclude, infer_types, include=None):
         ch_names = [ch_names[idx] for idx in sel]
         units = [units[idx] for idx in sel]
 
-        # make sure channel names are unique
-        ch_names = _unique_channel_names(ch_names)
+        if not exclude_after_unique:
+            # make sure channel names are unique
+            ch_names = _unique_channel_names(ch_names)
         orig_units = dict(zip(ch_names, units))
 
         physical_min = np.array([float(_edf_str_num(fid.read(8))) for ch in channels])[
@@ -1570,6 +1601,7 @@ def read_raw_edf(
     preload=False,
     units=None,
     encoding="utf8",
+    exclude_after_unique=False,
     *,
     verbose=None,
 ) -> RawEDF:
@@ -1614,6 +1646,7 @@ def read_raw_edf(
     %(preload)s
     %(units_edf_bdf_io)s
     %(encoding_edf)s
+    %(exclude_after_unique)s
     %(verbose)s
 
     Returns
@@ -1688,6 +1721,7 @@ def read_raw_edf(
         include=include,
         units=units,
         encoding=encoding,
+        exclude_after_unique=exclude_after_unique,
         verbose=verbose,
     )
 
@@ -1704,6 +1738,7 @@ def read_raw_bdf(
     preload=False,
     units=None,
     encoding="utf8",
+    exclude_after_unique=False,
     *,
     verbose=None,
 ) -> RawEDF:
@@ -1748,6 +1783,7 @@ def read_raw_bdf(
     %(preload)s
     %(units_edf_bdf_io)s
     %(encoding_edf)s
+    %(exclude_after_unique)s
     %(verbose)s
 
     Returns
@@ -1819,6 +1855,7 @@ def read_raw_bdf(
         include=include,
         units=units,
         encoding=encoding,
+        exclude_after_unique=exclude_after_unique,
         verbose=verbose,
     )
 
diff --git a/mne/morph.py b/mne/morph.py
index 812ba23e0..5b8bfba41 100644
--- a/mne/morph.py
+++ b/mne/morph.py
@@ -190,7 +190,7 @@ def compute_source_morph(
     .. footbibliography::
     """
     src_data, kind, src_subject = _get_src_data(src)
-    subject_from = _check_subject_src(subject_from, src_subject)
+    subject_from = _check_subject_src(subject_from, src_subject, warn_none=True)
     del src
     _validate_type(src_to, (SourceSpaces, None), "src_to")
     _validate_type(subject_to, (str, None), "subject_to")
@@ -823,19 +823,22 @@ def _resample_from_to(img, affine, to_vox_map):
 
 ###############################################################################
 # I/O
-def _check_subject_src(subject, src, name="subject_from", src_name="src"):
+def _check_subject_src(
+    subject, src, name="subject_from", src_name="src", *, warn_none=False
+):
     if isinstance(src, str):
         subject_check = src
     elif src is None:  # assume it's correct although dangerous but unlikely
         subject_check = subject
     else:
         subject_check = src._subject
-        if subject_check is None:
-            warn(
-                "The source space does not contain the subject name, we "
-                "recommend regenerating the source space (and forward / "
-                "inverse if applicable) for better code reliability"
-            )
+        warn_none = True
+    if subject_check is None and warn_none:
+        warn(
+            "The source space does not contain the subject name, we "
+            "recommend regenerating the source space (and forward / "
+            "inverse if applicable) for better code reliability"
+        )
     if subject is None:
         subject = subject_check
     elif subject_check is not None and subject != subject_check:
diff --git a/mne/utils/dataframe.py b/mne/utils/dataframe.py
index e4012c4d4..95618c614 100644
--- a/mne/utils/dataframe.py
+++ b/mne/utils/dataframe.py
@@ -10,6 +10,7 @@ import numpy as np
 
 from ..defaults import _handle_default
 from ._logging import logger, verbose
+from .check import check_version
 
 
 @verbose
@@ -50,9 +51,17 @@ def _convert_times(times, time_format, meas_date=None, first_time=0):
 
 
 def _inplace(df, method, **kwargs):
-    """Handle transition: inplace=True (pandas <1.5) → copy=False (>=1.5)."""
+    # Handle transition: inplace=True (pandas <1.5) → copy=False (>=1.5)
+    # and 3.0 warning:
+    # E   DeprecationWarning: The copy keyword is deprecated and will be removed in a
+    #     future version. Copy-on-Write is active in pandas since 3.0 which utilizes a
+    #     lazy copy mechanism that defers copies until necessary. Use .copy() to make
+    #     an eager copy if necessary.
     _meth = getattr(df, method)  # used for set_index() and rename()
-    if "copy" in signature(_meth).parameters:
+
+    if check_version("pandas", "3.0"):
+        return _meth(**kwargs)
+    elif "copy" in signature(_meth).parameters:
         return _meth(**kwargs, copy=False)
     else:
         _meth(**kwargs, inplace=True)
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
index 1c9575056..3b4811548 100644
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -1370,6 +1370,15 @@ evoked : instance of Evoked | list of Evoked
     dictionary.
 """
 
+docdict["exclude_after_unique"] = """
+exclude_after_unique : bool
+    If True, exclude channels are searched for after they have been made
+    unique. This is useful to choose channels that have been made unique
+    by adding a suffix.  If False, the original names are checked.
+
+    .. versionchanged:: 1.7
+"""
+
 docdict["exclude_clust"] = """
 exclude : bool array or None
     Mask to apply to the data to exclude certain points from clustering
diff --git a/tools/azure_dependencies.sh b/tools/azure_dependencies.sh
index c680fb100..cf4dd4726 100755
--- a/tools/azure_dependencies.sh
+++ b/tools/azure_dependencies.sh
@@ -1,38 +1,14 @@
-#!/bin/bash -ef
+#!/bin/bash
 
+set -eo pipefail
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
 STD_ARGS="--progress-bar off --upgrade"
-python -m pip install $STD_ARGS pip setuptools wheel packaging setuptools_scm
+python -m pip install $STD_ARGS pip setuptools wheel
 if [ "${TEST_MODE}" == "pip" ]; then
-	python -m pip install --only-binary="numba,llvmlite,numpy,scipy,vtk" -e .[test,full]
+	python -m pip install $STD_ARGS --only-binary="numba,llvmlite,numpy,scipy,vtk" -e .[test,full]
 elif [ "${TEST_MODE}" == "pip-pre" ]; then
-	STD_ARGS="$STD_ARGS --pre"
-	# python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://www.riverbankcomputing.com/pypi/simple" "PyQt6!=6.6.1,!=6.6.2" PyQt6-sip PyQt6-Qt6 "PyQt6-Qt6!=6.6.1,!=6.6.2"
-	python -m pip install $STD_ARGS --only-binary ":all:" "PyQt6!=6.6.1,!=6.6.2" PyQt6-sip PyQt6-Qt6 "PyQt6-Qt6!=6.6.1,!=6.6.2"
-	echo "Numpy etc."
-	# No pyarrow yet https://github.com/apache/arrow/issues/40216
-	# No h5py (and thus dipy) yet until they improve/refactor thier wheel building infrastructure for Windows
-	python -m pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 --extra-index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" "numpy>=2.1.0.dev0" "scipy>=1.14.0.dev0" "scikit-learn>=1.5.dev0" matplotlib pillow statsmodels
-	echo "OpenMEEG"
-	pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://test.pypi.org/simple" "openmeeg>=2.6.0.dev4"
-	echo "vtk"
-	python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://wheels.vtk.org" vtk
-	echo "nilearn"
-	python -m pip install $STD_ARGS git+https://github.com/nilearn/nilearn
-	echo "pyvista/pyvistaqt"
-	python -m pip install --progress-bar off git+https://github.com/pyvista/pyvista
-	python -m pip install --progress-bar off git+https://github.com/pyvista/pyvistaqt
-	echo "misc"
-	python -m pip install $STD_ARGS imageio-ffmpeg xlrd mffpy pillow traitlets pybv eeglabio
-	echo "nibabel with workaround"
-	python -m pip install --progress-bar off git+https://github.com/nipy/nibabel.git
-	echo "joblib"
-	python -m pip install --progress-bar off git+https://github.com/joblib/joblib@master
-	echo "EDFlib-Python"
-	python -m pip install $STD_ARGS git+https://github.com/the-siesta-group/edfio
-	# echo "pysnirf2"  # Needs h5py
-	# python -m pip install $STD_ARGS git+https://github.com/BUNPC/pysnirf2
-	./tools/check_qt_import.sh PyQt6
-	python -m pip install $STD_ARGS -e .[test]
+	${SCRIPT_DIR}/install_pre_requirements.sh
+	python -m pip install $STD_ARGS --pre -e .[test]
 else
 	echo "Unknown run type ${TEST_MODE}"
 	exit 1
diff --git a/tools/github_actions_dependencies.sh b/tools/github_actions_dependencies.sh
index c41e4c8e2..36a0e16d4 100755
--- a/tools/github_actions_dependencies.sh
+++ b/tools/github_actions_dependencies.sh
@@ -2,6 +2,7 @@
 
 set -o pipefail
 
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
 STD_ARGS="--progress-bar off --upgrade"
 INSTALL_ARGS="-e"
 INSTALL_KIND="test_extra,hdf5"
@@ -19,51 +20,10 @@ elif [ ! -z "$CONDA_DEPENDENCIES" ]; then
 	STD_ARGS="--progress-bar off"
 	INSTALL_KIND="test"
 else
-	echo "Install pip-pre dependencies"
 	test "${MNE_CI_KIND}" == "pip-pre"
 	STD_ARGS="$STD_ARGS --pre"
-	python -m pip install $STD_ARGS pip
-	echo "Numpy"
-	pip uninstall -yq numpy
-	echo "PyQt6"
-	# Now broken in latest release and in the pre release:
-	# pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 --extra-index-url https://www.riverbankcomputing.com/pypi/simple "PyQt6!=6.6.1,!=6.6.2" "PyQt6-Qt6!=6.6.1,!=6.6.2"
-	pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 "PyQt6!=6.6.1,!=6.6.2" "PyQt6-Qt6!=6.6.1,!=6.6.2"
-	echo "NumPy/SciPy/pandas etc."
-	# No pyarrow yet https://github.com/apache/arrow/issues/40216
-	# No dipy yet https://github.com/dipy/dipy/issues/2979
-	pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 --index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" "numpy>=2.1.0.dev0" h5py
-	pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 --extra-index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" "numpy>=2.1.0.dev0" "scipy>=1.14.0.dev0" "scikit-learn>=1.5.dev0" matplotlib pillow statsmodels pandas
-	# No python-picard (needs numexpr) until they update to NumPy 2.0 compat
+	${SCRIPT_DIR}/install_pre_requirements.sh
 	INSTALL_KIND="test_extra"
-	echo "OpenMEEG"
-	pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://test.pypi.org/simple" "openmeeg>=2.6.0.dev4"
-	# No Numba because it forces an old NumPy version
-	echo "nilearn"
-	pip install $STD_ARGS git+https://github.com/nilearn/nilearn
-	echo "VTK"
-	pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://wheels.vtk.org" vtk
-	python -c "import vtk"
-	echo "PyVista"
-	pip install $STD_ARGS git+https://github.com/drammock/pyvista@numpy-2-compat
-	echo "pyvistaqt"
-	pip install $STD_ARGS git+https://github.com/pyvista/pyvistaqt
-	echo "imageio-ffmpeg, xlrd, mffpy"
-	pip install $STD_ARGS imageio-ffmpeg xlrd mffpy patsy traitlets pybv eeglabio
-	echo "mne-qt-browser"
-	pip install $STD_ARGS git+https://github.com/mne-tools/mne-qt-browser
-	echo "nibabel with workaround"
-	pip install $STD_ARGS git+https://github.com/nipy/nibabel.git
-	echo "joblib"
-	pip install $STD_ARGS git+https://github.com/joblib/joblib@master
-	echo "edfio"
-	pip install $STD_ARGS git+https://github.com/the-siesta-group/edfio
-	echo "h5io"
-	pip install $STD_ARGS git+https://github.com/h5io/h5io
-	echo "pysnirf2"
-	pip install $STD_ARGS git+https://github.com/BUNPC/pysnirf2
-	# Make sure we're on a NumPy 2.0 variant
-	python -c "import numpy as np; assert np.__version__[0] == '2', np.__version__"
 fi
 echo ""
 
diff --git a/tools/install_pre_requirements.sh b/tools/install_pre_requirements.sh
new file mode 100755
index 000000000..1f54da654
--- /dev/null
+++ b/tools/install_pre_requirements.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+
+set -eo pipefail
+
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+PLATFORM=$(python -c 'import platform; print(platform.system())')
+
+echo "Installing pip-pre dependencies on ${PLATFORM}"
+STD_ARGS="--progress-bar off --upgrade --pre"
+
+# Dependencies of scientific-python-nightly-wheels are installed here so that
+# we can use strict --index-url (instead of --extra-index-url) below
+python -m pip install $STD_ARGS pip setuptools packaging \
+	threadpoolctl cycler fonttools kiwisolver pyparsing pillow python-dateutil \
+	patsy pytz tzdata nibabel tqdm trx-python joblib
+echo "PyQt6"
+# Now broken in latest release and in the pre release:
+# pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 --extra-index-url https://www.riverbankcomputing.com/pypi/simple "PyQt6!=6.6.1,!=6.6.2" "PyQt6-Qt6!=6.6.1,!=6.6.2"
+python -m pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 "PyQt6!=6.6.1,!=6.6.2" "PyQt6-Qt6!=6.6.1,!=6.6.2"
+echo "NumPy/SciPy/pandas etc."
+python -m pip uninstall -yq numpy
+# No pyarrow yet https://github.com/apache/arrow/issues/40216
+# No h5py (and thus dipy) yet until they improve/refactor thier wheel building infrastructure for Windows
+OTHERS=""
+if [[ "${PLATFORM}" == "Linux" ]]; then
+	OTHERS="h5py dipy"
+fi
+python -m pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 \
+	--index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" \
+	"numpy>=2.1.0.dev0" "scipy>=1.14.0.dev0" "scikit-learn>=1.5.dev0" \
+	matplotlib statsmodels pandas \
+	$OTHERS
+
+# No python-picard (needs numexpr) until they update to NumPy 2.0 compat
+# No Numba because it forces an old NumPy version
+
+echo "OpenMEEG"
+python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://test.pypi.org/simple" "openmeeg>=2.6.0.dev4"
+
+echo "nilearn"
+python -m pip install $STD_ARGS git+https://github.com/nilearn/nilearn
+
+echo "VTK"
+python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://wheels.vtk.org" vtk
+python -c "import vtk"
+
+echo "PyVista"
+python -m pip install $STD_ARGS git+https://github.com/pyvista/pyvista
+
+echo "pyvistaqt"
+pip install $STD_ARGS git+https://github.com/pyvista/pyvistaqt
+
+echo "imageio-ffmpeg, xlrd, mffpy"
+pip install $STD_ARGS imageio-ffmpeg xlrd mffpy traitlets pybv eeglabio
+
+echo "mne-qt-browser"
+pip install $STD_ARGS git+https://github.com/mne-tools/mne-qt-browser
+
+echo "nibabel"
+pip install $STD_ARGS git+https://github.com/nipy/nibabel
+
+echo "joblib"
+pip install $STD_ARGS git+https://github.com/joblib/joblib
+
+echo "edfio"
+pip install $STD_ARGS git+https://github.com/the-siesta-group/edfio
+
+if [[ "${PLATFORM}" == "Linux" ]]; then
+	echo "h5io"
+	pip install $STD_ARGS git+https://github.com/h5io/h5io
+
+	echo "pysnirf2"
+	pip install $STD_ARGS git+https://github.com/BUNPC/pysnirf2
+fi
+
+# Make sure we're on a NumPy 2.0 variant
+echo "Checking NumPy version"
+python -c "import numpy as np; assert np.__version__[0] == '2', np.__version__"
+
+# And that Qt works
+echo "Checking Qt"
+${SCRIPT_DIR}/check_qt_import.sh PyQt6


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d491a9702..68979e200 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -20,7 +20,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
         with:
-          python-version: '3.11'
+          python-version: '3.12'
       - uses: pre-commit/action@v3.0.1
 
   bandit:
@@ -54,17 +54,16 @@ jobs:
       matrix:
         include:
           - os: ubuntu-latest
-            python: '3.11'
+            python: '3.12'
             kind: pip-pre
           - os: ubuntu-latest
             python: '3.12'
             kind: conda
-          # 3.12 needs https://github.com/conda-forge/dipy-feedstock/pull/50
           - os: macos-14  # arm64
-            python: '3.11'
+            python: '3.12'
             kind: mamba
           - os: macos-latest  # intel
-            python: '3.11'
+            python: '3.12'
             kind: mamba
           - os: windows-latest
             python: '3.10'
diff --git a/mne/export/tests/test_export.py b/mne/export/tests/test_export.py
index 9ce72eb79..9c8a60f50 100644
--- a/mne/export/tests/test_export.py
+++ b/mne/export/tests/test_export.py
@@ -145,7 +145,7 @@ def _create_raw_for_edf_tests(stim_channel_index=None):
 
 
 edfio_mark = pytest.mark.skipif(
-    not _check_edfio_installed(strict=False), reason="edfio not installed"
+    not _check_edfio_installed(strict=False), reason="unsafe use of private module"
 )
 
 
diff --git a/mne/io/edf/tests/test_edf.py b/mne/io/edf/tests/test_edf.py
index bc6250e28..8ae55fdcc 100644
--- a/mne/io/edf/tests/test_edf.py
+++ b/mne/io/edf/tests/test_edf.py
@@ -731,9 +731,23 @@ def test_edf_stim_ch_pick_up(test_input, EXPECTED):
 
 
 @testing.requires_testing_data
-def test_bdf_multiple_annotation_channels():
+@pytest.mark.parametrize(
+    "exclude_after_unique, warns",
+    [
+        (False, False),
+        (True, True),
+    ],
+)
+def test_bdf_multiple_annotation_channels(exclude_after_unique, warns):
     """Test BDF with multiple annotation channels."""
-    raw = read_raw_bdf(bdf_multiple_annotations_path)
+    if warns:
+        ctx = pytest.warns(RuntimeWarning, match="Channel names are not unique")
+    else:
+        ctx = nullcontext()
+    with ctx:
+        raw = read_raw_bdf(
+            bdf_multiple_annotations_path, exclude_after_unique=exclude_after_unique
+        )
     assert len(raw.annotations) == 10
     descriptions = np.array(
         [
@@ -886,6 +900,32 @@ def test_exclude():
         assert ch not in raw.ch_names
 
 
+@pytest.mark.parametrize(
+    "EXPECTED, exclude, exclude_after_unique, warns",
+    [
+        (["EEG F2-Ref"], "EEG F1-Ref", False, False),
+        (["EEG F1-Ref-0", "EEG F2-Ref", "EEG F1-Ref-1"], "EEG F1-Ref-1", False, True),
+        (["EEG F2-Ref"], ["EEG F1-Ref"], False, False),
+        (["EEG F2-Ref"], "EEG F1-Ref", True, True),
+        (["EEG F1-Ref-0", "EEG F2-Ref"], "EEG F1-Ref-1", True, True),
+        (["EEG F1-Ref-0", "EEG F2-Ref", "EEG F1-Ref-1"], ["EEG F1-Ref"], True, True),
+    ],
+)
+def test_exclude_duplicate_channel_data(exclude, exclude_after_unique, warns, EXPECTED):
+    """Test exclude parameter for duplicate channel data."""
+    if warns:
+        ctx = pytest.warns(RuntimeWarning, match="Channel names are not unique")
+    else:
+        ctx = nullcontext()
+    with ctx:
+        raw = read_raw_edf(
+            duplicate_channel_labels_path,
+            exclude=exclude,
+            exclude_after_unique=exclude_after_unique,
+        )
+    assert raw.ch_names == EXPECTED
+
+
 def test_include():
     """Test include parameter."""
     raw = read_raw_edf(edf_path, include=["I1", "I2"])
@@ -899,6 +939,32 @@ def test_include():
         assert str(e.value) == "'exclude' must be empty" "if 'include' is assigned."
 
 
+@pytest.mark.parametrize(
+    "EXPECTED, include, exclude_after_unique, warns",
+    [
+        (["EEG F1-Ref-0", "EEG F1-Ref-1"], "EEG F1-Ref", False, True),
+        ([], "EEG F1-Ref-1", False, False),
+        (["EEG F1-Ref-0", "EEG F1-Ref-1"], ["EEG F1-Ref"], False, True),
+        (["EEG F1-Ref-0", "EEG F1-Ref-1"], "EEG F1-Ref", True, True),
+        (["EEG F1-Ref-1"], "EEG F1-Ref-1", True, True),
+        ([], ["EEG F1-Ref"], True, True),
+    ],
+)
+def test_include_duplicate_channel_data(include, exclude_after_unique, warns, EXPECTED):
+    """Test include parameter for duplicate channel data."""
+    if warns:
+        ctx = pytest.warns(RuntimeWarning, match="Channel names are not unique")
+    else:
+        ctx = nullcontext()
+    with ctx:
+        raw = read_raw_edf(
+            duplicate_channel_labels_path,
+            include=include,
+            exclude_after_unique=exclude_after_unique,
+        )
+    assert raw.ch_names == EXPECTED
+
+
 @testing.requires_testing_data
 def test_ch_types():
     """Test reading of channel types from EDF channel label."""