1515from ..base import BaseRaw
1616
1717
18+ def _parse_date (dt ):
19+ return datetime .fromisoformat (dt ).date ()
20+
21+
1822def _parse_patient_xml (tree ):
1923 """Convert an ElementTree to a dict."""
2024
@@ -23,9 +27,6 @@ def _parse_sex(sex):
2327 # F options and choosing one is mandatory. For `.otb4` the field is optional.
2428 return dict (m = 1 , f = 2 )[sex .lower ()[0 ]] if sex else 0 # 0 means "unknown"
2529
26- def _parse_date (dt ):
27- return datetime .fromisoformat (dt ).date ()
28-
2930 subj_info_mapping = (
3031 ("family_name" , "last_name" , str ),
3132 ("first_name" , "first_name" , str ),
@@ -42,6 +43,26 @@ def _parse_date(dt):
4243 return subject_info
4344
4445
46+ def _parse_otb_plus_metadata (metadata , extras_metadata ):
47+ assert metadata .tag == "Device"
48+ sfreq = float (metadata .attrib ["SampleFrequency" ])
49+ n_chan = int (metadata .attrib ["DeviceTotalChannels" ])
50+ bit_depth = int (metadata .attrib ["ad_bits" ])
51+ model = metadata .attrib ["Name" ]
52+ adc_range = 3.3
53+ return dict (
54+ sfreq = sfreq ,
55+ n_chan = n_chan ,
56+ bit_depth = bit_depth ,
57+ model = model ,
58+ adc_range = adc_range ,
59+ )
60+
61+
62+ def _parse_otb_four_metadata (metadata , extras_metadata ):
63+ pass
64+
65+
4566@fill_doc
4667class RawOTB (BaseRaw ):
4768 """Raw object from an OTB file.
@@ -64,8 +85,7 @@ def __init__(self, fname, *, verbose=None):
6485 # with permission to relicense as BSD-3 granted here:
6586 # https://github.com/OTBioelettronica/OTB-Python/issues/2#issuecomment-2979135882
6687 fname = str (_check_fname (fname , "read" , True , "fname" ))
67- if fname .endswith (".otb4" ):
68- raise NotImplementedError (".otb4 format is not yet supported" )
88+ v4_format = fname .endswith (".otb4" )
6989 logger .info (f"Loading { fname } " )
7090
7191 self .preload = True # lazy loading not supported
@@ -75,52 +95,46 @@ def __init__(self, fname, *, verbose=None):
7595 ch_names = list ()
7696 ch_types = list ()
7797
78- # TODO verify these are the only non-data channel IDs (other than "AUX*" which
79- # are handled separately via glob)
98+ # these are the only non-data channel IDs (besides "AUX*", handled via glob)
8099 NON_DATA_CHS = ("Quaternion" , "BufferChannel" , "RampChannel" , "LoadCellChannel" )
81- POWER_SUPPLY = 3.3 # volts
82100
83101 with tarfile .open (fname , "r" ) as fid :
84102 fnames = fid .getnames ()
85- # the .sig file is the binary channel data
86- sig_fname = [_fname for _fname in fnames if _fname .endswith (".sig" )]
87- if len (sig_fname ) != 1 :
88- raise NotImplementedError (
89- "multiple .sig files found in the OTB+ archive. Probably this "
90- "means that an acquisition was imported into another session. "
91- "This is not yet supported; please open an issue at "
92- "https://github.com/mne-tools/mne-emg/issues if you want us to add "
93- "such support."
94- )
95- sig_fname = sig_fname [0 ]
96- data_size_bytes = fid .getmember (sig_fname ).size
97- # the .xml file with the matching basename contains signal metadata
98- metadata_fname = str (Path (sig_fname ).with_suffix (".xml" ))
99- metadata = ET .fromstring (fid .extractfile (metadata_fname ).read ())
100- # patient info
101- patient_info_xml = ET .fromstring (fid .extractfile ("patient.xml" ).read ())
102- # structure of `metadata` is:
103- # Device
104- # └ Channels
105- # ├ Adapter
106- # │ ├ Channel
107- # │ ├ ...
108- # │ └ Channel
109- # ├ ...
110- # └ Adapter
111- # ├ Channel
112- # ├ ...
113- # └ Channel
114- assert metadata .tag == "Device"
115- sfreq = float (metadata .attrib ["SampleFrequency" ])
116- n_chan = int (metadata .attrib ["DeviceTotalChannels" ])
117- bit_depth = int (metadata .attrib ["ad_bits" ])
118- model = metadata .attrib ["Name" ]
119-
120- # TODO we may not need this? only relevant for Quattrocento device, and `n_chan`
121- # defined above should already be correct/sufficient
122- # if model := metadata.attrib.get("Model"):
123- # max_n_chan = int(model[-3:])
103+ # the .sig file(s) are the binary channel data.
104+ sig_fnames = [_fname for _fname in fnames if _fname .endswith (".sig" )]
105+ # TODO ↓↓↓↓↓↓↓↓ make compatible with multiple sig_fnames
106+ data_size_bytes = fid .getmember (sig_fnames [0 ]).size
107+ # triage the file format versions
108+ if v4_format :
109+ metadata_fname = "DeviceParameters.xml"
110+ extras_fname = "Tracks_000.xml"
111+ parse_func = _parse_otb_four_metadata
112+ else :
113+ # .otb4 format may legitimately have multiple .sig files, but
114+ # .otb+ should not (if it's truly raw data)
115+ if len (sig_fnames ) > 1 :
116+ raise NotImplementedError (
117+ "multiple .sig files found in the OTB+ archive. Probably this "
118+ "means that an acquisition was imported into another session. "
119+ "This is not yet supported; please open an issue at "
120+ "https://github.com/mne-tools/mne-emg/issues if you want us to "
121+ "add such support."
122+ )
123+ # the .xml file with the matching basename contains signal metadata
124+ metadata_fname = str (Path (sig_fnames [0 ]).with_suffix (".xml" ))
125+ extras_fname = "patient.xml"
126+ parse_func = _parse_otb_plus_metadata
127+ # parse the XML into a tree
128+ metadata_tree = ET .fromstring (fid .extractfile (metadata_fname ).read ())
129+ extras_tree = ET .fromstring (fid .extractfile (extras_fname ).read ())
130+ # extract what we need from the tree
131+ metadata = parse_func (metadata_tree , extras_tree )
132+ sfreq = metadata ["sfreq" ]
133+ n_chan = metadata ["n_chan" ]
134+ bit_depth = metadata ["bit_depth" ]
135+ model = metadata ["model" ]
136+ adc_range = metadata ["adc_range" ]
137+
124138 if bit_depth == 16 :
125139 _dtype = np .int16
126140 elif bit_depth == 24 : # EEG data recorded on OTB devices do this
@@ -140,10 +154,10 @@ def __init__(self, fname, *, verbose=None):
140154 )
141155 gains = np .full (n_chan , np .nan )
142156 # check in advance where we'll need to append indices to uniquify ch_names
143- n_ch_by_type = Counter ([ch .get ("ID" ) for ch in metadata .iter ("Channel" )])
157+ n_ch_by_type = Counter ([ch .get ("ID" ) for ch in metadata_tree .iter ("Channel" )])
144158 dupl_ids = [k for k , v in n_ch_by_type .items () if v > 1 ]
145159 # iterate over adapters & channels to extract gain, filters, names, etc
146- for adapter_ix , adapter in enumerate (metadata .iter ("Adapter" )):
160+ for adapter_ix , adapter in enumerate (metadata_tree .iter ("Adapter" )):
147161 adapter_ch_offset = int (adapter .get ("ChannelStartIndex" ))
148162 adapter_gain = float (adapter .get ("Gain" ))
149163 # we only care about lowpass/highpass on the data channels
@@ -188,28 +202,28 @@ def __init__(self, fname, *, verbose=None):
188202 )
189203 n_samples = int (n_samples )
190204
191- # check filter freqs.
192- # TODO filter freqs can vary by adapter, so in theory we might get different
205+ # check filter freqs. Can vary by adapter, so in theory we might get different
193206 # filters for different *data* channels (not just different between data and
194207 # misc/aux/whatever).
195208 if len (highpass ) > 1 :
196209 warn (
197210 "More than one highpass frequency found in file; choosing lowest "
198- f"({ min (highpass )} )"
211+ f"({ min (highpass )} Hz )"
199212 )
200213 if len (lowpass ) > 1 :
201214 warn (
202215 "More than one lowpass frequency found in file; choosing highest "
203- f"({ max (lowpass )} )"
216+ f"({ max (lowpass )} Hz )"
204217 )
205218 highpass = min (highpass )
206219 lowpass = max (lowpass )
207220
208221 # create info
209222 info = create_info (ch_names = ch_names , ch_types = ch_types , sfreq = sfreq )
210- subject_info = _parse_patient_xml (patient_info_xml )
211- device_info = dict (type = "OTB" , model = model ) # TODO type, model, serial, site
212- site = patient_info_xml .find ("place" )
223+ subject_info = _parse_patient_xml (extras_tree )
224+ device_info = dict (type = "OTB" , model = model ) # other allowed keys: serial
225+ meas_date = extras_tree .find ("time" )
226+ site = extras_tree .find ("place" )
213227 if site is not None :
214228 device_info .update (site = site .text )
215229 info .update (subject_info = subject_info , device_info = device_info )
@@ -218,27 +232,26 @@ def __init__(self, fname, *, verbose=None):
218232 info ["lowpass" ] = lowpass
219233 for _ch in info ["chs" ]:
220234 cal = 1 / 2 ** bit_depth / gains [ix + adapter_ch_offset ]
221- _ch .update (cal = cal , range = POWER_SUPPLY )
222- meas_date = patient_info_xml .find ("time" )
235+ _ch .update (cal = cal , range = adc_range )
223236 if meas_date is not None :
224237 info ["meas_date" ] = datetime .fromisoformat (meas_date .text ).astimezone (
225238 timezone .utc
226239 )
227240
228241 # sanity check
229- dur = patient_info_xml .find ("duration" )
242+ dur = extras_tree .find ("duration" )
230243 if dur is not None :
231244 np .testing .assert_almost_equal (
232245 float (dur .text ), n_samples / sfreq , decimal = 3
233246 )
234247
235- # TODO other fields in patient_info_xml :
248+ # TODO other fields in extras_tree :
236249 # protocol_code, pathology, commentsPatient, comments
237250
238251 # TODO parse files markers_0.xml, markers_1.xml as annotations?
239252
240253 # populate raw_extras
241- raw_extras = dict (dtype = _dtype , sig_fname = sig_fname )
254+ raw_extras = dict (dtype = _dtype , sig_fnames = sig_fnames )
242255 FORMAT_MAPPING = dict (
243256 d = "double" ,
244257 f = "single" ,
@@ -261,17 +274,24 @@ def __init__(self, fname, *, verbose=None):
261274 def _preload_data (self , preload ):
262275 """Load raw data from an OTB+ file."""
263276 _extras = self ._raw_extras [0 ]
264- sig_fname = _extras ["sig_fname " ]
277+ sig_fnames = _extras ["sig_fnames " ]
265278
266279 with tarfile .open (self .filenames [0 ], "r" ) as fid :
267- _data = (
268- np .frombuffer (
269- fid .extractfile (sig_fname ).read (),
270- dtype = _extras ["dtype" ],
280+ _data = list ()
281+ for sig_fname in sig_fnames :
282+ _data .append (
283+ np .frombuffer (
284+ fid .extractfile (sig_fname ).read (),
285+ dtype = _extras ["dtype" ],
286+ )
287+ .reshape (- 1 , self .info ["nchan" ])
288+ .T
271289 )
272- .reshape (- 1 , self .info ["nchan" ])
273- .T
274- )
290+ if len (_data ) == 1 :
291+ _data = _data [0 ]
292+ else :
293+ _data = np .concatenate (_data , axis = 0 )
294+
275295 cals = np .array (
276296 [
277297 _ch ["cal" ] * _ch ["range" ] * _ch .get ("scale" , 1.0 )
@@ -283,7 +303,7 @@ def _preload_data(self, preload):
283303
284304@fill_doc
285305def read_raw_otb (fname , verbose = None ) -> RawOTB :
286- """Reader for an OTB (.otb4 /.otb+) recording.
306+ """Reader for an OTB (.otb /.otb+/.otb4 ) recording.
287307
288308 Parameters
289309 ----------
0 commit comments