2525import pathlib
2626import warnings
2727import platform
28+ import re
2829
29- from collections import namedtuple
3030from urllib .request import urlopen
3131from datetime import datetime
3232
@@ -53,6 +53,10 @@ class Plexon2RawIO(BaseRawIO):
5353 pl2_dll_file_path: str | Path | None, default: None
5454 The path to the necessary dll for loading pl2 files
5555 If None will find correct dll for architecture and if it does not exist will download it
56+ reading_attempts: int, default: 15
57+ Number of attempts to read the file before raising an error
58+ This opening process is somewhat unreliable and might fail occasionally. Adjust this higher
59+ if you encounter problems in opening the file.
5660
5761 Notes
5862 -----
@@ -88,7 +92,7 @@ class Plexon2RawIO(BaseRawIO):
8892 extensions = ["pl2" ]
8993 rawmode = "one-file"
9094
91- def __init__ (self , filename , pl2_dll_file_path = None ):
95+ def __init__ (self , filename , pl2_dll_file_path = None , reading_attempts = 15 ):
9296
9397 # signals, event and spiking data will be cached
9498 # cached signal data can be cleared using `clear_analogsignal_cache()()`
@@ -128,7 +132,6 @@ def __init__(self, filename, pl2_dll_file_path=None):
128132
129133 self .pl2reader = PyPL2FileReader (pl2_dll_file_path = pl2_dll_file_path )
130134
131- reading_attempts = 10
132135 for attempt in range (reading_attempts ):
133136 self .pl2reader .pl2_open_file (self .filename )
134137
@@ -152,46 +155,72 @@ def _parse_header(self):
152155 # Scanning sources and populating signal channels at the same time. Sources have to have
153156 # same sampling rate and number of samples to belong to one stream.
154157 signal_channels = []
155- source_characteristics = {}
156- Source = namedtuple ("Source" , "id name sampling_rate n_samples" )
157- for c in range (self .pl2reader .pl2_file_info .m_TotalNumberOfAnalogChannels ):
158- achannel_info = self .pl2reader .pl2_get_analog_channel_info (c )
158+ channel_num_samples = []
159+
160+ # We will build the stream ids based on the channel prefixes
161+ # The channel prefixes are the first characters of the channel names which have the following format:
162+ # WB{number}, FPX{number}, SPKCX{number}, AI{number}, etc
163+ # We will extract the prefix and use it as stream id
164+ regex_prefix_pattern = r"^\D+" # Match any non-digit character at the beginning of the string
165+
166+ for channel_index in range (self .pl2reader .pl2_file_info .m_TotalNumberOfAnalogChannels ):
167+ achannel_info = self .pl2reader .pl2_get_analog_channel_info (channel_index )
159168 # only consider active channels
160169 if not (achannel_info .m_ChannelEnabled and achannel_info .m_ChannelRecordingEnabled ):
161170 continue
162171
163172 # assign to matching stream or create new stream based on signal characteristics
164173 rate = achannel_info .m_SamplesPerSecond
165- n_samples = achannel_info .m_NumberOfValues
166- source_id = str (achannel_info .m_Source )
167-
168- channel_source = Source (source_id , f"stream@{ rate } Hz" , rate , n_samples )
169- existing_source = source_characteristics .setdefault (source_id , channel_source )
170-
171- # ensure that stream of this channel and existing stream have same properties
172- if channel_source != existing_source :
173- raise ValueError (
174- f"The channel source { channel_source } must be the same as the existing source { existing_source } "
175- )
174+ num_samples = achannel_info .m_NumberOfValues
175+ channel_num_samples .append (num_samples )
176176
177177 ch_name = achannel_info .m_Name .decode ()
178178 chan_id = f"source{ achannel_info .m_Source } .{ achannel_info .m_Channel } "
179179 dtype = "int16"
180180 units = achannel_info .m_Units .decode ()
181181 gain = achannel_info .m_CoeffToConvertToUnits
182182 offset = 0.0 # PL2 files don't contain information on signal offset
183- stream_id = source_id
183+
184+ channel_prefix = re .match (regex_prefix_pattern , ch_name ).group (0 )
185+ stream_id = channel_prefix
184186 signal_channels .append ((ch_name , chan_id , rate , dtype , units , gain , offset , stream_id ))
185187
186188 signal_channels = np .array (signal_channels , dtype = _signal_channel_dtype )
187- self .signal_stream_characteristics = source_characteristics
188-
189- # create signal streams from source information
189+ channel_num_samples = np .array (channel_num_samples )
190+
191+ # We are using channel prefixes as stream_ids
192+ # The meaning of the channel prefixes was provided by a Plexon Engineer, see here:
193+ # https://github.com/NeuralEnsemble/python-neo/pull/1495#issuecomment-2184256894
194+ stream_id_to_stream_name = {
195+ "WB" : "WB-Wideband" ,
196+ "FP" : "FPl-Low Pass Filtered" ,
197+ "SP" : "SPKC-High Pass Filtered" ,
198+ "AI" : "AI-Auxiliary Input" ,
199+ }
200+
201+ unique_stream_ids = np .unique (signal_channels ["stream_id" ])
190202 signal_streams = []
191- for stream_idx , source in source_characteristics .items ():
192- signal_streams .append ((source .name , str (source .id )))
203+ for stream_id in unique_stream_ids :
204+ # We are using the channel prefixes as ids
205+ # The users of plexon can modify the prefix of the channel names (e.g. `my_prefix` instead of `WB`).
206+ # In that case we use the channel prefix both as stream id and name
207+ stream_name = stream_id_to_stream_name .get (stream_id , stream_id )
208+ signal_streams .append ((stream_name , stream_id ))
209+
193210 signal_streams = np .array (signal_streams , dtype = _signal_stream_dtype )
194211
212+ self .stream_id_samples = {}
213+ self .stream_index_to_stream_id = {}
214+ for stream_index , stream_id in enumerate (signal_streams ["id" ]):
215+ # Keep a mapping from stream_index to stream_id
216+ self .stream_index_to_stream_id [stream_index ] = stream_id
217+
218+ # We extract the number of samples for each stream
219+ mask = signal_channels ["stream_id" ] == stream_id
220+ signal_num_samples = np .unique (channel_num_samples [mask ])
221+ assert signal_num_samples .size == 1 , "All channels in a stream must have the same number of samples"
222+ self .stream_id_samples [stream_id ] = signal_num_samples [0 ]
223+
195224 # pre-loading spike channel_data for later usage
196225 self ._spike_channel_cache = {}
197226 spike_channels = []
@@ -354,16 +383,12 @@ def _segment_t_stop(self, block_index, seg_index):
354383 end_time = (
355384 self .pl2reader .pl2_file_info .m_StartRecordingTime + self .pl2reader .pl2_file_info .m_DurationOfRecording
356385 )
357- return end_time / self .pl2reader .pl2_file_info .m_TimestampFrequency
386+ return float ( end_time / self .pl2reader .pl2_file_info .m_TimestampFrequency )
358387
359388 def _get_signal_size (self , block_index , seg_index , stream_index ):
360- # this must return an integer value (the number of samples)
361-
362- stream_id = self .header ["signal_streams" ][stream_index ]["id" ]
363- stream_characteristic = list (self .signal_stream_characteristics .values ())[stream_index ]
364- if stream_id != stream_characteristic .id :
365- raise ValueError (f"The `stream_id` must be { stream_characteristic .id } " )
366- return int (stream_characteristic .n_samples ) # Avoids returning a numpy.int64 scalar
389+ stream_id = self .stream_index_to_stream_id [stream_index ]
390+ num_samples = int (self .stream_id_samples [stream_id ])
391+ return num_samples
367392
368393 def _get_signal_t_start (self , block_index , seg_index , stream_index ):
369394 # This returns the t_start of signals as a float value in seconds
0 commit comments