Skip to content

Commit 72dd333

Browse files
authored
Merge pull request #331 from CAMBI-tech/static-offset-refactor
Static Offset per Device
2 parents e8d5186 + c5204da commit 72dd333

File tree

17 files changed

+113
-64
lines changed

17 files changed

+113
-64
lines changed

bcipy/acquisition/devices.py

+14-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
'float32', 'double64', 'string', 'int32', 'int16', 'int8'
1717
]
1818
DEFAULT_DEVICE_TYPE = 'EEG'
19+
DEFAULT_STATIC_OFFSET = 0.1
1920

2021
log = logging.getLogger(__name__)
2122

@@ -85,6 +86,12 @@ class DeviceSpec:
8586
see https://labstreaminglayer.readthedocs.io/projects/liblsl/ref/enums.html
8687
excluded_from_analysis - list of channels (label) to exclude from analysis.
8788
status - recording status
89+
static_offset - Specifies the static trigger offset (in seconds) used to align
90+
triggers properly with EEG data from LSL. The system includes built-in
91+
offset correction, but there is still a hardware-limited offset between EEG
92+
and trigger timing values for which the system does not account. The correct
93+
value may be different for each computer, and must be determined on a
94+
case-by-case basis. Default: 0.1",
8895
"""
8996

9097
def __init__(self,
@@ -95,7 +102,8 @@ def __init__(self,
95102
description: Optional[str] = None,
96103
excluded_from_analysis: Optional[List[str]] = None,
97104
data_type: str = 'float32',
98-
status: DeviceStatus = DeviceStatus.ACTIVE):
105+
status: DeviceStatus = DeviceStatus.ACTIVE,
106+
static_offset: float = DEFAULT_STATIC_OFFSET):
99107

100108
assert sample_rate >= 0, "Sample rate can't be negative."
101109
assert data_type in SUPPORTED_DATA_TYPES
@@ -109,6 +117,7 @@ def __init__(self,
109117
self.excluded_from_analysis = excluded_from_analysis or []
110118
self._validate_excluded_channels()
111119
self.status = status
120+
self.static_offset = static_offset
112121

113122
@property
114123
def channel_count(self) -> int:
@@ -152,7 +161,8 @@ def to_dict(self) -> dict:
152161
'sample_rate': self.sample_rate,
153162
'description': self.description,
154163
'excluded_from_analysis': self.excluded_from_analysis,
155-
'status': str(self.status)
164+
'status': str(self.status),
165+
'static_offset': self.static_offset
156166
}
157167

158168
def __str__(self):
@@ -188,7 +198,8 @@ def make_device_spec(config: dict) -> DeviceSpec:
188198
description=config['description'],
189199
excluded_from_analysis=config.get(
190200
'excluded_from_analysis', []),
191-
status=DeviceStatus.from_str(config.get('status', default_status)))
201+
status=DeviceStatus.from_str(config.get('status', default_status)),
202+
static_offset=config.get('static_offset', DEFAULT_STATIC_OFFSET))
192203

193204

194205
def load(config_path: Path = Path(DEFAULT_CONFIG), replace: bool = False) -> Dict[str, DeviceSpec]:

bcipy/acquisition/multimodal.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ def get_data_by_device(
131131
132132
Parameters
133133
----------
134-
start - start time (acquisition clock) of data window
134+
start - start time (acquisition clock) of data window; NOTE: the
135+
actual start time will be adjusted to by the static_offset
136+
configured for each device.
135137
seconds - duration of data to return for each device
136138
content_types - specifies which devices to include; if not
137139
unspecified, data for all types is returned.
@@ -144,19 +146,21 @@ def get_data_by_device(
144146
for content_type in content_types:
145147
name = content_type.name
146148
client = self.get_client(content_type)
149+
150+
adjusted_start = start + client.device_spec.static_offset
147151
if client.device_spec.sample_rate > 0:
148152
count = round(seconds * client.device_spec.sample_rate)
149153
log.info(f'Need {count} records for processing {name} data')
150-
output[content_type] = client.get_data(start=start,
154+
output[content_type] = client.get_data(start=adjusted_start,
151155
limit=count)
152156
data_count = len(output[content_type])
153157
if strict and data_count < count:
154158
msg = f'Needed {count} {name} records but received {data_count}'
155159
raise InsufficientDataException(msg)
156160
else:
157161
# Markers have an IRREGULAR_RATE.
158-
output[content_type] = client.get_data(start=start,
159-
end=start + seconds)
162+
output[content_type] = client.get_data(start=adjusted_start,
163+
end=adjusted_start + seconds)
160164
return output
161165

162166
def cleanup(self):

bcipy/acquisition/tests/test_client_manager.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def setUp(self):
1515
self.eeg_device_mock.content_type = 'EEG'
1616
self.eeg_device_mock.sample_rate = 300
1717
self.eeg_device_mock.is_active = True
18+
self.eeg_device_mock.static_offset = 0.1
1819

1920
self.eeg_client_mock = Mock()
2021
self.eeg_client_mock.device_spec = self.eeg_device_mock
@@ -26,6 +27,7 @@ def setUp(self):
2627
self.gaze_device_mock.content_type = 'Eyetracker'
2728
self.gaze_device_mock.sample_rate = 60
2829
self.gaze_device_mock.is_active = False
30+
self.gaze_device_mock.static_offset = 0.0
2931
self.gaze_client_mock = Mock()
3032
self.gaze_client_mock.device_spec = self.gaze_device_mock
3133

@@ -115,6 +117,7 @@ def test_get_data_by_device(self):
115117
switch_device_mock.name = 'Test-switch-2000'
116118
switch_device_mock.content_type = 'Markers'
117119
switch_device_mock.sample_rate = 0.0
120+
switch_device_mock.static_offset = 0.2
118121

119122
switch_client_mock = Mock()
120123
switch_client_mock.device_spec = switch_device_mock
@@ -141,11 +144,11 @@ def test_get_data_by_device(self):
141144
ContentType.MARKERS
142145
])
143146

144-
self.eeg_client_mock.get_data.assert_called_once_with(start=100,
147+
self.eeg_client_mock.get_data.assert_called_once_with(start=100.1,
145148
limit=1500)
146149
self.gaze_client_mock.get_data.assert_called_once_with(start=100,
147150
limit=300)
148-
switch_client_mock.get_data.assert_called_with(start=100, end=105)
151+
switch_client_mock.get_data.assert_called_with(start=100.2, end=105.2)
149152

150153
self.assertTrue(ContentType.EEG in results)
151154
self.assertTrue(ContentType.EYETRACKER in results)

bcipy/acquisition/tests/test_devices.py

+45-4
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def test_load_from_config(self):
5757
self.assertEqual(channels, spec.channels)
5858
self.assertEqual(devices.DeviceStatus.ACTIVE, spec.status)
5959
self.assertTrue(spec.is_active)
60+
self.assertEqual(spec.static_offset, devices.DEFAULT_STATIC_OFFSET)
6061

6162
self.assertEqual(spec, devices.preconfigured_device('DSI-VR300'))
6263
shutil.rmtree(temp_dir)
@@ -222,9 +223,22 @@ def test_device_spec_to_dict(self):
222223
"""DeviceSpec should be able to be converted to a dictionary."""
223224
device_name = 'TestDevice'
224225
channels = ['C1', 'C2', 'C3']
225-
expected_channel_output = [{'label': 'C1', 'name': 'C1', 'type': None, 'units': None},
226-
{'label': 'C2', 'name': 'C2', 'type': None, 'units': None},
227-
{'label': 'C3', 'name': 'C3', 'type': None, 'units': None}]
226+
expected_channel_output = [{
227+
'label': 'C1',
228+
'name': 'C1',
229+
'type': None,
230+
'units': None
231+
}, {
232+
'label': 'C2',
233+
'name': 'C2',
234+
'type': None,
235+
'units': None
236+
}, {
237+
'label': 'C3',
238+
'name': 'C3',
239+
'type': None,
240+
'units': None
241+
}]
228242
sample_rate = 256.0
229243
content_type = 'EEG'
230244
spec = devices.DeviceSpec(name=device_name,
@@ -238,6 +252,8 @@ def test_device_spec_to_dict(self):
238252
self.assertEqual(expected_channel_output, spec_dict['channels'])
239253
self.assertEqual(sample_rate, spec_dict['sample_rate'])
240254
self.assertEqual('passive', spec_dict['status'])
255+
self.assertEqual(spec_dict['static_offset'],
256+
devices.DEFAULT_STATIC_OFFSET)
241257

242258
def test_load_status(self):
243259
"""Should be able to load a list of supported devices from a
@@ -259,7 +275,32 @@ def test_load_status(self):
259275

260276
devices.load(config_path, replace=True)
261277
supported = devices.preconfigured_devices()
262-
self.assertEqual(devices.DeviceStatus.PASSIVE, supported['MyDevice'].status)
278+
self.assertEqual(devices.DeviceStatus.PASSIVE,
279+
supported['MyDevice'].status)
280+
shutil.rmtree(temp_dir)
281+
282+
def test_load_static_offset(self):
283+
"""Loaded device should support using a custom static offset."""
284+
285+
# create a config file in a temp location.
286+
temp_dir = tempfile.mkdtemp()
287+
offset = 0.2
288+
my_devices = [
289+
dict(name="MyDevice",
290+
content_type="EEG",
291+
description="My Device",
292+
channels=["a", "b", "c"],
293+
sample_rate=100.0,
294+
status=str(devices.DeviceStatus.PASSIVE),
295+
static_offset=offset)
296+
]
297+
config_path = Path(temp_dir, 'my_devices.json')
298+
with open(config_path, 'w', encoding=DEFAULT_ENCODING) as config_file:
299+
json.dump(my_devices, config_file)
300+
301+
devices.load(config_path, replace=True)
302+
supported = devices.preconfigured_devices()
303+
self.assertEqual(supported['MyDevice'].static_offset, offset)
263304
shutil.rmtree(temp_dir)
264305

265306
def test_device_status(self):

bcipy/helpers/convert.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import os
55
import tarfile
66
from pathlib import Path
7-
from typing import Dict, List, Tuple, Optional, Union
7+
from typing import Dict, List, Optional, Tuple, Union
88

99
import mne
1010
import numpy as np
1111
from mne.io import RawArray
1212
from pyedflib import FILETYPE_BDFPLUS, FILETYPE_EDFPLUS, EdfWriter
1313
from tqdm import tqdm
1414

15+
from bcipy.acquisition.devices import preconfigured_device
1516
from bcipy.config import (DEFAULT_PARAMETER_FILENAME, RAW_DATA_FILENAME,
1617
TRIGGER_FILENAME)
1718
from bcipy.helpers.load import load_json_parameters, load_raw_data
@@ -177,6 +178,7 @@ def pyedf_convert(data_dir: str,
177178
value_cast=True)
178179
data = load_raw_data(str(Path(data_dir, f'{RAW_DATA_FILENAME}.csv')))
179180
fs = data.sample_rate
181+
device_spec = preconfigured_device(data.daq_type)
180182
if pre_filter:
181183
default_transform = get_default_transform(
182184
sample_rate_hz=data.sample_rate,
@@ -194,7 +196,7 @@ def pyedf_convert(data_dir: str,
194196
else:
195197
raw_data, _ = data.by_channel()
196198
durations = trigger_durations(params) if use_event_durations else {}
197-
static_offset = params['static_trigger_offset']
199+
static_offset = device_spec.static_offset
198200
logger.info(f'Static offset: {static_offset}')
199201

200202
trigger_type, trigger_timing, trigger_label = trigger_decoder(

bcipy/helpers/demo/demo_visualization.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
if not path:
4545
path = load_experimental_data()
4646

47-
parameters = load_json_parameters(f'{path}/{DEFAULT_PARAMETER_FILENAME}', value_cast=True)
47+
parameters = load_json_parameters(f'{path}/{DEFAULT_PARAMETER_FILENAME}',
48+
value_cast=True)
4849

4950
# extract all relevant parameters
5051
trial_window = parameters.get("trial_window", (0, 0.5))
@@ -59,12 +60,16 @@
5960
filter_high = parameters.get("filter_high")
6061
filter_low = parameters.get("filter_low")
6162
filter_order = parameters.get("filter_order")
62-
static_offset = parameters.get("static_trigger_offset")
63+
6364
raw_data = load_raw_data(Path(path, f'{RAW_DATA_FILENAME}.csv'))
6465
channels = raw_data.channels
6566
type_amp = raw_data.daq_type
6667
sample_rate = raw_data.sample_rate
6768

69+
devices.load(Path(path, DEFAULT_DEVICE_SPEC_FILENAME))
70+
device_spec = devices.preconfigured_device(raw_data.daq_type)
71+
static_offset = device_spec.static_offset
72+
6873
# setup filtering
6974
default_transform = get_default_transform(
7075
sample_rate_hz=sample_rate,
@@ -82,8 +87,6 @@
8287
)
8388
labels = [0 if label == 'nontarget' else 1 for label in trigger_targetness]
8489

85-
devices.load(Path(path, DEFAULT_DEVICE_SPEC_FILENAME))
86-
device_spec = devices.preconfigured_device(raw_data.daq_type)
8790
channel_map = analysis_channels(channels, device_spec)
8891

8992
save_path = None if not args.save else path

bcipy/helpers/task.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ def get_data_for_decision(inquiry_timing: List[Tuple[str, float]],
180180
def get_device_data_for_decision(
181181
inquiry_timing: List[Tuple[str, float]],
182182
daq: ClientManager,
183-
offset: float = 0.0,
184183
prestim: float = 0.0,
185184
poststim: float = 0.0) -> Dict[ContentType, List[Record]]:
186185
"""Queries the acquisition client manager for a slice of data from each
@@ -206,13 +205,13 @@ def get_device_data_for_decision(
206205
_, last_stim_time = inquiry_timing[-1]
207206

208207
# adjust for offsets
209-
time1 = first_stim_time + offset - prestim
210-
time2 = last_stim_time + offset
208+
time1 = first_stim_time - prestim
209+
time2 = last_stim_time
211210

212211
if time2 < time1:
213212
raise InsufficientDataException(
214213
f'Invalid data query [{time1}-{time2}] with parameters:'
215-
f'[inquiry={inquiry_timing}, offset={offset}, prestim={prestim}, poststim={poststim}]'
214+
f'[inquiry={inquiry_timing}, prestim={prestim}, poststim={poststim}]'
216215
)
217216

218217
data = daq.get_data_by_device(start=time1,

bcipy/helpers/tests/resources/mock_session/parameters.json

-8
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,6 @@
5858
],
5959
"type": "str"
6060
},
61-
"static_trigger_offset": {
62-
"value": ".1",
63-
"section": "bci_config",
64-
"readableName": "Static Trigger Offset",
65-
"helpTip": "Specifies the static trigger offset (in seconds) used to align triggers properly with EEG data from LSL. The system includes built-in offset correction, but there is still a hardware-limited offset between EEG and trigger timing values for which the system does not account. The default value of 0.1 has been verified for OHSU hardware. The correct value may be different for other computers, and must be determined on a case-by-case basis. Default: .1",
66-
"recommended_values": "",
67-
"type": "float"
68-
},
6961
"k_folds": {
7062
"value": "10",
7163
"section": "signal_config",

bcipy/helpers/tests/test_convert.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mockito import any as any_value
1212
from mockito import mock, unstub, verify, verifyNoMoreInteractions, when
1313

14+
import bcipy.acquisition.devices as devices
1415
from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETER_FILENAME,
1516
RAW_DATA_FILENAME, TRIGGER_FILENAME)
1617
from bcipy.helpers import convert
@@ -33,8 +34,7 @@ def create_bcipy_session_artifacts(
3334
'filter_high': 30,
3435
'filter_order': 5,
3536
'notch_filter_frequency': 60,
36-
'down_sampling_rate': 3,
37-
'static_trigger_offset': 0.0
37+
'down_sampling_rate': 3
3838
},
3939
) -> Tuple[str, RawData, Parameters]:
4040
"""Write BciPy session artifacts to a temporary directory.
@@ -44,7 +44,9 @@ def create_bcipy_session_artifacts(
4444
trg_data = MOCK_TRIGGER_DATA
4545
if isinstance(channels, int):
4646
channels = [f'ch{i}' for i in range(channels)]
47-
data = sample_data(ch_names=channels, sample_rate=sample_rate, rows=samples)
47+
data = sample_data(ch_names=channels, daq_type='SampleDevice', sample_rate=sample_rate, rows=samples)
48+
devices.register(devices.DeviceSpec('SampleDevice', channels=channels, sample_rate=sample_rate))
49+
4850
with open(Path(write_dir, TRIGGER_FILENAME), 'w', encoding=DEFAULT_ENCODING) as trg_file:
4951
trg_file.write(trg_data)
5052

@@ -57,7 +59,6 @@ def create_bcipy_session_artifacts(
5759
time_prompt=0.5,
5860
time_flash=0.5,
5961
# define filter settings
60-
static_trigger_offset=filter_settings['static_trigger_offset'],
6162
down_sampling_rate=filter_settings['down_sampling_rate'],
6263
notch_filter_frequency=filter_settings['notch_filter_frequency'],
6364
filter_high=filter_settings['filter_high'],
@@ -348,8 +349,7 @@ def setUp(self) -> None:
348349
'filter_high': 30,
349350
'filter_order': 5,
350351
'notch_filter_frequency': 60,
351-
'down_sampling_rate': 3,
352-
'static_trigger_offset': 0.0
352+
'down_sampling_rate': 3
353353
}
354354
create_bcipy_session_artifacts(
355355
self.temp_dir,
@@ -476,11 +476,12 @@ def setUp(self):
476476
'filter_high': 30,
477477
'filter_order': 5,
478478
'notch_filter_frequency': 60,
479-
'down_sampling_rate': 3,
480-
'static_trigger_offset': 0.0
479+
'down_sampling_rate': 3
481480
}
482481
self.channels = ['timestamp', 'O1', 'O2', 'Pz']
483482
self.raw_data = RawData('SampleDevice', self.sample_rate, self.channels)
483+
devices.register(devices.DeviceSpec('SampleDevice', channels=self.channels, sample_rate=self.sample_rate))
484+
484485
# generate 100 random samples of data
485486
for _ in range(0, 100):
486487
channel_data = gen_random_data(low=-1000,

0 commit comments

Comments
 (0)