Skip to content

Commit 398330d

Browse files
committed
WIP adding support for otb4 [ci skip]
1 parent 6cb51a4 commit 398330d

File tree

1 file changed

+90
-70
lines changed

1 file changed

+90
-70
lines changed

mne/io/otb/otb.py

Lines changed: 90 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
from ..base import BaseRaw
1616

1717

18+
def _parse_date(dt):
19+
return datetime.fromisoformat(dt).date()
20+
21+
1822
def _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
4667
class 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
285305
def 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

Comments
 (0)