Skip to content

Commit 05a0222

Browse files
committed
more compatibility tweaks [ci skip]
1 parent 398330d commit 05a0222

File tree

1 file changed

+83
-66
lines changed

1 file changed

+83
-66
lines changed

mne/io/otb/otb.py

Lines changed: 83 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
from ...utils import _check_fname, fill_doc, logger, verbose, warn
1515
from ..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

1821
def _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

4649
def _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

Comments
 (0)