14
14
from ...utils import _check_fname , fill_doc , logger , verbose , warn
15
15
from ..base import BaseRaw
16
16
17
+ # these are the only non-data channel IDs (besides "AUX*", handled via glob)
18
+ _NON_DATA_CHS = ("Quaternion" , "BufferChannel" , "RampChannel" , "LoadCellChannel" )
19
+
17
20
18
21
def _parse_date (dt ):
19
22
return datetime .fromisoformat (dt ).date ()
@@ -23,8 +26,8 @@ def _parse_patient_xml(tree):
23
26
"""Convert an ElementTree to a dict."""
24
27
25
28
def _parse_sex (sex ):
26
- # TODO For devices that generate `.otb+` files, the recording GUI only has M or
27
- # F options and choosing one is mandatory. For `.otb4` the field is optional.
29
+ # For devices that generate `.otb+` files, the recording GUI only has M or F
30
+ # options and choosing one is mandatory. For `.otb4` the field is optional.
28
31
return dict (m = 1 , f = 2 )[sex .lower ()[0 ]] if sex else 0 # 0 means "unknown"
29
32
30
33
subj_info_mapping = (
@@ -45,17 +48,71 @@ def _parse_sex(sex):
45
48
46
49
def _parse_otb_plus_metadata (metadata , extras_metadata ):
47
50
assert metadata .tag == "Device"
51
+ # device-level metadata
48
52
sfreq = float (metadata .attrib ["SampleFrequency" ])
49
53
n_chan = int (metadata .attrib ["DeviceTotalChannels" ])
50
54
bit_depth = int (metadata .attrib ["ad_bits" ])
51
- model = metadata .attrib ["Name" ]
52
- adc_range = 3.3
55
+ device_name = metadata .attrib ["Name" ]
56
+ adc_range = 3.3 # TODO is this V or mV ??
57
+ # containers
58
+ gains = np .full (n_chan , np .nan )
59
+ ch_names = list ()
60
+ ch_types = list ()
61
+ highpass = list ()
62
+ lowpass = list ()
63
+ # check in advance where we'll need to append indices to uniquify ch_names
64
+ n_ch_by_type = Counter ([ch .get ("ID" ) for ch in metadata .iter ("Channel" )])
65
+ dupl_ids = [k for k , v in n_ch_by_type .items () if v > 1 ]
66
+ # iterate over adapters & channels to extract gain, filters, names, etc
67
+ for adapter in metadata .iter ("Adapter" ):
68
+ adapter_id = adapter .get ("ID" )
69
+ adapter_gain = float (adapter .get ("Gain" ))
70
+ ch_offset = int (adapter .get ("ChannelStartIndex" ))
71
+ # we only really care about lowpass/highpass on the data channels
72
+ if adapter_id not in ("AdapterQuaternions" , "AdapterControl" ):
73
+ highpass .append (float (adapter .get ("HighPassFilter" )))
74
+ lowpass .append (float (adapter .get ("LowPassFilter" )))
75
+ for ch in adapter .iter ("Channel" ):
76
+ ix = int (ch .get ("Index" ))
77
+ ch_id = ch .get ("ID" )
78
+ # # see if we can parse the adapter name to get row,col info
79
+ # pattern = re.compile(
80
+ # # connector type inter-elec dist grid rows grid cols
81
+ # r"(?:[a-zA-Z]+)(?:(?P<ied>\d+)MM)(?P<row>\d{2})(?P<col>\d{2})"
82
+ # )
83
+ # if match := pattern.match(ch_id):
84
+ # col = ix % int(match["col"])
85
+ # row = ix // int(match["row"])
86
+ # ch_name = f"EMG_{adapter_ix}({row:02},{col:02})"
87
+ # elif ch_id
88
+ # else:
89
+ # ch_name = f"EMG_{ix + adapter_ch_offset:03}"
90
+ # ch_names.append(ch_name)
91
+ ch_names .append (f"{ ch_id } _{ ix } " if ch_id in dupl_ids else ch_id )
92
+ # store gains
93
+ gain_ix = ix + ch_offset
94
+ gains [gain_ix ] = float (ch .get ("Gain" )) * adapter_gain
95
+ # TODO verify ch_type for quats, buffer channel, and ramp channel
96
+ ch_types .append (
97
+ "misc"
98
+ if ch_id in _NON_DATA_CHS or ch_id .lower ().startswith ("aux" )
99
+ else "emg"
100
+ )
101
+ # parse subject info
102
+ subject_info = _parse_patient_xml (extras_metadata )
103
+
53
104
return dict (
54
105
sfreq = sfreq ,
55
106
n_chan = n_chan ,
56
107
bit_depth = bit_depth ,
57
- model = model ,
108
+ device_name = device_name ,
58
109
adc_range = adc_range ,
110
+ subject_info = subject_info ,
111
+ gains = gains ,
112
+ ch_names = ch_names ,
113
+ ch_types = ch_types ,
114
+ highpass = highpass ,
115
+ lowpass = lowpass ,
59
116
)
60
117
61
118
@@ -90,14 +147,6 @@ def __init__(self, fname, *, verbose=None):
90
147
91
148
self .preload = True # lazy loading not supported
92
149
93
- highpass = list ()
94
- lowpass = list ()
95
- ch_names = list ()
96
- ch_types = list ()
97
-
98
- # these are the only non-data channel IDs (besides "AUX*", handled via glob)
99
- NON_DATA_CHS = ("Quaternion" , "BufferChannel" , "RampChannel" , "LoadCellChannel" )
100
-
101
150
with tarfile .open (fname , "r" ) as fid :
102
151
fnames = fid .getnames ()
103
152
# the .sig file(s) are the binary channel data.
@@ -132,8 +181,14 @@ def __init__(self, fname, *, verbose=None):
132
181
sfreq = metadata ["sfreq" ]
133
182
n_chan = metadata ["n_chan" ]
134
183
bit_depth = metadata ["bit_depth" ]
135
- model = metadata ["model " ]
184
+ device_name = metadata ["device_name " ]
136
185
adc_range = metadata ["adc_range" ]
186
+ subject_info = metadata ["subject_info" ]
187
+ ch_names = metadata ["ch_names" ]
188
+ ch_types = metadata ["ch_types" ]
189
+ gains = metadata ["gains" ]
190
+ highpass = metadata ["highpass" ]
191
+ lowpass = metadata ["lowpass" ]
137
192
138
193
if bit_depth == 16 :
139
194
_dtype = np .int16
@@ -150,49 +205,8 @@ def __init__(self, fname, *, verbose=None):
150
205
"If this file can be successfully read with other software (i.e. it is "
151
206
"not corrupted), please open an issue at "
152
207
"https://github.com/mne-tools/mne-emg/issues so we can add support for "
153
- "your use case ."
208
+ "your file ."
154
209
)
155
- gains = np .full (n_chan , np .nan )
156
- # check in advance where we'll need to append indices to uniquify ch_names
157
- n_ch_by_type = Counter ([ch .get ("ID" ) for ch in metadata_tree .iter ("Channel" )])
158
- dupl_ids = [k for k , v in n_ch_by_type .items () if v > 1 ]
159
- # iterate over adapters & channels to extract gain, filters, names, etc
160
- for adapter_ix , adapter in enumerate (metadata_tree .iter ("Adapter" )):
161
- adapter_ch_offset = int (adapter .get ("ChannelStartIndex" ))
162
- adapter_gain = float (adapter .get ("Gain" ))
163
- # we only care about lowpass/highpass on the data channels
164
- # TODO verify these two are the only non-data adapter types
165
- if adapter .get ("ID" ) not in ("AdapterQuaternions" , "AdapterControl" ):
166
- highpass .append (float (adapter .get ("HighPassFilter" )))
167
- lowpass .append (float (adapter .get ("LowPassFilter" )))
168
-
169
- for ch in adapter .iter ("Channel" ):
170
- ix = int (ch .get ("Index" ))
171
- ch_id = ch .get ("ID" )
172
- # # see if we can parse the adapter name to get row,col info
173
- # pattern = re.compile(
174
- # # connector type inter-elec dist grid rows grid cols
175
- # r"(?:[a-zA-Z]+)(?:(?P<ied>\d+)MM)(?P<row>\d{2})(?P<col>\d{2})"
176
- # )
177
- # if match := pattern.match(ch_id):
178
- # col = ix % int(match["col"])
179
- # row = ix // int(match["row"])
180
- # ch_name = f"EMG_{adapter_ix}({row:02},{col:02})"
181
- # elif ch_id
182
- # else:
183
- # ch_name = f"EMG_{ix + adapter_ch_offset:03}"
184
- # ch_names.append(ch_name)
185
- ch_names .append (f"{ ch_id } _{ ix } " if ch_id in dupl_ids else ch_id )
186
- # store gains
187
- gains [ix + adapter_ch_offset ] = float (ch .get ("Gain" )) * adapter_gain
188
- # TODO verify ch_type for quats, buffer channel, and ramp channel
189
- ch_types .append (
190
- "misc"
191
- if ch_id in NON_DATA_CHS or ch_id .lower ().startswith ("aux" )
192
- else "emg"
193
- )
194
- assert np .isfinite (gains ).all ()
195
-
196
210
# compute number of samples
197
211
n_samples , extra = divmod (data_size_bytes , (bit_depth // 8 ) * n_chan )
198
212
if extra != 0 :
@@ -202,6 +216,9 @@ def __init__(self, fname, *, verbose=None):
202
216
)
203
217
n_samples = int (n_samples )
204
218
219
+ # validate gains
220
+ assert np .isfinite (gains ).all ()
221
+
205
222
# check filter freqs. Can vary by adapter, so in theory we might get different
206
223
# filters for different *data* channels (not just different between data and
207
224
# misc/aux/whatever).
@@ -220,8 +237,7 @@ def __init__(self, fname, *, verbose=None):
220
237
221
238
# create info
222
239
info = create_info (ch_names = ch_names , ch_types = ch_types , sfreq = sfreq )
223
- subject_info = _parse_patient_xml (extras_tree )
224
- device_info = dict (type = "OTB" , model = model ) # other allowed keys: serial
240
+ device_info = dict (type = "OTB" , model = device_name ) # other allowed keys: serial
225
241
meas_date = extras_tree .find ("time" )
226
242
site = extras_tree .find ("place" )
227
243
if site is not None :
@@ -230,8 +246,8 @@ def __init__(self, fname, *, verbose=None):
230
246
with info ._unlock ():
231
247
info ["highpass" ] = highpass
232
248
info ["lowpass" ] = lowpass
233
- for _ch in info ["chs" ]:
234
- cal = 1 / 2 ** bit_depth / gains [ix + adapter_ch_offset ]
249
+ for ix , _ch in enumerate ( info ["chs" ]) :
250
+ cal = 1 / 2 ** bit_depth / gains [ix ]
235
251
_ch .update (cal = cal , range = adc_range )
236
252
if meas_date is not None :
237
253
info ["meas_date" ] = datetime .fromisoformat (meas_date .text ).astimezone (
@@ -245,7 +261,7 @@ def __init__(self, fname, *, verbose=None):
245
261
float (dur .text ), n_samples / sfreq , decimal = 3
246
262
)
247
263
248
- # TODO other fields in extras_tree:
264
+ # TODO other fields in extras_tree for otb+ format :
249
265
# protocol_code, pathology, commentsPatient, comments
250
266
251
267
# TODO parse files markers_0.xml, markers_1.xml as annotations?
@@ -266,7 +282,7 @@ def __init__(self, fname, *, verbose=None):
266
282
last_samps = (n_samples - 1 ,),
267
283
filenames = [fname ],
268
284
orig_format = orig_format ,
269
- # orig_units="V" , # TODO maybe not needed
285
+ # orig_units=dict(...) , # TODO needed?
270
286
raw_extras = [raw_extras ],
271
287
verbose = verbose ,
272
288
)
@@ -292,11 +308,12 @@ def _preload_data(self, preload):
292
308
else :
293
309
_data = np .concatenate (_data , axis = 0 )
294
310
311
+ # TODO without this fudge factor, the scale of the signals seems way too high
312
+ # (sample data channels show a dynamic range of 0.2 - 3.3 V)
313
+ # the puzzling thing is that in the MATLAB code the fudge is 1e3 (not 1e-3) ?!?
314
+ fudge_factor = 1e-3
295
315
cals = np .array (
296
- [
297
- _ch ["cal" ] * _ch ["range" ] * _ch .get ("scale" , 1.0 )
298
- for _ch in self .info ["chs" ]
299
- ]
316
+ [_ch ["cal" ] * _ch ["range" ] * fudge_factor for _ch in self .info ["chs" ]]
300
317
)
301
318
self ._data = _data * cals [:, np .newaxis ]
302
319
0 commit comments