1414from ...utils import _check_fname , fill_doc , logger , verbose , warn
1515from ..base import BaseRaw
1616
17+ # these are the only non-data channel IDs (besides "AUX*", handled via glob)
18+ _NON_DATA_CHS = ("Quaternion" , "BufferChannel" , "RampChannel" , "LoadCellChannel" )
19+
1720
1821def _parse_date (dt ):
1922 return datetime .fromisoformat (dt ).date ()
@@ -23,8 +26,8 @@ def _parse_patient_xml(tree):
2326 """Convert an ElementTree to a dict."""
2427
2528 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.
2831 return dict (m = 1 , f = 2 )[sex .lower ()[0 ]] if sex else 0 # 0 means "unknown"
2932
3033 subj_info_mapping = (
@@ -45,17 +48,71 @@ def _parse_sex(sex):
4548
4649def _parse_otb_plus_metadata (metadata , extras_metadata ):
4750 assert metadata .tag == "Device"
51+ # device-level metadata
4852 sfreq = float (metadata .attrib ["SampleFrequency" ])
4953 n_chan = int (metadata .attrib ["DeviceTotalChannels" ])
5054 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+
53104 return dict (
54105 sfreq = sfreq ,
55106 n_chan = n_chan ,
56107 bit_depth = bit_depth ,
57- model = model ,
108+ device_name = device_name ,
58109 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 ,
59116 )
60117
61118
@@ -90,14 +147,6 @@ def __init__(self, fname, *, verbose=None):
90147
91148 self .preload = True # lazy loading not supported
92149
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-
101150 with tarfile .open (fname , "r" ) as fid :
102151 fnames = fid .getnames ()
103152 # the .sig file(s) are the binary channel data.
@@ -132,8 +181,14 @@ def __init__(self, fname, *, verbose=None):
132181 sfreq = metadata ["sfreq" ]
133182 n_chan = metadata ["n_chan" ]
134183 bit_depth = metadata ["bit_depth" ]
135- model = metadata ["model " ]
184+ device_name = metadata ["device_name " ]
136185 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" ]
137192
138193 if bit_depth == 16 :
139194 _dtype = np .int16
@@ -150,49 +205,8 @@ def __init__(self, fname, *, verbose=None):
150205 "If this file can be successfully read with other software (i.e. it is "
151206 "not corrupted), please open an issue at "
152207 "https://github.com/mne-tools/mne-emg/issues so we can add support for "
153- "your use case ."
208+ "your file ."
154209 )
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-
196210 # compute number of samples
197211 n_samples , extra = divmod (data_size_bytes , (bit_depth // 8 ) * n_chan )
198212 if extra != 0 :
@@ -202,6 +216,9 @@ def __init__(self, fname, *, verbose=None):
202216 )
203217 n_samples = int (n_samples )
204218
219+ # validate gains
220+ assert np .isfinite (gains ).all ()
221+
205222 # check filter freqs. Can vary by adapter, so in theory we might get different
206223 # filters for different *data* channels (not just different between data and
207224 # misc/aux/whatever).
@@ -220,8 +237,7 @@ def __init__(self, fname, *, verbose=None):
220237
221238 # create info
222239 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
225241 meas_date = extras_tree .find ("time" )
226242 site = extras_tree .find ("place" )
227243 if site is not None :
@@ -230,8 +246,8 @@ def __init__(self, fname, *, verbose=None):
230246 with info ._unlock ():
231247 info ["highpass" ] = highpass
232248 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 ]
235251 _ch .update (cal = cal , range = adc_range )
236252 if meas_date is not None :
237253 info ["meas_date" ] = datetime .fromisoformat (meas_date .text ).astimezone (
@@ -245,7 +261,7 @@ def __init__(self, fname, *, verbose=None):
245261 float (dur .text ), n_samples / sfreq , decimal = 3
246262 )
247263
248- # TODO other fields in extras_tree:
264+ # TODO other fields in extras_tree for otb+ format :
249265 # protocol_code, pathology, commentsPatient, comments
250266
251267 # TODO parse files markers_0.xml, markers_1.xml as annotations?
@@ -266,7 +282,7 @@ def __init__(self, fname, *, verbose=None):
266282 last_samps = (n_samples - 1 ,),
267283 filenames = [fname ],
268284 orig_format = orig_format ,
269- # orig_units="V" , # TODO maybe not needed
285+ # orig_units=dict(...) , # TODO needed?
270286 raw_extras = [raw_extras ],
271287 verbose = verbose ,
272288 )
@@ -292,11 +308,12 @@ def _preload_data(self, preload):
292308 else :
293309 _data = np .concatenate (_data , axis = 0 )
294310
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
295315 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" ]]
300317 )
301318 self ._data = _data * cals [:, np .newaxis ]
302319
0 commit comments