Skip to content

Commit 8825b7d

Browse files
committed
Merge branch 'main' into princeton
2 parents 315f09c + c924da9 commit 8825b7d

24 files changed

+890
-123
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,12 @@ pip install -e .
1414
- `projects/task_type_procedures.json` - Associate Alyx procedures to a custom task protocol
1515
- `projects/_template.py` - Example for creating a custom Bpod extractor, QC or DAQ sync task
1616
- `projects/extraction_tasks.py` - Where to import pipeline tasks so they are readable by ibllib
17+
18+
## Contributing
19+
20+
To contribute to this repository you must create a pull request into main. Before merging you must increase the version number in the [pyproject.toml](./pyproject.toml) file (see [this guide](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers) for versioning scheme).
21+
A GitHub check will ensure that the repository version is valid and greater than the version on the main branch. This is essential as we currently do not publish to PyPi.
22+
The pull request may be merged only when this check passes. Bypassing this check is not permitted, nor are direct pushes to main. Once merged, a version tag is automatically generated.
23+
24+
> [!IMPORTANT]
25+
> Tests in this repository are run by both the [iblrig](https://github.com/int-brain-lab/iblrig) and [ibllib](https://github.com/int-brain-lab/ibllib) CI.

iblrig_custom_tasks/_sp_passiveVideo/__init__.py

Whitespace-only changes.

iblrig_custom_tasks/_sp_passiveVideo/task.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
TODO Add custom task list to Session class
55
"""
66
import time
7+
import shutil
78
from pathlib import Path
89
from collections import defaultdict
10+
from functools import partial
911
import logging
1012
import warnings
1113

@@ -15,7 +17,7 @@
1517
import iblrig.misc
1618
from iblrig.base_tasks import BpodMixin
1719

18-
_logger = logging.getLogger(__name__)
20+
_logger = logging.getLogger(f'iblrig.{__name__}')
1921

2022
# this allows the CI and automated tests to import the file and make sure it is valid without having vlc
2123
try:
@@ -27,26 +29,60 @@
2729
'git+https://github.com/int-brain-lab/project_extraction.git"', RuntimeWarning)
2830

2931

32+
class MediaStats(vlc.MediaStats):
33+
"""A class to store media stats."""
34+
35+
def fieldnames(self):
36+
"""Return the field names."""
37+
return zip(*self._fields_)[0]
38+
39+
def as_tuple(self):
40+
"""Return all attribute values as a tuple."""
41+
return tuple(map(partial(getattr, self), self.fieldnames()))
42+
43+
3044
class Player:
3145
"""A VLC player."""
32-
def __init__(self, rate=1, logger=None):
46+
def __init__(self, rate=1):
3347
self._instance = vlc.Instance(['--video-on-top'])
3448
self._player = self._instance.media_player_new()
3549
self._player.set_fullscreen(True)
3650
self._player.set_rate(rate)
3751
self._media = None
52+
self._media_stats = MediaStats()
53+
self._stats = []
3854
self.events = defaultdict(list)
39-
self.logger = logger or _logger
4055
em = self._player.event_manager()
4156
for event in (vlc.EventType.MediaPlayerPlaying, vlc.EventType.MediaPlayerEndReached):
4257
em.event_attach(event, self._record_event)
4358

4459
def _record_event(self, event):
4560
"""VLC event callback."""
46-
self.logger.debug('%s', event.type)
61+
_logger.debug('%s', event.type)
4762
# Have to convert to str as object pointer may change
4863
self.events[str(event.type).split('.')[-1]].append(time.time())
4964

65+
def update_media_stats(self):
66+
"""Update media stats.
67+
68+
Returns
69+
-------
70+
bool
71+
True if the stats have changed since the last update.
72+
"""
73+
if not vlc.libvlc_media_get_stats(self._player.get_media(), self._media_stats):
74+
return False
75+
stats = tuple((time.time(), *self._media_stats.as_tuple()))
76+
if not any(self._stats) or stats[1:] != self._stats[-1][1:]:
77+
self._stats.append(stats)
78+
return True
79+
return False
80+
81+
@property
82+
def stats(self):
83+
"""Return media stats."""
84+
return pd.DataFrame(self._stats, columns=['time', *self._media_stats.fieldnames()])
85+
5086
def play(self, path):
5187
"""Play a video.
5288
@@ -102,69 +138,102 @@ def get_ended_time(self, repeat=-1):
102138
elif repeat == -1 or len(ends) > repeat:
103139
return ends[repeat]
104140

141+
def get_media_length(self):
142+
"""
143+
Return length of the video in seconds.
144+
145+
Returns
146+
-------
147+
float, None
148+
The length of the video in seconds when played at the provided frame rate.
149+
None is returned when no video is loaded.
150+
"""
151+
if self._media:
152+
length = self._media.get_length()
153+
if length > -1:
154+
return length / 1e3
155+
105156

106157
class Session(BpodMixin):
107158
"""Play a single video."""
108159

109160
protocol_name = '_sp_passiveVideo'
161+
extractor_tasks = ['PassiveVideoTimeline']
110162

111163
def __init__(self, **kwargs):
112164
super().__init__(**kwargs)
113165
if self.hardware_settings.get('MAIN_SYNC', False):
114166
raise NotImplementedError('Recording frame2ttl on Bpod not yet implemented')
115167
self.paths.DATA_FILE_PATH = self.paths.DATA_FILE_PATH.with_name('_sp_taskData.raw.pqt')
168+
self.paths.STATS_FILE_PATH = self.paths.DATA_FILE_PATH.with_name('_sp_videoData.stats.pqt')
116169
self.video = None
117170
self.trial_num = -1
171+
# For py3.11 use logging.getLevelNamesMapping instead
172+
self._log_level = logging.getLevelName(kwargs.get('log_level', 'INFO'))
118173
columns = ['intervals_0', 'intervals_1']
119174
self.data = pd.DataFrame(pd.NA, index=range(self.task_params.NREPEATS), columns=columns)
120175

121176
def save(self):
122-
self.logger.info('Saving data')
177+
_logger.info('Saving data')
123178
if self.video:
124179
data = pd.concat([self.data, pd.DataFrame.from_dict(self.video.events)], axis=1)
125180
data.to_parquet(self.paths.DATA_FILE_PATH)
181+
if 20 > self._log_level > 0:
182+
stats = self.video.stats
183+
stats.to_parquet(self.paths.STATS_FILE_PATH)
184+
if self.video._media and self.video._media.get_mrl().endswith(str(self.task_params.VIDEO)):
185+
ext = Path(self.task_params.VIDEO).suffix
186+
video_file_path = self.paths.DATA_FILE_PATH.with_name(f'_sp_video.raw{ext}')
187+
_logger.info('Copying %s -> %s', self.task_params.VIDEO, video_file_path)
188+
shutil.copy(self.task_params.VIDEO, video_file_path)
189+
else:
190+
_logger.warning('Video not copied (video most likely was not played)')
191+
self. self.video._media.get_mrl()
126192
self.paths.SESSION_FOLDER.joinpath('transfer_me.flag').touch()
127193

128194
def start_hardware(self):
129-
self.start_mixin_bpod() # used for protocol spacer only
130-
self.video = Player(logger=self.logger)
195+
self.start_mixin_bpod()
196+
self.video = Player()
131197

132198
def next_trial(self):
133199
"""Start the next trial."""
134200
self.trial_num += 1
135201
self.data.at[self.trial_num, 'intervals_0'] = time.time()
136202
if self.trial_num == 0:
137-
self.logger.info('Starting video %s', self.task_params.VIDEO)
203+
_logger.info('Starting video %s', self.task_params.VIDEO)
138204
self.video.play(self.task_params.VIDEO)
139205
else:
140-
self.logger.debug('Trial #%i: Replaying video', self.trial_num + 1)
206+
_logger.debug('Trial #%i: Replaying video', self.trial_num + 1)
141207
assert self.video
142208
self.video.replay()
143209

144210
def _set_bpod_out(self, val):
145211
"""Set Bpod BNC1 output state."""
212+
BNC_HIGH = 255
213+
BNC_LOW = 0
146214
if isinstance(val, bool):
147-
val = 255 if val else 128
215+
val = BNC_HIGH if val else BNC_LOW
148216
self.bpod.manual_override(Bpod.ChannelTypes.OUTPUT, Bpod.ChannelNames.BNC, channel_number=1, value=val)
149217

150218
def _run(self):
151219
"""This is the method that runs the video."""
152-
# make the bpod send spacer signals to the main sync clock for protocol discovery
153-
self.send_spacers()
154220
for rep in range(self.task_params.NREPEATS): # Main loop
155221
self.next_trial()
156222
self._set_bpod_out(True)
157223
# TODO c.f. MediaListPlayerPlayed event
158224
while not self.video.is_started:
159225
... # takes time to actually start playback
160226
while self.video.is_playing or (end_time := self.video.get_ended_time(rep)) is None:
227+
if 20 > self._log_level > 0:
228+
self.video.update_media_stats()
161229
time.sleep(0.05)
162230
# trial finishes when playback finishes
163231
self._set_bpod_out(False)
164232
self.session_info.NTRIALS += 1
165233
self.data.at[self.trial_num, 'intervals_1'] = time.time()
234+
self.data.at[self.trial_num, 'video_runtime'] = self.video.get_media_length()
166235
dt = self.task_params.ITI_DELAY_SECS - (time.time() - end_time)
167-
self.logger.debug(f'dt = {dt}')
236+
_logger.debug(f'dt = {dt}')
168237
# wait to achieve the desired ITI duration
169238
if dt > 0:
170239
time.sleep(dt)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import unittest
2+
from unittest.mock import Mock
3+
from iblrig_custom_tasks._sp_passiveVideo.task import Session, Player
4+
from iblrig.test.base import TaskArgsMixin
5+
6+
7+
class TestPassiveVideo(TaskArgsMixin, unittest.TestCase):
8+
9+
def setUp(self):
10+
self.get_task_kwargs()
11+
12+
def test_next_trial(self):
13+
self.assertRaises(NotImplementedError, Session, **self.task_kwargs)
14+
self.task_kwargs['hardware_settings']['MAIN_SYNC'] = False
15+
task = Session(log_level='DEBUG', **self.task_kwargs)
16+
task.video = Mock(auto_spec=Player)
17+
task.task_params.VIDEO = r'C:\Users\Work\Downloads\ONE\perlin-xyscale2-tscale50-comb08-5min.mp4'
18+
task.task_params.VIDEO = r'C:\Users\Work\Downloads\SampleVideo_1280x720_1mb.mp4'
19+
task.next_trial()
20+
task.video.play.assert_called_once_with(task.task_params.VIDEO)
21+
task.video.replay.assert_not_called()
22+
task.video.reset_mock()
23+
task.next_trial()
24+
task.video.replay.assert_called_once()
25+
# task.bpod = MagicMock()
26+
# with patch.object(task, 'start_mixin_bpod'):
27+
# task.run()
28+
29+
30+
if __name__ == '__main__':
31+
unittest.main()

iblrig_custom_tasks/nate_adaptiveTimeoutChoiceWorld/__init__.py

Whitespace-only changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
This task inherits TrainingChoiceWorldSession with the addition of configurable, adaptive timeouts for incorrect
3+
choices depending on the stimulus contrast.
4+
"""
5+
6+
import logging
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import numpy as np
11+
import yaml
12+
from pydantic import NonNegativeFloat
13+
14+
from iblrig.misc import get_task_arguments
15+
from iblrig_tasks._iblrig_tasks_trainingChoiceWorld.task import Session as TrainingCWSession
16+
17+
log = logging.getLogger('iblrig.task')
18+
19+
20+
# read defaults from task_parameters.yaml
21+
with open(Path(__file__).parent.joinpath('task_parameters.yaml')) as f:
22+
DEFAULTS = yaml.safe_load(f)
23+
24+
25+
class AdaptiveTimeoutChoiceWorldTrialData(TrainingCWSession.TrialDataModel):
26+
adaptive_delay_nogo: NonNegativeFloat
27+
adaptive_delay_error: NonNegativeFloat
28+
29+
30+
class Session(TrainingCWSession):
31+
protocol_name = 'nate_adaptiveTimeoutChoiceWorld'
32+
TrialDataModel = AdaptiveTimeoutChoiceWorldTrialData
33+
34+
def __init__(
35+
self,
36+
*args,
37+
adaptive_delay_nogo=DEFAULTS['ADAPTIVE_FEEDBACK_NOGO_DELAY_SECS'],
38+
adaptive_delay_error=DEFAULTS['ADAPTIVE_FEEDBACK_ERROR_DELAY_SECS'],
39+
**kwargs,
40+
):
41+
self._adaptive_delay_nogo = adaptive_delay_nogo
42+
self._adaptive_delay_error = adaptive_delay_error
43+
super().__init__(*args, **kwargs)
44+
assert len(self._adaptive_delay_nogo) == len(self.task_params.CONTRAST_SET)
45+
assert len(self._adaptive_delay_error) == len(self.task_params.CONTRAST_SET)
46+
47+
def draw_next_trial_info(self, **kwargs):
48+
super().draw_next_trial_info(**kwargs)
49+
contrast = self.trials_table.at[self.trial_num, 'contrast']
50+
index = np.flatnonzero(np.array(self.task_params['CONTRAST_SET']) == contrast)[0]
51+
self.trials_table.at[self.trial_num, 'adaptive_delay_nogo'] = self._adaptive_delay_nogo[index]
52+
self.trials_table.at[self.trial_num, 'adaptive_delay_error'] = self._adaptive_delay_error[index]
53+
54+
@property
55+
def feedback_nogo_delay(self):
56+
return self.trials_table.at[self.trial_num, 'adaptive_delay_nogo']
57+
58+
@property
59+
def feedback_error_delay(self):
60+
return self.trials_table.at[self.trial_num, 'adaptive_delay_error']
61+
62+
def show_trial_log(self, extra_info: dict[str, Any] | None = None, log_level: int = logging.INFO):
63+
trial_info = self.trials_table.iloc[self.trial_num]
64+
info_dict = {
65+
'Adaptive no-go delay': f'{trial_info.adaptive_delay_nogo:.2f} s',
66+
'Adaptive error delay': f'{trial_info.adaptive_delay_error:.2f} s',
67+
}
68+
if isinstance(extra_info, dict):
69+
info_dict.update(extra_info)
70+
super().show_trial_log(extra_info=info_dict, log_level=log_level)
71+
72+
@staticmethod
73+
def extra_parser():
74+
parser = super(Session, Session).extra_parser()
75+
parser.add_argument(
76+
'--adaptive_delay_nogo',
77+
option_strings=['--adaptive_delay_nogo'],
78+
dest='adaptive_delay_nogo',
79+
default=DEFAULTS['ADAPTIVE_FEEDBACK_NOGO_DELAY_SECS'],
80+
nargs='+',
81+
type=float,
82+
help='list of delays for no-go condition (contrasts: 1.0, 0.5, 0.25, 0.125, 0.0625, 0.0)',
83+
)
84+
parser.add_argument(
85+
'--adaptive_delay_error',
86+
option_strings=['--adaptive_delay_error'],
87+
dest='adaptive_delay_error',
88+
default=DEFAULTS['ADAPTIVE_FEEDBACK_ERROR_DELAY_SECS'],
89+
nargs='+',
90+
type=float,
91+
help='list of delays for error condition (contrasts: 1.0, 0.5, 0.25, 0.125, 0.0625, 0.0)',
92+
)
93+
return parser
94+
95+
96+
if __name__ == '__main__': # pragma: no cover
97+
kwargs = get_task_arguments(parents=[Session.extra_parser()])
98+
sess = Session(**kwargs)
99+
sess.run()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'ADAPTIVE_GAIN': True
2+
'ADAPTIVE_REWARD': True
3+
'AG_INIT_VALUE': 8.0 # Adaptive Gain init value. Once the mouse completes 200 response trials whithin a session, this reverts to STIM_GAIN
4+
'CONTRAST_SET_PROBABILITY_TYPE': skew_zero # uniform, skew_zero
5+
'DEBIAS': True # Whether to use debiasing rule or not by repeating error trials
6+
'REWARD_AMOUNT_UL': 3.0 # Reward amount (uL), will oscillate between 1.5 and 3 uL depending on previous sessions if adaptive_reward is True
7+
8+
'CONTRAST_SET': [1.0, 0.5, 0.25, 0.125, 0.0625, 0.0]
9+
'ADAPTIVE_FEEDBACK_NOGO_DELAY_SECS': [2.0, 2.0, 2.0, 2.0, 2.0, 2.0]
10+
'ADAPTIVE_FEEDBACK_ERROR_DELAY_SECS': [2.0, 2.0, 2.0, 2.0, 2.0, 2.0]

0 commit comments

Comments
 (0)