Repository: catalystneuro/neuroconv

Issue:
[Bug]: Can't initialise `OpenEphysRecordingInterface` with more than one events folder
### What happened?

I tried to use NWB Guide to convert the openephys data from Constantinople lab, but received an error. The data is in OpenEphys binary format and has multiple streams (NIDAQ, AP, LF):

<img width="500" alt="Screenshot 2024-07-25 at 14 26 48" src="https://github.com/user-attachments/assets/7a144c09-ca61-4f92-bd22-690d95794c5c">


I can parse the stream names with `OpenEphysBinaryRecordingInterface`:

```python
from neuroconv.datainterfaces import OpenEphysBinaryRecordingInterface

folder_path = "/Volumes/t7-ssd/GCP/Constantinople/J076_2023-12-06_13-24-28/Record Node 117"
print(OpenEphysBinaryRecordingInterface.get_stream_names(folder_path=folder_path))
>>>['Record Node 117#NI-DAQmx-114.PXI-6133', 'Record Node 117#Neuropix-PXI-119.ProbeA-AP', 'Record Node 117#Neuropix-PXI-119.ProbeA-LFP']
```
However, when I try to initialise the interface, I get an `Exception` that more than one events folder found (see traceback).

I haven't seen OpenEphys data with multiple streams, so I would appreciate any suggestions where to start fixing this. 
@CodyCBakerPhD (maybe @h-mayorquin ) FYI @bendichter 


### Steps to Reproduce

```python
from neuroconv.datainterfaces import OpenEphysRecordingInterface

folder_path = "/Volumes/t7-ssd/GCP/Constantinople/J076_2023-12-06_13-24-28/Record Node 117"

interface = OpenEphysRecordingInterface(folder_path=folder_path)
```


### Traceback

```python
Traceback (most recent call last):
  File "/Users/weian/anaconda3/envs/constantinople-lab-to-nwb/lib/python3.11/site-packages/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py", line 94, in __init__
    raise error
  File "/Users/weian/anaconda3/envs/constantinople-lab-to-nwb/lib/python3.11/site-packages/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py", line 79, in __init__
    _open_with_pyopenephys(folder_path=folder_path)
  File "/Users/weian/anaconda3/envs/constantinople-lab-to-nwb/lib/python3.11/site-packages/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py", line 18, in _open_with_pyopenephys
    pyopenephys_file = pyopenephys.File(foldername=folder_path)
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/weian/anaconda3/envs/constantinople-lab-to-nwb/lib/python3.11/site-packages/pyopenephys/core.py", line 175, in __init__
    self._experiments.append(Experiment(op.join(self._absolute_foldername, rel_path), id, self))
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/weian/anaconda3/envs/constantinople-lab-to-nwb/lib/python3.11/site-packages/pyopenephys/core.py", line 263, in __init__
    Recording(op.join(self._absolute_foldername, rel_path), id, self, verbose=verbose)
  File "/Users/weian/anaconda3/envs/constantinople-lab-to-nwb/lib/python3.11/site-packages/pyopenephys/core.py", line 425, in __init__
    raise Exception("More than one events folder found!")
Exception: More than one events folder found!
```


### Operating System

macOS

### Python Executable

Conda

### Python Version

3.11

### Package Versions

_No response_

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/catalystneuro/neuroconv/blob/main/.github/CODE_OF_CONDUCT.rst)
- [X] Have you ensured this bug was not already [reported](https://github.com/catalystneuro/neuroconv/issues)?

Discussion:
CodyCBakerPhD: Does this fix the original issue you found on your new example data?
weiglszonja: > Does this fix the original issue you found on your new example data?

I managed to access the traces and run the conversion without any error, but now looking at https://github.com/SpikeInterface/spikeextractors/blob/master/spikeextractors/extractors/openephysextractors/openephysextractors.py#L33 I'm a bit confused why this is working now... I'll look at the data and make this draft until I'm sure 
bendichter: @weiglszonja the up-to-date location would be here: https://github.com/SpikeInterface/spikeinterface/blob/main/src/spikeinterface/extractors/neoextractors/openephys.py which does not import pyopenephys
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) Report
Attention: Patch coverage is `92.85714%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 90.38%. Comparing base [(`6a3046d`)](https://app.codecov.io/gh/catalystneuro/neuroconv/commit/6a3046d187bbe81b07731857804df839ef505060?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) to head [(`51ec4ea`)](https://app.codecov.io/gh/catalystneuro/neuroconv/commit/51ec4eaadccf99b0541d4c9495d4b8cb3985bb93?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971/graphs/tree.svg?width=650&height=150&src=pr&token=h63OQoWehP&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro)](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro)

```diff
@@            Coverage Diff             @@
##             main     #971      +/-   ##
==========================================
- Coverage   90.39%   90.38%   -0.02%     
==========================================
  Files         128      129       +1     
  Lines        7486     7495       +9     
==========================================
+ Hits         6767     6774       +7     
- Misses        719      721       +2     
```

| [Flag](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) | `90.38% <92.85%> (-0.02%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) | Coverage Δ | |
|---|---|---|
| [.../ecephys/openephys/openephysbinarydatainterface.py](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?src=pr&el=tree&filepath=src%2Fneuroconv%2Fdatainterfaces%2Fecephys%2Fopenephys%2Fopenephysbinarydatainterface.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro#diff-c3JjL25ldXJvY29udi9kYXRhaW50ZXJmYWNlcy9lY2VwaHlzL29wZW5lcGh5cy9vcGVuZXBoeXNiaW5hcnlkYXRhaW50ZXJmYWNlLnB5) | `97.22% <100.00%> (-0.70%)` | :arrow_down: |
| [...atainterfaces/ecephys/openephys/openephys\_utils.py](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?src=pr&el=tree&filepath=src%2Fneuroconv%2Fdatainterfaces%2Fecephys%2Fopenephys%2Fopenephys_utils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro#diff-c3JjL25ldXJvY29udi9kYXRhaW50ZXJmYWNlcy9lY2VwaHlzL29wZW5lcGh5cy9vcGVuZXBoeXNfdXRpbHMucHk=) | `90.47% <90.47%> (ø)` | |

</details>
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) Report
Attention: Patch coverage is `92.85714%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 90.38%. Comparing base [(`6a3046d`)](https://app.codecov.io/gh/catalystneuro/neuroconv/commit/6a3046d187bbe81b07731857804df839ef505060?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) to head [(`9cf8707`)](https://app.codecov.io/gh/catalystneuro/neuroconv/commit/9cf8707c8c7396d54ff7ea382824dfe80030e42b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971/graphs/tree.svg?width=650&height=150&src=pr&token=h63OQoWehP&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro)](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro)

```diff
@@            Coverage Diff             @@
##             main     #971      +/-   ##
==========================================
- Coverage   90.39%   90.38%   -0.02%     
==========================================
  Files         128      129       +1     
  Lines        7486     7495       +9     
==========================================
+ Hits         6767     6774       +7     
- Misses        719      721       +2     
```

| [Flag](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) | `90.38% <92.85%> (-0.02%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro) | Coverage Δ | |
|---|---|---|
| [.../ecephys/openephys/openephysbinarydatainterface.py](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?src=pr&el=tree&filepath=src%2Fneuroconv%2Fdatainterfaces%2Fecephys%2Fopenephys%2Fopenephysbinarydatainterface.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro#diff-c3JjL25ldXJvY29udi9kYXRhaW50ZXJmYWNlcy9lY2VwaHlzL29wZW5lcGh5cy9vcGVuZXBoeXNiaW5hcnlkYXRhaW50ZXJmYWNlLnB5) | `97.22% <100.00%> (-0.70%)` | :arrow_down: |
| [...tainterfaces/ecephys/openephys/\_openephys\_utils.py](https://app.codecov.io/gh/catalystneuro/neuroconv/pull/971?src=pr&el=tree&filepath=src%2Fneuroconv%2Fdatainterfaces%2Fecephys%2Fopenephys%2F_openephys_utils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=catalystneuro#diff-c3JjL25ldXJvY29udi9kYXRhaW50ZXJmYWNlcy9lY2VwaHlzL29wZW5lcGh5cy9fb3BlbmVwaHlzX3V0aWxzLnB5) | `90.47% <90.47%> (ø)` | |

</details>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfc6e93c..8cf1c6ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Upcoming
 
+### Improvements
+
+* The `OpenEphysBinaryRecordingInterface` now uses `lxml` for extracting the session start time from the settings.xml file and does not depend on `pyopenephys` anymore. [PR #971](https://github.com/catalystneuro/neuroconv/pull/971)
+
+
 
 ## v0.5.0 (July 17, 2024)
 
diff --git a/src/neuroconv/datainterfaces/ecephys/openephys/_openephys_utils.py b/src/neuroconv/datainterfaces/ecephys/openephys/_openephys_utils.py
new file mode 100644
index 00000000..2dd972ae
--- /dev/null
+++ b/src/neuroconv/datainterfaces/ecephys/openephys/_openephys_utils.py
@@ -0,0 +1,52 @@
+from datetime import datetime
+from pathlib import Path
+from typing import Union
+from warnings import warn
+
+from lxml import etree
+
+from ....utils import FolderPathType
+
+
+def _get_session_start_time(element: etree.Element) -> Union[datetime, None]:
+    """
+    Get the session start time from the settings.xml file.
+    Returns the session start time as a datetime object.
+    When the session start time is not found in the XML, a warning is issued and None is returned.
+
+    Parameters
+    ----------
+    element : etree.Element
+        The root element of the XML tree.
+    """
+    date_str = element.findtext("./INFO/DATE")
+    if date_str is None:
+        warn(message="Could not fetch session start time from settings.xml file.", category=UserWarning)
+        return None
+
+    session_start_time = datetime.strptime(date_str, "%d %b %Y %H:%M:%S")
+    return session_start_time
+
+
+def _read_settings_xml(folder_path: FolderPathType) -> etree.Element:
+    """
+    Read the settings.xml file from an OpenEphys binary recording folder.
+    Returns the root element of the XML tree.
+
+    Parameters
+    ----------
+    folder_path : FolderPathType
+        The path to the folder containing the settings.xml file.
+    """
+    folder_path = Path(folder_path)
+
+    xml_file_paths = list(folder_path.rglob("settings.xml"))
+    if not len(xml_file_paths) == 1:
+        raise ValueError(
+            f"Unable to identify the OpenEphys folder structure! "
+            f"Please check that your `folder_path` contains a settings.xml file and sub-folders of the "
+            "following form: 'experiment<index>' -> 'recording<index>' -> 'continuous'."
+        )
+    xml_file_path = xml_file_paths[0]
+    tree = etree.parse(str(xml_file_path))
+    return tree.getroot()
diff --git a/src/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py b/src/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py
index f8f5f792..88a700dd 100644
--- a/src/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py
+++ b/src/neuroconv/datainterfaces/ecephys/openephys/openephysbinarydatainterface.py
@@ -1,24 +1,9 @@
-from contextlib import redirect_stdout
-from io import StringIO
 from typing import List, Optional
 
 from ..baserecordingextractorinterface import BaseRecordingExtractorInterface
 from ....utils import FolderPathType, get_schema_from_method_signature
 
 
-def _open_with_pyopenephys(folder_path: FolderPathType):
-    """
-    Defined here to reduce duplication; used twice in the interface below.
-
-    The pyopenephys package has a couple of annoyances, one of which is blanket print statements on file load.
-    """
-    import pyopenephys
-
-    with redirect_stdout(StringIO()):
-        pyopenephys_file = pyopenephys.File(foldername=folder_path)
-    return pyopenephys_file
-
-
 class OpenEphysBinaryRecordingInterface(BaseRecordingExtractorInterface):
     """
     Primary data interface for converting binary OpenEphys data (.dat files).
@@ -75,23 +60,9 @@ class OpenEphysBinaryRecordingInterface(BaseRecordingExtractorInterface):
         verbose : bool, default: True
         es_key : str, default: "ElectricalSeries"
         """
-        try:
-            _open_with_pyopenephys(folder_path=folder_path)
-        except Exception as error:
-            # Type of error might depend on pyopenephys version and/or platform
-            error_case_1 = (
-                type(error) is Exception
-                and str(error) == "Only 'binary' and 'openephys' format are supported by pyopenephys"
-            )
-            error_case_2 = type(error) is OSError and "Unique settings file not found in" in str(error)
-            if error_case_1 or error_case_2:  # Raise a more informative error instead.
-                raise ValueError(
-                    "Unable to identify the OpenEphys folder structure! "
-                    "Please check that your `folder_path` contains sub-folders of the "
-                    "following form: 'experiment<index>' -> 'recording<index>' -> 'continuous'."
-                )
-            else:
-                raise error
+        from ._openephys_utils import _read_settings_xml
+
+        self._xml_root = _read_settings_xml(folder_path)
 
         available_streams = self.get_stream_names(folder_path=folder_path)
         if len(available_streams) > 1 and stream_name is None:
@@ -114,10 +85,11 @@ class OpenEphysBinaryRecordingInterface(BaseRecordingExtractorInterface):
             self.subset_channels = [0, 1]
 
     def get_metadata(self) -> dict:
-        metadata = super().get_metadata()
+        from ._openephys_utils import _get_session_start_time
 
-        pyopenephys_file = _open_with_pyopenephys(folder_path=self.source_data["folder_path"])
-        session_start_time = pyopenephys_file.experiments[0].datetime
+        metadata = super().get_metadata()
 
-        metadata["NWBFile"].update(session_start_time=session_start_time)
+        session_start_time = _get_session_start_time(element=self._xml_root)
+        if session_start_time is not None:
+            metadata["NWBFile"].update(session_start_time=session_start_time)
         return metadata
diff --git a/src/neuroconv/datainterfaces/ecephys/openephys/requirements.txt b/src/neuroconv/datainterfaces/ecephys/openephys/requirements.txt
index 46902de1..2378cff7 100644
--- a/src/neuroconv/datainterfaces/ecephys/openephys/requirements.txt
+++ b/src/neuroconv/datainterfaces/ecephys/openephys/requirements.txt
@@ -1,1 +1,1 @@
-pyopenephys>=1.1.2
+lxml>=4.9.4


Test patch:
diff --git a/tests/test_on_data/test_recording_interfaces.py b/tests/test_on_data/test_recording_interfaces.py
index e2a53832..650bce82 100644
--- a/tests/test_on_data/test_recording_interfaces.py
+++ b/tests/test_on_data/test_recording_interfaces.py
@@ -357,15 +357,12 @@ class TestOpenEphysBinaryRecordingInterfaceClassMethodsAndAssertions(RecordingEx
         with self.assertRaisesWith(
             exc_type=ValueError,
             exc_msg=(
-                "Unable to identify the OpenEphys folder structure! "
-                "Please check that your `folder_path` contains sub-folders of the "
-                "following form: 'experiment<index>' -> 'recording<index>' -> 'continuous'."
+                "Unable to identify the OpenEphys folder structure! Please check that your `folder_path` contains a "
+                "settings.xml file and sub-folders of the following form: 'experiment<index>' -> 'recording<index>' ->"
+                " 'continuous'."
             ),
         ):
-            OpenEphysBinaryRecordingInterface(
-                folder_path=str(DATA_PATH / "openephysbinary" / "v0.5.3_two_neuropixels_stream"),
-                stream_name="Record_Node_107#Neuropix-PXI-116.0",
-            )
+            OpenEphysBinaryRecordingInterface(folder_path=str(DATA_PATH / "openephysbinary"))
 
     def test_stream_name_missing_assertion(self):
         with self.assertRaisesWith(