Skip to content

Commit a2d2583

Browse files
committed
rough in otb4 reader [ci skip]
1 parent 00921e0 commit a2d2583

File tree

1 file changed

+180
-27
lines changed

1 file changed

+180
-27
lines changed

mne/io/otb/otb.py

Lines changed: 180 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
from ...utils import _check_fname, fill_doc, logger, verbose, warn
1616
from ..base import BaseRaw
1717

18-
# these are the only non-data channel IDs (besides "AUX*", handled via glob)
19-
_NON_DATA_CHS = ("Quaternion", "BufferChannel", "RampChannel", "LoadCellChannel")
18+
# these will all get mapped to `misc`. Quaternion channels are handled separately.
19+
_NON_DATA_CHS = ("buffer", "ramp", "loadcell", "aux")
2020

2121

2222
def _parse_date(dt):
@@ -50,13 +50,14 @@ def _parse_sex(sex):
5050
def _parse_otb_plus_metadata(metadata, extras_metadata):
5151
assert metadata.tag == "Device"
5252
# device-level metadata
53-
sfreq = float(metadata.attrib["SampleFrequency"])
54-
n_chan = int(metadata.attrib["DeviceTotalChannels"])
53+
adc_range = 0.0033 # 3.3 mV (TODO VERIFY)
5554
bit_depth = int(metadata.attrib["ad_bits"])
5655
device_name = metadata.attrib["Name"]
57-
adc_range = 0.0033 # 3.3 mV (TODO VERIFY)
56+
n_chan = int(metadata.attrib["DeviceTotalChannels"])
57+
sfreq = float(metadata.attrib["SampleFrequency"])
5858
# containers
5959
gains = np.full(n_chan, np.nan)
60+
scalings = np.full(n_chan, np.nan)
6061
ch_names = list()
6162
ch_types = list()
6263
highpass = list()
@@ -93,19 +94,22 @@ def _parse_otb_plus_metadata(metadata, extras_metadata):
9394
# store gains
9495
gain_ix = ix + ch_offset
9596
gains[gain_ix] = float(ch.get("Gain")) * adapter_gain
96-
# TODO verify ch_type for quats, buffer channel, and ramp channel
97-
# ramp and controls channels definitely "MISC"
97+
# TODO verify ch_type for quats & buffer channel
98+
# ramp and control channels definitely "MISC"
9899
# quats should maybe be FIFF.FIFFV_QUAT_{N} (N from 0-6), but need to verify
99100
# what quats should be, as there are only 4 quat channels. The FIFF quats:
100101
# 0: obsolete
101102
# 1-3: rotations
102103
# 4-6: translations
103-
if ch_id == "Quaternions":
104-
ch_type = FIFF.FIFFV_QUAT_0 # TODO verify
105-
elif ch_id in _NON_DATA_CHS or ch_id.lower().startswith("aux"):
104+
if ch_id.startswith("Quaternion"):
105+
ch_type = "chpi" # TODO verify
106+
scalings[gain_ix] = 1e-4
107+
elif any(ch_id.lower().startswith(_ch.lower()) for _ch in _NON_DATA_CHS):
106108
ch_type = "misc"
109+
scalings[gain_ix] = 1.0
107110
else:
108111
ch_type = "emg"
112+
scalings[gain_ix] = 1.0
109113
ch_types.append(ch_type)
110114
# parse subject info
111115
subject_info = _parse_patient_xml(extras_metadata)
@@ -122,11 +126,151 @@ def _parse_otb_plus_metadata(metadata, extras_metadata):
122126
ch_types=ch_types,
123127
highpass=highpass,
124128
lowpass=lowpass,
129+
units=None,
130+
scalings=scalings,
131+
signal_paths=None,
125132
)
126133

127134

128135
def _parse_otb_four_metadata(metadata, extras_metadata):
129-
pass
136+
def get_str(node, tag):
137+
return node.find(tag).text
138+
139+
def get_int(node, tag):
140+
return int(get_str(node, tag))
141+
142+
def get_float(node, tag, **replacements):
143+
# filter freqs may be "Unknown", can't blindly parse as floats
144+
val = get_str(node, tag)
145+
return replacements.get(val, float(val))
146+
147+
assert metadata.tag == "DeviceParameters"
148+
# device-level metadata
149+
adc_range = float(metadata.find("ADC_Range").text)
150+
bit_depth = int(metadata.find("AdBits").text)
151+
n_chan = int(metadata.find("TotalChannelsInFile").text)
152+
sfreq = float(metadata.find("SamplingFrequency").text)
153+
# containers
154+
gains = np.full(n_chan, np.nan)
155+
ch_names = list()
156+
ch_types = list()
157+
highpass = list()
158+
lowpass = list()
159+
n_chans = list()
160+
paths = list()
161+
scalings = list()
162+
units = list()
163+
# stored per-adapter, but should be uniform
164+
adc_ranges = set()
165+
bit_depths = set()
166+
device_names = set()
167+
meas_dates = set()
168+
sfreqs = set()
169+
# adapter-level metadata
170+
for adapter in extras_metadata.iter("TrackInfo"):
171+
# expected to be same for all adapters
172+
adc_ranges.add(get_float(adapter, "ADC_Range"))
173+
bit_depths.add(get_int(adapter, "ADC_Nbits"))
174+
device_names.add(get_str(adapter, "Device"))
175+
sfreqs.add(get_int(adapter, "SamplingFrequency"))
176+
# may be different for each adapter
177+
adapter_id = get_str(adapter, "SubTitle")
178+
adapter_gain = get_float(adapter, "Gain")
179+
ch_offset = get_int(adapter, "AcquisitionChannel")
180+
n_chans.append(get_int(adapter, "NumberOfChannels"))
181+
paths.append(get_str(adapter, "SignalStreamPath"))
182+
scalings.append(get_str(adapter, "UnitOfMeasurementFactor"))
183+
units.append(get_str(adapter, "UnitOfMeasurement"))
184+
# we only really care about lowpass/highpass on the data channels
185+
if adapter_id not in ("Quaternion", "Buffer", "Ramp"):
186+
highpass = get_float(adapter, "HighPassFilter", Unknown=None)
187+
lowpass = get_float(adapter, "LowPassFilter", Unknown=None)
188+
# meas_date
189+
meas_date = adapter.find("StringsDescriptions").find("StartDate")
190+
if meas_date is not None:
191+
meas_dates.add(_parse_date(meas_date.text).astimezone(timezone.utc))
192+
# # range (TODO maybe not needed; might be just for mfg's GUI?)
193+
# # FWIW in the example file: range for Buffer is 1-100,
194+
# # Ramp and Control are -32767-32768, and
195+
# # EMG chs are ±2.1237507098703645E-05
196+
# rmin = get_float(adapter, "RangeMin")
197+
# rmax = get_float(adapter, "RangeMax")
198+
# if rmin.is_integer() and rmax.is_integer():
199+
# rmin = int(rmin)
200+
# rmax = int(rmax)
201+
202+
# extract channel-specific info ↓ not a typo
203+
for ch in adapter.get("Channels").iter("ChannelRapresentation"):
204+
# channel names
205+
ix = int(ch.find("Index").text)
206+
ch_name = ch.find("Label").text
207+
try:
208+
_ = int(ch_name)
209+
except ValueError:
210+
pass
211+
else:
212+
ch_name = f"{adapter_id}_{ix}"
213+
ch_names.append(ch_name)
214+
# gains
215+
gains[ix + ch_offset] = adapter_gain
216+
# channel types
217+
# TODO verify for quats & buffer channel
218+
# ramp and control channels definitely "MISC"
219+
# quats should maybe be FIFF.FIFFV_QUAT_{N} (N from 0-6), but need to verify
220+
# what quats should be, as there are only 4 quat channels. The FIFF quats:
221+
# 0: obsolete
222+
# 1-3: rotations
223+
# 4-6: translations
224+
if adapter_id.startswith("Quaternion"):
225+
ch_type = "chpi" # TODO verify
226+
elif any(adapter_id.lower().startswith(_ch) for _ch in _NON_DATA_CHS):
227+
ch_type = "misc"
228+
else:
229+
ch_type = "emg"
230+
ch_types.append(ch_type)
231+
232+
# validate the fields stored at adapter level, but that ought to be uniform:
233+
def check_uniform(name, adapter_values, device_value=None):
234+
if len(adapter_values) > 1:
235+
raise RuntimeError(
236+
f"multiple {name}s found ({', '.join(sorted(adapter_values))}), "
237+
"this is not yet supported"
238+
)
239+
adapter_value = adapter_values.pop()
240+
if device_value is not None and device_value != adapter_value:
241+
raise RuntimeError(
242+
f"mismatch between device-level {name} ({device_value}) and "
243+
f"adapter-level {name} ({adapter_value})"
244+
)
245+
return adapter_value
246+
247+
device_name = check_uniform("device name", device_names)
248+
adc_range = check_uniform("analog-to-digital range", adc_ranges, adc_range)
249+
bit_depth = check_uniform("bit depth", bit_depths, bit_depth)
250+
sfreq = check_uniform("sampling frequency", sfreqs, sfreq)
251+
252+
# verify number of channels in device metadata matches sum of adapters
253+
assert sum(n_chans) == n_chan, (
254+
f"total channels ({n_chan}) doesn't match sum of channels for each adapter "
255+
f"({sum(n_chans)})"
256+
)
257+
258+
return dict(
259+
adc_range=adc_range,
260+
bit_depth=bit_depth,
261+
ch_names=ch_names,
262+
ch_types=ch_types,
263+
device_name=device_name,
264+
gains=gains,
265+
highpass=highpass,
266+
lowpass=lowpass,
267+
n_chan=n_chan,
268+
scalings=scalings,
269+
sfreq=sfreq,
270+
signal_paths=paths,
271+
subject_info=dict(),
272+
units=units,
273+
)
130274

131275

132276
@fill_doc
@@ -160,8 +304,9 @@ def __init__(self, fname, *, verbose=None):
160304
fnames = fid.getnames()
161305
# the .sig file(s) are the binary channel data.
162306
sig_fnames = [_fname for _fname in fnames if _fname.endswith(".sig")]
163-
# TODO ↓↓↓↓↓↓↓↓ make compatible with multiple sig_fnames
164-
data_size_bytes = fid.getmember(sig_fnames[0]).size
307+
# TODO ↓↓↓↓↓↓↓↓ this may be wrong for Novecento+ devices
308+
# (MATLAB code appears to skip the first sig_fname)
309+
data_size_bytes = sum(fid.getmember(_fname).size for _fname in sig_fnames)
165310
# triage the file format versions
166311
if v4_format:
167312
metadata_fname = "DeviceParameters.xml"
@@ -187,17 +332,20 @@ def __init__(self, fname, *, verbose=None):
187332
extras_tree = ET.fromstring(fid.extractfile(extras_fname).read())
188333
# extract what we need from the tree
189334
metadata = parse_func(metadata_tree, extras_tree)
190-
sfreq = metadata["sfreq"]
191-
n_chan = metadata["n_chan"]
192-
bit_depth = metadata["bit_depth"]
193-
device_name = metadata["device_name"]
194335
adc_range = metadata["adc_range"]
195-
subject_info = metadata["subject_info"]
336+
bit_depth = metadata["bit_depth"]
196337
ch_names = metadata["ch_names"]
197338
ch_types = metadata["ch_types"]
339+
device_name = metadata["device_name"]
198340
gains = metadata["gains"]
199341
highpass = metadata["highpass"]
200342
lowpass = metadata["lowpass"]
343+
n_chan = metadata["n_chan"]
344+
scalings = metadata["scalings"]
345+
sfreq = metadata["sfreq"]
346+
signal_paths = metadata["signal_paths"]
347+
subject_info = metadata["subject_info"]
348+
# units = metadata["units"] # TODO needed for orig_units maybe
201349

202350
if bit_depth == 16:
203351
_dtype = np.int16
@@ -206,7 +354,7 @@ def __init__(self, fname, *, verbose=None):
206354
# https://stackoverflow.com/a/34128171
207355
# https://stackoverflow.com/a/11967503
208356
raise NotImplementedError(
209-
"OTB+ files with 24-bit data are not yet supported."
357+
"OTB files with 24-bit data are not yet supported."
210358
)
211359
else:
212360
raise NotImplementedError(
@@ -256,8 +404,8 @@ def __init__(self, fname, *, verbose=None):
256404
info["highpass"] = highpass
257405
info["lowpass"] = lowpass
258406
for ix, _ch in enumerate(info["chs"]):
259-
cal = 1 / 2**bit_depth / gains[ix]
260-
# TODO need different range for Quaternions?
407+
# divisor = 1.0 if _ch["kind"] == FIFF.FIFFV_MISC_CH else 2**bit_depth
408+
cal = 1 / 2**bit_depth / gains[ix] * scalings[ix]
261409
_range = (
262410
adc_range
263411
if _ch["kind"] in (FIFF.FIFFV_EMG_CH, FIFF.FIFFV_EEG_CH)
@@ -269,7 +417,7 @@ def __init__(self, fname, *, verbose=None):
269417
timezone.utc
270418
)
271419

272-
# sanity check
420+
# verify duration from metadata matches n_samples/sfreq
273421
dur = extras_tree.find("duration")
274422
if dur is not None:
275423
np.testing.assert_almost_equal(
@@ -282,7 +430,12 @@ def __init__(self, fname, *, verbose=None):
282430
# TODO parse files markers_0.xml, markers_1.xml as annotations?
283431

284432
# populate raw_extras
285-
raw_extras = dict(dtype=_dtype, sig_fnames=sig_fnames)
433+
raw_extras = dict(
434+
device_name=device_name,
435+
dtype=_dtype,
436+
sig_fnames=sig_fnames,
437+
signal_paths=signal_paths,
438+
)
286439
FORMAT_MAPPING = dict(
287440
d="double",
288441
f="single",
@@ -306,6 +459,9 @@ def _preload_data(self, preload):
306459
"""Load raw data from an OTB+ file."""
307460
_extras = self._raw_extras[0]
308461
sig_fnames = _extras["sig_fnames"]
462+
# if device_name=="Novecento+" then we may need these:
463+
# sig_paths = _extras["signal_paths"]
464+
# device_name = _extras["device_name"]
309465

310466
with tarfile.open(self.filenames[0], "r") as fid:
311467
_data = list()
@@ -318,10 +474,7 @@ def _preload_data(self, preload):
318474
.reshape(-1, self.info["nchan"])
319475
.T
320476
)
321-
if len(_data) == 1:
322-
_data = _data[0]
323-
else:
324-
_data = np.concatenate(_data, axis=0)
477+
_data = np.concatenate(_data, axis=0) # no-op if len(_data) == 1
325478

326479
cals = np.array([_ch["cal"] * _ch["range"] for _ch in self.info["chs"]])
327480
self._data = _data * cals[:, np.newaxis]

0 commit comments

Comments
 (0)