• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

int-brain-lab / iblrig / 15738036488

18 Jun 2025 04:10PM UTC coverage: 48.249% (+1.5%) from 46.79%
15738036488

Pull #815

github

9b495a
web-flow
Merge fd70c12e3 into 5c537cbb7
Pull Request #815: extended tests for photometry copier

23 of 32 new or added lines in 1 file covered. (71.88%)

1106 existing lines in 22 files now uncovered.

4408 of 9136 relevant lines covered (48.25%)

0.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

21.22
/iblrig/gui/wizard.py
1
import argparse
2✔
2
import ctypes
2✔
3
import json
2✔
4
import logging
2✔
5
import os
2✔
6
import re
2✔
7
import shutil
2✔
8
import subprocess
2✔
9
import sys
2✔
10
import traceback
2✔
11
from collections import OrderedDict
2✔
12
from dataclasses import dataclass
2✔
13
from importlib.metadata import entry_points
2✔
14
from importlib.util import module_from_spec, spec_from_file_location
2✔
15
from pathlib import Path
2✔
16

17
import pyqtgraph as pg
2✔
18
from pydantic import ValidationError
2✔
19
from PyQt5 import QtCore, QtGui, QtWidgets
2✔
20
from PyQt5.QtCore import QThreadPool, pyqtSlot
2✔
21
from PyQt5.QtWidgets import QStyle
2✔
22
from requests import HTTPError
2✔
23
from serial import SerialException
2✔
24
from typing_extensions import override
2✔
25

26
import iblrig.hardware_validation
2✔
27
import iblrig.path_helper
2✔
28
import iblrig_tasks
2✔
29
from ibllib.io.raw_data_loaders import load_settings
2✔
30
from iblrig.base_tasks import BaseSession, EmptySession
2✔
31
from iblrig.choiceworld import compute_adaptive_reward_volume, get_subject_training_info, training_phase_from_contrast_set
2✔
32
from iblrig.constants import BASE_DIR
2✔
33
from iblrig.gui.frame2ttl import Frame2TTLCalibrationDialog
2✔
34
from iblrig.gui.splash import Splash
2✔
35
from iblrig.gui.tab_about import TabAbout
2✔
36
from iblrig.gui.tab_data import TabData
2✔
37
from iblrig.gui.tab_docs import TabDocs
2✔
38
from iblrig.gui.tab_log import TabLog
2✔
39
from iblrig.gui.tools import DiskSpaceIndicator, RemoteDevicesItemModel, Worker
2✔
40
from iblrig.gui.ui_login import Ui_login
2✔
41
from iblrig.gui.ui_update import Ui_update
2✔
42
from iblrig.gui.ui_wizard import Ui_wizard
2✔
43
from iblrig.gui.validation import SystemValidationDialog
2✔
44
from iblrig.gui.valve import ValveCalibrationDialog
2✔
45
from iblrig.hardware import Bpod
2✔
46
from iblrig.hardware_validation import Status
2✔
47
from iblrig.misc import get_task_argument_parser
2✔
48
from iblrig.path_helper import load_pydantic_yaml
2✔
49
from iblrig.pydantic_definitions import HardwareSettings, RigSettings
2✔
50
from iblrig.raw_data_loaders import load_task_jsonable
2✔
51
from iblrig.tools import alyx_reachable, get_lab_location_dict, internet_available
2✔
52
from iblrig.valve import Valve
2✔
53
from iblrig.version_management import check_for_updates, get_changelog
2✔
54
from iblutil.util import Bunch, setup_logger
2✔
55
from one.webclient import AlyxClient
2✔
56
from pybpodapi.exceptions.bpod_error import BpodErrorException
2✔
57

58
try:
2✔
59
    import iblrig_custom_tasks
2✔
60

61
    CUSTOM_TASKS = True
2✔
UNCOV
62
except ImportError:
×
UNCOV
63
    CUSTOM_TASKS = False
×
64

65
log = logging.getLogger(__name__)
2✔
66
pg.setConfigOption('foreground', 'k')
2✔
67
pg.setConfigOptions(antialias=True)
2✔
68

69
PROCEDURES = [
2✔
70
    'Behavior training/tasks',
71
    'Ephys recording with acute probe(s)',
72
    'Ephys recording with chronic probe(s)',
73
    'Fiber photometry',
74
    'handling_habituation',
75
    'Imaging',
76
]
77
PROJECTS = ['ibl_neuropixel_brainwide_01', 'practice']
2✔
78

79
ANSI_COLORS: dict[str, str] = {'31': 'Red', '32': 'Green', '33': 'Yellow', '35': 'Magenta', '36': 'Cyan', '37': 'White'}
2✔
80
REGEX_STDOUT = re.compile(
2✔
81
    r'^\x1b\[(?:\d;)?(?:\d+;)?'
82
    r'(?P<color>\d+)m[\d-]*\s+'
83
    r'(?P<time>[\d\:]+)\s+'
84
    r'(?P<level>\w+\s+)'
85
    r'(?P<file>[\w\:\.]+)\s+'
86
    r'(?P<message>[^\x1b]*)',
87
    re.MULTILINE,
88
)
89

90

91
def _set_list_view_from_string_list(ui_list: QtWidgets.QListView, string_list: list):
2✔
92
    """Small boiler plate util to set the selection of a list view from a list of strings."""
UNCOV
93
    if string_list is None or len(string_list) == 0:
×
UNCOV
94
        return
×
UNCOV
95
    for i, s in enumerate(ui_list.model().stringList()):
×
UNCOV
96
        if s in string_list:
×
UNCOV
97
            ui_list.selectionModel().select(ui_list.model().createIndex(i, 0), QtCore.QItemSelectionModel.Select)
×
98

99

100
@dataclass
2✔
101
class RigWizardModel:
2✔
102
    alyx: AlyxClient | None = None
2✔
103
    procedures: list | None = None
2✔
104
    projects: list | None = None
2✔
105
    task_name: str | None = None
2✔
106
    user: str | None = None
2✔
107
    subject: str | None = None
2✔
108
    session_folder: Path | None = None
2✔
109
    test_subject_name = 'test_subject'
2✔
110
    free_reward_time: float | None = None
2✔
111
    file_iblrig_settings: Path | str | None = None
2✔
112
    file_hardware_settings: Path | str | None = None
2✔
113

114
    def __post_init__(self):
2✔
115
        self.iblrig_settings: RigSettings = load_pydantic_yaml(RigSettings, filename=self.file_iblrig_settings, do_raise=True)
2✔
116
        self.hardware_settings: HardwareSettings = load_pydantic_yaml(
2✔
117
            HardwareSettings, filename=self.file_hardware_settings, do_raise=True
118
        )
119

120
        self.free_reward_time = Valve(self.hardware_settings.device_valve).free_reward_time_sec
2✔
121

122
        if self.iblrig_settings.ALYX_URL is not None:
2✔
UNCOV
123
            self.alyx = AlyxClient(base_url=str(self.iblrig_settings.ALYX_URL), silent=True)
×
124

125
        self.all_users = [self.iblrig_settings['ALYX_USER']] if self.iblrig_settings['ALYX_USER'] else []
2✔
126
        self.all_procedures = sorted(PROCEDURES)
2✔
127
        self.all_projects = sorted(PROJECTS)
2✔
128

129
        # for the tasks, we build a dictionary that contains the task name as key and the path to task.py as value
130
        tasks = sorted([p for p in Path(iblrig_tasks.__file__).parent.rglob('task.py')])
2✔
131
        if CUSTOM_TASKS:
2✔
132
            tasks.extend(sorted([p for p in Path(iblrig_custom_tasks.__file__).parent.rglob('task.py')]))
2✔
133
        self.all_tasks = OrderedDict({p.parts[-2]: p for p in tasks})
2✔
134

135
        # include external tasks registered as plugins
136
        for plugin in sorted(entry_points(group='iblrig.plugins'), key=lambda ep: ep.name):
2✔
UNCOV
137
            if plugin.name.startswith('task_') and issubclass(session := plugin.load(), BaseSession):
×
UNCOV
138
                self.all_tasks[session.protocol_name] = session.get_task_file()
×
139

140
        # get the subjects from iterating over folders in the the iblrig data path
141
        if self.iblrig_settings['iblrig_local_data_path'] is None:
2✔
142
            self.all_subjects = [self.test_subject_name]
2✔
143
        else:
UNCOV
144
            folder_subjects = Path(self.iblrig_settings['iblrig_local_data_path']).joinpath(
×
145
                self.iblrig_settings['ALYX_LAB'], 'Subjects'
146
            )
UNCOV
147
            self.all_subjects = [self.test_subject_name] + sorted(
×
148
                [f.name for f in folder_subjects.glob('*') if f.is_dir() and f.name != self.test_subject_name]
149
            )
150

151
    def get_session(self, task_name: str) -> BaseSession:
2✔
152
        """
153
        Get a session object for the given task name.
154

155
        Parameters
156
        ----------
157
        task_name: str
158
            The name of the task
159

160
        Returns
161
        -------
162
        BaseSession
163
            The session object for the given task name
164
        """
165
        spec = spec_from_file_location('task', self.all_tasks[task_name])
2✔
166
        task = module_from_spec(spec)
2✔
167
        sys.modules[spec.name] = task
2✔
168
        spec.loader.exec_module(task)
2✔
169
        return task.Session
2✔
170

171
    def get_task_extra_parser(self, task_name: str):
2✔
172
        """
173
        Get an extra parser for the given task name.
174

175
        Parameters
176
        ----------
177
        task_name
178
            The name of the task
179

180
        Returns
181
        -------
182
        ArgumentParser
183
            The extra parser for the given task name
184
        """
185
        return self.get_session(task_name).extra_parser()
2✔
186

187
    def get_task_parameters(self, task_name: str) -> Bunch:
2✔
188
        """
189
        Return parameters for the given task.
190

191
        Parameters
192
        ----------
193
        task_name
194
            The name of the task
195

196
        Returns
197
        -------
198
        Bunch
199
            The parameters for the given task
200
        """
201
        return self.get_session(task_name).read_task_parameter_files()
×
202

203
    @property
2✔
204
    def task_file(self) -> Path:
2✔
205
        return self.all_tasks.get(self.task_name, None)
×
206

207
    def login(
2✔
208
        self,
209
        username: str,
210
        password: str | None = None,
211
        do_cache: bool = False,
212
        alyx_client: AlyxClient | None = None,
213
        gui: bool = False,
214
    ) -> bool:
215
        # Use predefined AlyxClient for testing purposes:
216
        if alyx_client is not None:
2✔
217
            self.alyx = alyx_client
2✔
218

219
        # Alternatively, try to log in:
220
        else:
UNCOV
221
            try:
×
222
                self.alyx.authenticate(username, password, do_cache, force=password is not None)
×
223
                if self.alyx.is_logged_in and self.alyx.user == username:
×
224
                    self.user = self.alyx.user
×
225
                    log.info(f'Logged into {self.alyx.base_url} as {self.alyx.user}')
×
226
                else:
227
                    return False
×
228
            except HTTPError as e:
×
UNCOV
229
                if e.errno == 400 and any(x in e.response.text for x in ('credentials', 'required')):
×
UNCOV
230
                    log.error(e.filename)
×
UNCOV
231
                    return False
×
232
                else:
UNCOV
233
                    raise e
×
234

235
        # validate connection and some parameters now that we're connected
236
        try:
2✔
237
            self.alyx.rest(
2✔
238
                'locations',
239
                'partial_update',
240
                id=self.hardware_settings.RIG_NAME,
241
                data={'json': get_lab_location_dict(self.hardware_settings, self.iblrig_settings)},
242
            )
243
        except HTTPError as ex:
×
UNCOV
244
            if ex.response.status_code not in (404, 400):  # file not found; auth error
×
245
                # Likely Alyx is down or server-side issue
UNCOV
246
                message = 'Failed to determine lab location on Alyx'
×
UNCOV
247
                solution = 'Check if Alyx is reachable'
×
248
            else:
UNCOV
249
                message = f'Could not find rig name {self.hardware_settings.RIG_NAME} in Alyx'
×
UNCOV
250
                solution = (
×
251
                    f'Please check the RIG_NAME key in hardware_settings.yaml and make sure it is created in Alyx here: '
252
                    f'{self.iblrig_settings.ALYX_URL}/admin/misc/lablocation/'
253
                )
UNCOV
254
            QtWidgets.QMessageBox().critical(None, 'Error', f'{message}\n\n{solution}')
×
255

256
        # get subjects from Alyx: this is the set of subjects that are alive and in the lab defined in settings
257
        # stock subjects are excluded, unless the user is stock manager
258
        kwargs = {'alive': True, 'lab': self.iblrig_settings['ALYX_LAB'], 'no_cache': True}
2✔
259
        is_stock_manager = any(self.alyx.rest('subjects', 'list', responsible_user=self.user, stock=True, limit=1, **kwargs))
2✔
260
        if not is_stock_manager:
2✔
261
            kwargs['stock'] = False
2✔
262
        rest_subjects = self.alyx.rest('subjects', 'list', **kwargs)
2✔
263
        self.all_subjects.remove(self.test_subject_name)
2✔
264
        self.all_subjects = [self.test_subject_name] + sorted(set(self.all_subjects + [s['nickname'] for s in rest_subjects]))
2✔
265

266
        # then get the projects that map to the current user
267
        rest_projects = self.alyx.rest('projects', 'list', no_cache=True)
2✔
268
        projects = [p['name'] for p in rest_projects if (username in p['users'] or len(p['users']) == 0)]
2✔
269
        self.all_projects = sorted(set(projects + self.all_projects))
2✔
270

271
        return True
2✔
272

273
    def logout(self):
2✔
UNCOV
274
        if not self.alyx.is_logged_in or self.alyx.user is not self.user:
×
275
            return
×
276
        log.info(f'User {self.user} logged out')
×
277
        self.alyx.logout()
×
278
        self.user = None
×
279
        self.__post_init__()
×
280

281
    def free_reward(self):
2✔
UNCOV
282
        try:
×
UNCOV
283
            bpod = Bpod(
×
284
                self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True, disable_behavior_ports=[1, 2, 3]
285
            )
UNCOV
286
            bpod.pulse_valve(open_time_s=self.free_reward_time)
×
287
        except (OSError, BpodErrorException):
×
288
            log.error('Cannot find bpod - is it connected?')
×
289
            return
×
290

291

292
class RigWizard(QtWidgets.QMainWindow, Ui_wizard):
2✔
293
    training_info: dict = {}
2✔
294
    session_info: dict = {}
2✔
295
    task_parameters: dict | None = None
2✔
296
    new_subject_details = QtCore.pyqtSignal()
2✔
297
    append_session: bool = False
2✔
298
    previous_subject: str | None = None
2✔
299

300
    def __init__(self, debug: bool = False, remote_devices: bool = False):
2✔
301
        super().__init__()
×
302
        self.setupUi(self)
×
303

304
        # load tabs
305
        self.tabLog = TabLog(parent=self.tabWidget)
×
306
        self.tabData = TabData(parent=self.tabWidget)
×
UNCOV
307
        self.tabDocs = TabDocs(parent=self.tabWidget)
×
308
        self.tabAbout = TabAbout(parent=self.tabWidget)
×
309
        self.tabWidget.addTab(self.tabLog, QtGui.QIcon(':/images/log'), 'Log')
×
UNCOV
310
        self.tabWidget.addTab(self.tabData, QtGui.QIcon(':/images/sessions'), 'Data')
×
311
        self.tabWidget.addTab(self.tabDocs, QtGui.QIcon(':/images/help'), 'Docs')
×
312
        self.tabWidget.addTab(self.tabAbout, QtGui.QIcon(':/images/about'), 'About')
×
313
        self.tabWidget.setCurrentIndex(0)
×
314

315
        self.debug = debug
×
316
        self.settings = QtCore.QSettings()
×
317

318
        try:
×
319
            self.model = RigWizardModel()
×
320
        except ValidationError as e:
×
321
            yml = (
×
322
                'hardware_settings.yaml'
323
                if 'hardware' in e.title
324
                else 'iblrig_settings.yaml'
325
                if 'iblrig' in e.title
326
                else 'Settings File'
327
            )
UNCOV
328
            description = ''
×
329
            for error in e.errors():
×
330
                key = '.'.join(error.get('loc', ''))
×
331
                val = error.get('input', '')
×
332
                msg = error.get('msg', '')
×
333
                description += (
×
334
                    f'<table>'
335
                    f'<tr><td><b>key:</b></td><td><td>{key}</td></tr>\n'
336
                    f'<tr><td><b>value:</b></td><td><td>{val}</td></tr>\n'
337
                    f'<tr><td><b>error:</b></td><td><td>{msg}</td></tr></table><br>\n'
338
                )
339
            self._show_error_dialog(title=f'Error validating {yml}', description=description.strip())
×
340
            raise e
×
341

342
        # remote devices (only show if at least one device was found)
343
        if remote_devices:
×
344
            self.remoteDevicesModel = RemoteDevicesItemModel(iblrig_settings=self.model.iblrig_settings)
×
345
            self.listViewRemoteDevices.setModel(self.remoteDevicesModel)
×
346
        else:
347
            self.listViewRemoteDevices.setVisible(False)
×
UNCOV
348
            self.labelRemoteDevices.setVisible(False)
×
349

350
        # task parameters and subject details
351
        self.uiComboTask.currentTextChanged.connect(self._controls_for_task_arguments)
×
UNCOV
352
        self.uiComboTask.currentTextChanged.connect(self._get_task_parameters)
×
UNCOV
353
        self.uiComboSubject.currentTextChanged.connect(self._get_subject_details)
×
354
        self.new_subject_details.connect(self._set_automatic_values)
×
355
        self.model2view()
×
356

357
        # connect widgets signals to slots
UNCOV
358
        self.uiActionValidateHardware.triggered.connect(self._on_validate_hardware)
×
359
        self.uiActionCalibrateFrame2ttl.triggered.connect(self._on_calibrate_frame2ttl)
×
UNCOV
360
        self.uiActionCalibrateValve.triggered.connect(self._on_calibrate_valve)
×
361
        self.uiActionTrainingLevelV7.triggered.connect(self._on_menu_training_level_v7)
×
362

363
        self.uiPushStart.clicked.connect(self.start_stop)
×
UNCOV
364
        self.uiPushPause.clicked.connect(self.pause)
×
365
        self.uiListProjects.clicked.connect(self._enable_ui_elements)
×
366
        self.uiListProcedures.clicked.connect(self._enable_ui_elements)
×
UNCOV
367
        self.lineEditSubject.textChanged.connect(self._filter_subjects)
×
368

369
        self.running_task_process = None
×
370
        self.task_arguments = dict()
×
UNCOV
371
        self.task_settings_widgets = None
×
372

373
        self.uiPushStart.installEventFilter(self)
×
374
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
375
        self.uiPushPause.setIcon(self.style().standardIcon(QStyle.SP_MediaPause))
×
376

377
        self.controller2model()
×
378

379
        self.tabWidget.currentChanged.connect(self._on_switch_tab)
×
380

381
        # username
382
        if self.iblrig_settings.ALYX_URL is not None:
×
383
            self.uiLineEditUser.returnPressed.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
384
            self.uiPushButtonLogIn.released.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
385
        else:
386
            self.uiLineEditUser.setPlaceholderText('')
×
387
            self.uiPushButtonLogIn.setEnabled(False)
×
388

389
        # tools
390
        self.uiPushFlush.clicked.connect(self.flush)
×
UNCOV
391
        self.uiPushReward.clicked.connect(self.model.free_reward)
×
UNCOV
392
        self.uiPushReward.setStatusTip(
×
393
            f'Click to grant a free reward ({self.hardware_settings.device_valve.FREE_REWARD_VOLUME_UL:.1f} μL)'
394
        )
395
        self.uiPushStatusLED.setChecked(self.settings.value('bpod_status_led', True, bool))
×
396
        self.uiPushStatusLED.toggled.connect(self.toggle_status_led)
×
UNCOV
397
        self.toggle_status_led(self.uiPushStatusLED.isChecked())
×
398

399
        # statusbar / disk stats
UNCOV
400
        local_data = self.iblrig_settings['iblrig_local_data_path']
×
401
        local_data = Path(local_data) if local_data else Path.home().joinpath('iblrig_data')
×
402
        self.uiDiskSpaceIndicator = DiskSpaceIndicator(parent=self.statusbar, directory=local_data)
×
403
        self.uiDiskSpaceIndicator.setMaximumWidth(70)
×
404
        self.statusbar.addPermanentWidget(self.uiDiskSpaceIndicator)
×
405
        self.statusbar.setContentsMargins(0, 0, 6, 0)
×
406

407
        # disable control of LED if Bpod does not have the respective capability
408
        try:
×
UNCOV
409
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
UNCOV
410
            self.uiPushStatusLED.setEnabled(bpod.can_control_led)
×
411
        except SerialException:
×
412
            pass
×
413

414
        # show splash-screen / store validation results
UNCOV
415
        splash_screen = Splash(parent=self)
×
416
        splash_screen.exec()
×
417
        self.validation_results = splash_screen.validation_results
×
418

419
        # check for update
UNCOV
420
        update_worker = Worker(check_for_updates)
×
421
        update_worker.signals.result.connect(self._on_check_update_result)
×
UNCOV
422
        QThreadPool.globalInstance().start(update_worker)
×
423

424
        # show GUI
425
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowFullscreenButtonHint)
×
UNCOV
426
        self.move(self.settings.value('pos', self.pos(), QtCore.QPoint))
×
UNCOV
427
        self.resize(self.settings.value('size', self.size(), QtCore.QSize))
×
428
        self.show()
×
429

430
        # show validation errors / warnings:
UNCOV
431
        if any(results := [r for r in self.validation_results if r.status in (Status.FAIL, Status.WARN)]):
×
UNCOV
432
            msg_box = QtWidgets.QMessageBox(parent=self)
×
433
            msg_box.setWindowTitle('IBLRIG System Validation')
×
434
            msg_box.setIcon(QtWidgets.QMessageBox().Warning)
×
UNCOV
435
            msg_box.setTextFormat(QtCore.Qt.TextFormat.RichText)
×
UNCOV
436
            text = f'The following issue{"s were" if len(results) > 1 else " was"} detected:'
×
437
            for result in results:
×
438
                text = (
×
439
                    text + f'<br><br>\n'
440
                    f'<b>{"Warning" if result.status == Status.WARN else "Failure"}:</b> {result.message}<br>\n'
441
                    f'{("<b>Suggestion:</b> " + result.solution) if result.solution is not None else ""}'
442
                )
UNCOV
443
            text = text + '<br><br>\nPlease refer to the System Validation tool for more details.'
×
444
            msg_box.setText(text)
×
445
            msg_box.exec()
×
446

447
    @property
2✔
448
    def iblrig_settings(self) -> RigSettings:
2✔
UNCOV
449
        return self.model.iblrig_settings
×
450

451
    @property
2✔
452
    def hardware_settings(self) -> HardwareSettings:
2✔
453
        return self.model.hardware_settings
×
454

455
    def _get_task_parameters(self, task_name):
2✔
456
        worker = Worker(self.model.get_task_parameters, task_name)
×
457
        worker.signals.result.connect(self._on_task_parameters_result)
×
UNCOV
458
        QThreadPool.globalInstance().start(worker)
×
459

460
    def _on_task_parameters_result(self, result):
2✔
461
        self.task_parameters = result
×
462
        self._get_subject_details(self.uiComboSubject.currentText())
×
463

464
    def _get_subject_details(self, subject_name: str):
2✔
465
        if not isinstance(subject_name, str) or self.task_parameters is None:
×
466
            return
×
UNCOV
467
        worker = Worker(
×
468
            get_subject_training_info,
469
            subject_name=subject_name,
470
            task_name=self.uiComboTask.currentText(),
471
            stim_gain=self.task_parameters.get('AG_INIT_VALUE'),
472
            stim_gain_on_error=self.task_parameters.get('STIM_GAIN'),
473
            default_reward=self.task_parameters.get('REWARD_AMOUNT_UL'),
474
        )
475
        worker.signals.result.connect(self._on_subject_details_result)
×
476
        QThreadPool.globalInstance().start(worker)
×
477

478
    def _on_subject_details_result(self, result):
2✔
UNCOV
479
        self.training_info, self.session_info = result
×
UNCOV
480
        self.new_subject_details.emit()
×
481

482
    def _show_error_dialog(
2✔
483
        self,
484
        title: str,
485
        description: str,
486
        issues: list[str] | None = None,
487
        suggestions: list[str] | None = None,
488
        leads: list[str] | None = None,
489
    ):
UNCOV
490
        text = description.strip()
×
491

UNCOV
492
        def build_list(items: list[str] or None, header_singular: str, header_plural: str | None = None):
×
493
            nonlocal text
494
            if items is None or len(items) == 0:
×
495
                return
×
496
            if len(items) > 1:
×
UNCOV
497
                if header_plural is None:
×
UNCOV
498
                    header_plural = header_singular.strip() + 's'
×
499
                text += f'<br><br>{header_plural}:<ul>'
×
500
            else:
UNCOV
501
                text += f'<br><br>{header_singular.strip()}:<ul>'
×
502
            for item in items:
×
UNCOV
503
                text += f'<li>{item.strip()}</li>'
×
UNCOV
504
            text += '</ul>'
×
505

506
        build_list(issues, 'Possible issue')
×
UNCOV
507
        build_list(suggestions, 'Suggested action')
×
UNCOV
508
        build_list(leads, 'Possible lead')
×
509
        QtWidgets.QMessageBox.critical(self, title, text)
×
510

511
    def _on_switch_tab(self, index):
2✔
512
        # if self.tabWidget.tabText(index) == 'Session':
513
        # QtCore.QTimer.singleShot(1, lambda: self.resize(self.minimumSizeHint()))
514
        # self.adjustSize()
UNCOV
515
        pass
×
516

517
    def _on_validate_hardware(self) -> None:
2✔
518
        SystemValidationDialog(self, hardware_settings=self.hardware_settings, rig_settings=self.iblrig_settings)
×
519

520
    def _on_calibrate_frame2ttl(self) -> None:
2✔
521
        Frame2TTLCalibrationDialog(self, hardware_settings=self.hardware_settings)
×
522

523
    def _on_calibrate_valve(self) -> None:
2✔
524
        ValveCalibrationDialog(self)
×
525

526
    def _on_menu_training_level_v7(self) -> None:
2✔
527
        """
528
        Prompt user for a session path to get v7 training level.
529

530
        This code will be removed and is here only for convenience while users transition from v7 to v8
531
        """
532
        # get session path
UNCOV
533
        if not (local_path := Path(r'C:\iblrig_data\Subjects')).exists():
×
534
            local_path = self.iblrig_settings.iblrig_local_data_path
×
535
        session_path = QtWidgets.QFileDialog.getExistingDirectory(
×
536
            self, 'Select Session Path', str(local_path), QtWidgets.QFileDialog.ShowDirsOnly
537
        )
538
        if session_path is None or session_path == '':
×
539
            return
×
540

541
        # get trials table
UNCOV
542
        file_jsonable = next(Path(session_path).glob('raw_behavior_data/_iblrig_taskData.raw.jsonable'), None)
×
543
        if file_jsonable is None:
×
544
            QtWidgets.QMessageBox().critical(self, 'Error', f'No jsonable found in {session_path}')
×
545
            return
×
546
        trials_table, _ = load_task_jsonable(file_jsonable)
×
547
        if trials_table.empty:
×
548
            QtWidgets.QMessageBox().critical(self, 'Error', f'No trials found in {session_path}')
×
UNCOV
549
            return
×
550

551
        # get task settings
552
        task_settings = load_settings(session_path, task_collection='raw_behavior_data')
×
UNCOV
553
        if task_settings is None:
×
UNCOV
554
            QtWidgets.QMessageBox().critical(self, 'Error', f'No task settings found in {session_path}')
×
555
            return
×
556

557
        # compute values
558
        contrast_set = trials_table['signed_contrast'].abs().unique()
×
559
        training_phase = training_phase_from_contrast_set(contrast_set)
×
560
        previous_reward_volume = (
×
561
            task_settings.get('ADAPTIVE_REWARD_AMOUNT_UL')
562
            or task_settings.get('REWARD_AMOUNT_UL')
563
            or task_settings.get('REWARD_AMOUNT')
564
        )
565
        reward_amount = compute_adaptive_reward_volume(
×
566
            subject_weight_g=task_settings['SUBJECT_WEIGHT'],
567
            reward_volume_ul=previous_reward_volume,
568
            delivered_volume_ul=trials_table['reward_amount'].sum(),
569
            ntrials=trials_table.shape[0],
570
        )
571
        stim_gain = trials_table['stim_gain'].values[-1]
×
572

573
        # display results
UNCOV
574
        box = QtWidgets.QMessageBox(parent=self)
×
575
        box.setIcon(QtWidgets.QMessageBox.Information)
×
576
        box.setModal(False)
×
577
        box.setWindowTitle('Training Level')
×
578
        box.setText(
×
579
            f'{session_path}\n\ntraining phase:\t{training_phase}\nreward:\t{reward_amount:.2f} uL\nstimulus gain:\t{stim_gain}'
580
        )
581
        if self.uiComboTask.currentText() == '_iblrig_tasks_trainingChoiceWorld':
×
582
            box.setStandardButtons(QtWidgets.QMessageBox.Apply | QtWidgets.QMessageBox.Close)
×
583
        else:
UNCOV
584
            box.setStandardButtons(QtWidgets.QMessageBox.Close)
×
UNCOV
585
        box.exec()
×
UNCOV
586
        if box.clickedButton() == box.button(QtWidgets.QMessageBox.Apply):
×
587
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--adaptive_gain').setValue(stim_gain)
×
588
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--adaptive_reward').setValue(reward_amount)
×
589
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--training_phase').setValue(training_phase)
×
590

591
    def _on_check_update_result(self, result: tuple[bool, str]) -> None:
2✔
592
        """
593
        Handle the result of checking for updates.
594

595
        Parameters
596
        ----------
597
        result : tuple[bool, str | None]
598
            A tuple containing a boolean flag indicating update availability (result[0])
599
            and the remote version string (result[1]).
600

601
        Returns
602
        -------
603
        None
604
        """
605
        if result[0]:
×
UNCOV
606
            UpdateNotice(parent=self, version=result[1])
×
607

608
    def _log_in_or_out(self, username: str) -> bool:
2✔
609
        # Routine for logging out:
610
        if self.uiPushButtonLogIn.text() == 'Log Out':
×
UNCOV
611
            self.model.logout()
×
UNCOV
612
            self.uiLineEditUser.setText('')
×
613
            self.uiLineEditUser.setReadOnly(False)
×
614
            for action in self.uiLineEditUser.actions():
×
615
                self.uiLineEditUser.removeAction(action)
×
UNCOV
616
            self.uiLineEditUser.setStyleSheet('')
×
617
            self.uiLineEditUser.actions()
×
618
            self.uiPushButtonLogIn.setText('Log In')
×
619
            return True
×
620

621
        # Routine for logging in:
622
        # 1) Try to log in with just the username. This will succeed if the credentials for the respective user are cached. We
623
        #    also try to catch connection issues and show helpful error messages.
UNCOV
624
        try:
×
UNCOV
625
            logged_in = self.model.login(username, gui=True)
×
UNCOV
626
        except ConnectionError:
×
627
            if not internet_available(timeout=1, force_update=True):
×
628
                self._show_error_dialog(
×
629
                    title='Error connecting to Alyx',
630
                    description='Your computer appears to be offline.',
631
                    suggestions=['Check your internet connection.'],
632
                )
633
            elif not alyx_reachable():
×
UNCOV
634
                self._show_error_dialog(
×
635
                    title='Error connecting to Alyx',
636
                    description=f'Cannot connect to {self.iblrig_settings.ALYX_URL}',
637
                    leads=[
638
                        'Is `ALYX_URL` in `iblrig_settings.yaml` set correctly?',
639
                        'Is your machine allowed to connect to Alyx?',
640
                        'Is the Alyx server up and running nominally?',
641
                    ],
642
                )
UNCOV
643
            return False
×
644

645
        # 2) If there is no cached session for the given user and we can connect to Alyx: show the password dialog and loop
646
        #    until, either, the login was successful or the cancel button was pressed.
UNCOV
647
        if not logged_in:
×
UNCOV
648
            password = ''
×
649
            remember = False
×
UNCOV
650
            while not logged_in:
×
651
                dlg = LoginWindow(parent=self, username=username, password=password, remember=remember)
×
UNCOV
652
                if dlg.result():
×
653
                    username = dlg.lineEditUsername.text()
×
654
                    password = dlg.lineEditPassword.text()
×
655
                    remember = dlg.checkBoxRememberMe.isChecked()
×
656
                    dlg.deleteLater()
×
657
                    logged_in = self.model.login(username=username, password=password, do_cache=remember, gui=True)
×
658
                else:
659
                    dlg.deleteLater()
×
660
                    break
×
661

662
        # 3) Finally, if the login was successful, we need to apply some changes to the GUI
UNCOV
663
        if logged_in:
×
UNCOV
664
            self.uiLineEditUser.addAction(QtGui.QIcon(':/images/check'), QtWidgets.QLineEdit.ActionPosition.TrailingPosition)
×
UNCOV
665
            self.uiLineEditUser.setText(username)
×
666
            self.uiLineEditUser.setReadOnly(True)
×
UNCOV
667
            self.uiLineEditUser.setStyleSheet('background-color: rgb(246, 245, 244);')
×
668
            self.uiPushButtonLogIn.setText('Log Out')
×
UNCOV
669
            self.model2view()
×
670
        return logged_in
×
671

672
    @override
2✔
673
    def eventFilter(self, obj, event):
2✔
674
        if obj == self.uiPushStart and event.type() in [QtCore.QEvent.HoverEnter, QtCore.QEvent.HoverLeave]:
×
675
            for widget in [self.uiListProcedures, self.uiListProjects]:
×
UNCOV
676
                if len(widget.selectedIndexes()) > 0:
×
UNCOV
677
                    continue
×
678
                match event.type():
×
UNCOV
679
                    case QtCore.QEvent.HoverEnter:
×
UNCOV
680
                        widget.setStyleSheet('QListView { background-color: pink; border: 1px solid red; }')
×
681
                    case _:
×
UNCOV
682
                        widget.setStyleSheet('')
×
683
            return True
×
UNCOV
684
        return False
×
685

686
    @override
2✔
687
    def closeEvent(self, event) -> None:
2✔
688
        def accept() -> None:
×
689
            self.settings.setValue('pos', self.pos())
×
690
            self.settings.setValue('size', self.size())
×
691
            self.settings.setValue('bpod_status_led', self.uiPushStatusLED.isChecked())
×
692
            self.toggle_status_led(is_toggled=True)
×
693
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])  # bpod is a singleton
×
694
            bpod.close()
×
UNCOV
695
            event.accept()
×
696

UNCOV
697
        if self.running_task_process is None:
×
UNCOV
698
            accept()
×
699
        else:
UNCOV
700
            msg_box = QtWidgets.QMessageBox(parent=self)
×
UNCOV
701
            msg_box.setWindowTitle('Hold on')
×
UNCOV
702
            msg_box.setText('A task is running - do you really want to quit?')
×
UNCOV
703
            msg_box.setStandardButtons(QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes)
×
UNCOV
704
            msg_box.setIcon(QtWidgets.QMessageBox().Question)
×
UNCOV
705
            match msg_box.exec_():
×
UNCOV
706
                case QtWidgets.QMessageBox.No:
×
UNCOV
707
                    event.ignore()
×
UNCOV
708
                case QtWidgets.QMessageBox.Yes:
×
UNCOV
709
                    self.setEnabled(False)
×
UNCOV
710
                    self.repaint()
×
UNCOV
711
                    self.start_stop()
×
UNCOV
712
                    accept()
×
713

714
    def model2view(self):
2✔
715
        # stores the current values in the model
UNCOV
716
        self.controller2model()
×
717
        # set the default values
UNCOV
718
        self.uiComboTask.setModel(QtCore.QStringListModel(list(self.model.all_tasks.keys())))
×
UNCOV
719
        self.uiComboSubject.setModel(QtCore.QStringListModel(self.model.all_subjects))
×
UNCOV
720
        self.uiListProcedures.setModel(QtCore.QStringListModel(self.model.all_procedures))
×
UNCOV
721
        self.uiListProjects.setModel(QtCore.QStringListModel(self.model.all_projects))
×
722
        # set the selections
UNCOV
723
        self.uiComboTask.setCurrentText(self.model.task_name)
×
UNCOV
724
        self.uiComboSubject.setCurrentText(self.model.subject)
×
UNCOV
725
        _set_list_view_from_string_list(self.uiListProcedures, self.model.procedures)
×
UNCOV
726
        _set_list_view_from_string_list(self.uiListProjects, self.model.projects)
×
UNCOV
727
        self._enable_ui_elements()
×
728

729
    def controller2model(self):
2✔
UNCOV
730
        self.model.procedures = [i.data() for i in self.uiListProcedures.selectedIndexes()]
×
UNCOV
731
        self.model.projects = [i.data() for i in self.uiListProjects.selectedIndexes()]
×
UNCOV
732
        self.model.task_name = self.uiComboTask.currentText()
×
UNCOV
733
        self.model.subject = self.uiComboSubject.currentText()
×
734

735
    def _controls_for_task_arguments(self, task_name: str):
2✔
UNCOV
736
        self.controller2model()
×
UNCOV
737
        self.task_arguments = dict()
×
738

739
        # collect & filter list of parser arguments (general & task specific)
UNCOV
740
        args = sorted(get_task_argument_parser()._actions, key=lambda x: x.dest)
×
UNCOV
741
        args = sorted(self.model.get_task_extra_parser(self.model.task_name)._actions, key=lambda x: x.dest) + args
×
UNCOV
742
        args = [
×
743
            x
744
            for x in args
745
            if not any(
746
                set(x.option_strings).intersection(
747
                    [
748
                        '--subject',
749
                        '--user',
750
                        '--projects',
751
                        '--log-level',
752
                        '--procedures',
753
                        '--weight',
754
                        '--help',
755
                        '--append',
756
                        '--no-interactive',
757
                        '--stub',
758
                        '--wizard',
759
                        '--remote',
760
                    ]
761
                )
762
            )
763
        ]
764

UNCOV
765
        group = self.uiGroupTaskParameters
×
UNCOV
766
        layout = group.layout()
×
UNCOV
767
        self.task_settings_widgets = [None] * len(args)
×
768

UNCOV
769
        while layout.rowCount():
×
UNCOV
770
            layout.removeRow(0)
×
771

772
        for arg in args:
×
773
            param = str(max(arg.option_strings, key=len))
×
774
            label = param.replace('_', ' ').replace('--', '').title()
×
775

776
            # create widget for bool arguments
UNCOV
777
            if isinstance(arg, argparse._StoreTrueAction | argparse._StoreFalseAction):
×
UNCOV
778
                widget = QtWidgets.QCheckBox()
×
UNCOV
779
                widget.setTristate(False)
×
UNCOV
780
                if arg.default:
×
UNCOV
781
                    widget.setCheckState(arg.default * 2)
×
UNCOV
782
                widget.toggled.connect(lambda val, p=param: self._set_task_arg(p, val > 0))
×
UNCOV
783
                widget.toggled.emit(widget.isChecked() > 0)
×
784

785
            # create widget for string arguments
UNCOV
786
            elif arg.type in (str, None):
×
787
                # string options (-> combo-box)
UNCOV
788
                if isinstance(arg.choices, list):
×
789
                    widget = QtWidgets.QComboBox()
×
790
                    widget.addItems(arg.choices)
×
791
                    if arg.default:
×
UNCOV
792
                        widget.setCurrentIndex([widget.itemText(x) for x in range(widget.count())].index(arg.default))
×
793
                    widget.currentTextChanged.connect(lambda val, p=param: self._set_task_arg(p, val))
×
794
                    widget.currentTextChanged.emit(widget.currentText())
×
795

796
                # list of strings (-> line-edit)
UNCOV
797
                elif arg.nargs == '+':
×
798
                    widget = QtWidgets.QLineEdit()
×
UNCOV
799
                    if arg.default:
×
UNCOV
800
                        widget.setText(', '.join(arg.default))
×
801
                    widget.editingFinished.connect(
×
802
                        lambda p=param, w=widget: self._set_task_arg(p, [x.strip() for x in w.text().split(',')])
803
                    )
UNCOV
804
                    widget.editingFinished.emit()
×
805

806
                # single string (-> line-edit)
807
                else:
UNCOV
808
                    widget = QtWidgets.QLineEdit()
×
UNCOV
809
                    if arg.default:
×
UNCOV
810
                        widget.setText(arg.default)
×
UNCOV
811
                    widget.editingFinished.connect(lambda p=param, w=widget: self._set_task_arg(p, w.text()))
×
UNCOV
812
                    widget.editingFinished.emit()
×
813

814
            # create widget for list of floats
UNCOV
815
            elif arg.type is float and arg.nargs == '+':
×
UNCOV
816
                widget = QtWidgets.QLineEdit()
×
UNCOV
817
                if arg.default:
×
UNCOV
818
                    widget.setText(str(arg.default)[1:-1])
×
UNCOV
819
                widget.editingFinished.connect(
×
820
                    lambda p=param, w=widget: self._set_task_arg(p, [x.strip() for x in w.text().split(',')])
821
                )
UNCOV
822
                widget.editingFinished.emit()
×
823

824
            # create widget for adaptive gain
UNCOV
825
            elif arg.dest == 'adaptive_gain':
×
UNCOV
826
                widget = QtWidgets.QDoubleSpinBox()
×
UNCOV
827
                widget.setDecimals(1)
×
828

829
            # create widget for numerical arguments
UNCOV
830
            elif arg.type in [float, int]:
×
UNCOV
831
                if arg.type is float:
×
832
                    widget = QtWidgets.QDoubleSpinBox()
×
833
                    widget.setDecimals(1)
×
834
                else:
835
                    widget = QtWidgets.QSpinBox()
×
836
                if arg.default:
×
837
                    widget.setValue(arg.default)
×
838
                widget.valueChanged.connect(lambda val, p=param: self._set_task_arg(p, str(val)))
×
839
                widget.valueChanged.emit(widget.value())
×
840

841
            # no other argument types supported for now
842
            else:
UNCOV
843
                continue
×
844

845
            # add custom widget properties
UNCOV
846
            widget.setObjectName(param)
×
UNCOV
847
            widget.setProperty('parameter_name', param)
×
848
            widget.setProperty('parameter_dest', arg.dest)
×
849

850
            # display help strings as status tip
UNCOV
851
            if arg.help:
×
852
                widget.setStatusTip(arg.help)
×
853

854
            # some customizations
UNCOV
855
            match widget.property('parameter_dest'):
×
856
                case 'probability_left' | 'probability_opto_stim':
×
857
                    widget.setMinimum(0.0)
×
858
                    widget.setMaximum(1.0)
×
UNCOV
859
                    widget.setSingleStep(0.1)
×
860
                    widget.setDecimals(2)
×
861

862
                case 'contrast_set_probability_type':
×
863
                    label = 'Probability Type'
×
864

865
                case 'session_template_id':
×
866
                    label = 'Session Template ID'
×
867
                    widget.setMinimum(0)
×
UNCOV
868
                    widget.setMaximum(11)
×
869

UNCOV
870
                case 'delay_mins':
×
871
                    label = 'Initial Delay, min'
×
UNCOV
872
                    widget.setMaximum(60)
×
873

UNCOV
874
                case 'training_phase':
×
UNCOV
875
                    widget.setSpecialValueText('automatic')
×
UNCOV
876
                    widget.setMaximum(5)
×
UNCOV
877
                    widget.setMinimum(-1)
×
UNCOV
878
                    widget.setValue(-1)
×
879

UNCOV
880
                case 'adaptive_reward':
×
UNCOV
881
                    label = 'Reward Amount, μl'
×
UNCOV
882
                    minimum = 1.4
×
UNCOV
883
                    widget.setSpecialValueText('automatic')
×
UNCOV
884
                    widget.setMaximum(3)
×
UNCOV
885
                    widget.setSingleStep(0.1)
×
UNCOV
886
                    widget.setMinimum(minimum)
×
UNCOV
887
                    widget.setValue(widget.minimum())
×
UNCOV
888
                    widget.valueChanged.connect(
×
889
                        lambda val, a=arg, m=minimum: self._set_task_arg(a.option_strings[0], str(val if val > m else -1))
890
                    )
UNCOV
891
                    widget.valueChanged.emit(widget.value())
×
892

UNCOV
893
                case 'reward_set_ul':
×
UNCOV
894
                    label = 'Reward Set, μl'
×
895

UNCOV
896
                case 'adaptive_gain':
×
UNCOV
897
                    label = 'Stimulus Gain'
×
UNCOV
898
                    minimum = 0
×
UNCOV
899
                    widget.setSpecialValueText('automatic')
×
UNCOV
900
                    widget.setSingleStep(0.1)
×
UNCOV
901
                    widget.setMinimum(minimum)
×
UNCOV
902
                    widget.setValue(widget.minimum())
×
UNCOV
903
                    widget.valueChanged.connect(
×
904
                        lambda val, a=arg, m=minimum: self._set_task_arg(a.option_strings[0], str(val) if val > m else 'None')
905
                    )
UNCOV
906
                    widget.valueChanged.emit(widget.value())
×
907

UNCOV
908
                case 'reward_amount_ul':
×
UNCOV
909
                    label = 'Reward Amount, μl'
×
UNCOV
910
                    widget.setSingleStep(0.1)
×
UNCOV
911
                    widget.setMinimum(0)
×
912

UNCOV
913
                case 'stim_gain':
×
UNCOV
914
                    label = 'Stimulus Gain'
×
915

UNCOV
916
                case 'stim_reverse':
×
UNCOV
917
                    label = 'Reverse Stimulus'
×
918

UNCOV
919
                case 'duration_spontaneous':
×
UNCOV
920
                    label = 'Spontaneous Activity, s'
×
UNCOV
921
                    widget.setMinimum(0)
×
UNCOV
922
                    widget.setMaximum(60 * 60 * 24 - 1)
×
UNCOV
923
                    widget.setValue(arg.default)
×
924

UNCOV
925
            widget.wheelEvent = lambda event: None
×
UNCOV
926
            layout.addRow(self.tr(label), widget)
×
927

928
        # add label to indicate absence of task specific parameters
UNCOV
929
        if layout.rowCount() == 0:
×
UNCOV
930
            layout.addRow(self.tr('(none)'), None)
×
UNCOV
931
            layout.itemAt(0, 0).widget().setEnabled(False)
×
932

933
    def _set_automatic_values(self):
2✔
UNCOV
934
        def _helper(name: str, destination: str, str_format: str):
×
UNCOV
935
            value = self.training_info.get(name)
×
UNCOV
936
            if (widget := self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, destination)) is not None:
×
UNCOV
937
                if value is None:
×
UNCOV
938
                    default = ' (default)' if self.session_info is None else ''
×
UNCOV
939
                    widget.setSpecialValueText(f'automatic{default}')
×
940
                else:
UNCOV
941
                    default = ' (default)' if self.session_info is None else ''
×
UNCOV
942
                    widget.setSpecialValueText(f'automatic: {value:{str_format}}{default}')
×
943

UNCOV
944
        _helper('training_phase', '--training_phase', 'd')
×
UNCOV
945
        _helper('adaptive_reward', '--adaptive_reward', '0.1f')
×
UNCOV
946
        _helper('adaptive_gain', '--adaptive_gain', '0.1f')
×
947

948
    def _set_task_arg(self, key, value):
2✔
UNCOV
949
        self.task_arguments[key] = value
×
950

951
    def _filter_subjects(self):
2✔
UNCOV
952
        filter_str = self.lineEditSubject.text().lower()
×
UNCOV
953
        result = [s for s in self.model.all_subjects if filter_str in s.lower()]
×
UNCOV
954
        if len(result) == 0:
×
UNCOV
955
            result = [self.model.test_subject_name]
×
UNCOV
956
        self.uiComboSubject.setModel(QtCore.QStringListModel(result))
×
957

958
    def pause(self):
2✔
UNCOV
959
        self.uiPushPause.setStyleSheet('QPushButton {background-color: yellow;}' if self.uiPushPause.isChecked() else '')
×
UNCOV
960
        match self.uiPushPause.isChecked():
×
UNCOV
961
            case True:
×
UNCOV
962
                print('Pausing after current trial ...')
×
UNCOV
963
                if self.model.session_folder.exists():
×
UNCOV
964
                    self.model.session_folder.joinpath('.pause').touch()
×
UNCOV
965
            case False:
×
UNCOV
966
                print('Resuming ...')
×
UNCOV
967
                if self.model.session_folder.joinpath('.pause').exists():
×
UNCOV
968
                    self.model.session_folder.joinpath('.pause').unlink()
×
969

970
    def start_stop(self):
2✔
UNCOV
971
        match self.uiPushStart.text():
×
UNCOV
972
            case 'Start':
×
UNCOV
973
                self.uiPushStart.setText('Stop')
×
UNCOV
974
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
975
                self._enable_ui_elements()
×
976

UNCOV
977
                self.tabLog.plainTextEditNarrative.clear()
×
UNCOV
978
                self.tabLog.narrativeUpdated.connect(self._on_updated_narrative)
×
979

980
                # Manage appended session
UNCOV
981
                self.append_session = False
×
UNCOV
982
                if self.previous_subject == self.model.subject and not self.model.hardware_settings.MAIN_SYNC:
×
UNCOV
983
                    self.append_session = (
×
984
                        QtWidgets.QMessageBox.question(
985
                            self,
986
                            'Appended Session',
987
                            'Would you like to append to the previous session?',
988
                            QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
989
                            QtWidgets.QMessageBox.No,
990
                        )
991
                        == QtWidgets.QMessageBox.Yes
992
                    )
993

994
                # Manage subject weight
UNCOV
995
                dlg = QtWidgets.QInputDialog()
×
UNCOV
996
                weight, ok = dlg.getDouble(
×
997
                    self,
998
                    'Subject Weight',
999
                    'Subject Weight (g):',
1000
                    value=0,
1001
                    min=0,
1002
                    decimals=2,
1003
                    flags=dlg.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint,
1004
                )
UNCOV
1005
                if not ok or weight == 0:
×
UNCOV
1006
                    self.uiPushStart.setText('Start')
×
UNCOV
1007
                    self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
1008
                    self._enable_ui_elements()
×
UNCOV
1009
                    return
×
1010

UNCOV
1011
                self.controller2model()
×
1012

UNCOV
1013
                logging.disable(logging.INFO)
×
UNCOV
1014
                task = EmptySession(subject=self.model.subject, append=self.append_session, interactive=False)
×
UNCOV
1015
                logging.disable(logging.NOTSET)
×
UNCOV
1016
                self.model.session_folder = task.paths['SESSION_FOLDER']
×
UNCOV
1017
                if self.model.session_folder.joinpath('.stop').exists():
×
UNCOV
1018
                    self.model.session_folder.joinpath('.stop').unlink()
×
UNCOV
1019
                self.model.raw_data_folder = task.paths['SESSION_RAW_DATA_FOLDER']
×
1020

1021
                # disable Bpod status LED
UNCOV
1022
                bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1023
                bpod.set_status_led(False)
×
1024

1025
                # close Bpod singleton so subprocess can access use the port
UNCOV
1026
                bpod.close()
×
1027

1028
                # build the argument list for the subprocess
UNCOV
1029
                cmd = []
×
UNCOV
1030
                if self.model.task_name:
×
UNCOV
1031
                    cmd.extend([str(self.model.task_file)])
×
UNCOV
1032
                if self.model.user:
×
UNCOV
1033
                    cmd.extend(['--user', self.model.user])
×
UNCOV
1034
                if self.model.subject:
×
UNCOV
1035
                    cmd.extend(['--subject', self.model.subject])
×
UNCOV
1036
                if self.model.procedures:
×
UNCOV
1037
                    cmd.extend(['--procedures', *self.model.procedures])
×
UNCOV
1038
                if self.model.projects:
×
UNCOV
1039
                    cmd.extend(['--projects', *self.model.projects])
×
UNCOV
1040
                if len(remotes := self.listViewRemoteDevices.getDevices()) > 0:
×
UNCOV
1041
                    cmd.extend(['--remote', *remotes])
×
UNCOV
1042
                for key, value in self.task_arguments.items():
×
UNCOV
1043
                    if isinstance(value, list):
×
UNCOV
1044
                        cmd.extend([key] + value)
×
UNCOV
1045
                    elif isinstance(value, bool):
×
UNCOV
1046
                        if value is True:
×
UNCOV
1047
                            cmd.append(key)
×
1048
                        else:
UNCOV
1049
                            pass
×
1050
                    else:
UNCOV
1051
                        cmd.extend([key, value])
×
UNCOV
1052
                cmd.extend(['--weight', f'{weight}'])
×
UNCOV
1053
                cmd.extend(['--log-level', 'DEBUG' if self.debug else 'INFO'])
×
UNCOV
1054
                cmd.append('--wizard')
×
UNCOV
1055
                if self.append_session:
×
UNCOV
1056
                    cmd.append('--append')
×
UNCOV
1057
                if self.running_task_process is None:
×
UNCOV
1058
                    self.tabLog.clear()
×
UNCOV
1059
                    self.tabLog.appendText(f'Starting subprocess: {self.model.task_name} ...\n', 'White')
×
UNCOV
1060
                    log.info('Starting subprocess')
×
UNCOV
1061
                    log.info(subprocess.list2cmdline(cmd))
×
UNCOV
1062
                    self.running_task_process = QtCore.QProcess()
×
UNCOV
1063
                    self.running_task_process.setWorkingDirectory(BASE_DIR)
×
UNCOV
1064
                    self.running_task_process.setProcessChannelMode(QtCore.QProcess.SeparateChannels)
×
UNCOV
1065
                    self.running_task_process.finished.connect(self._on_task_finished)
×
UNCOV
1066
                    self.running_task_process.readyReadStandardOutput.connect(self._on_read_standard_output)
×
UNCOV
1067
                    self.running_task_process.readyReadStandardError.connect(self._on_read_standard_error)
×
UNCOV
1068
                    self.running_task_process.start(shutil.which('python'), cmd)
×
UNCOV
1069
                self.uiPushStart.setStatusTip('stop the session after the current trial')
×
UNCOV
1070
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
1071
                self.tabWidget.setCurrentIndex(self.tabWidget.indexOf(self.tabLog))
×
UNCOV
1072
            case 'Stop':
×
UNCOV
1073
                self.uiPushStart.setEnabled(False)
×
1074

UNCOV
1075
                self.tabLog.narrativeTimerTimeout()
×
UNCOV
1076
                self.tabLog.narrativeUpdated.disconnect()
×
UNCOV
1077
                self.tabLog.plainTextEditNarrative.setEnabled(False)
×
1078

UNCOV
1079
                if self.model.session_folder and self.model.session_folder.exists():
×
UNCOV
1080
                    self.model.session_folder.joinpath('.stop').touch()
×
1081

1082
    @pyqtSlot(bytes)
2✔
1083
    def _on_updated_narrative(self, narrative: bytes):
2✔
1084
        """Update narrative.txt if text-field has been modified."""
UNCOV
1085
        self.model.session_folder.mkdir(parents=True, exist_ok=True)
×
UNCOV
1086
        with self.model.session_folder.joinpath('narrative.txt').open('w+b') as f:
×
UNCOV
1087
            f.write(narrative)
×
1088

1089
    def _on_read_standard_output(self):
2✔
1090
        """
1091
        Read and process standard output entries.
1092

1093
        Reads standard output from a running task process, processes each entry,
1094
        extracts color information, sets character color in the QPlainTextEdit widget,
1095
        and appends time and message information to the widget.
1096
        """
UNCOV
1097
        data = self.running_task_process.readAllStandardOutput().data().decode('utf-8', 'ignore').strip()
×
UNCOV
1098
        entries = re.finditer(REGEX_STDOUT, data)
×
UNCOV
1099
        for entry in entries:
×
UNCOV
1100
            color = ANSI_COLORS.get(entry.groupdict().get('color', '37'), 'White')
×
UNCOV
1101
            time = entry.groupdict().get('time', '')
×
UNCOV
1102
            msg = entry.groupdict().get('message', '')
×
UNCOV
1103
            self.tabLog.appendText(f'{time} {msg}', color)
×
UNCOV
1104
        if self.debug:
×
UNCOV
1105
            print(data)
×
1106

1107
    def _on_read_standard_error(self):
2✔
1108
        """
1109
        Read and process standard error entries.
1110

1111
        Reads standard error from a running task process, sets character color
1112
        in the QPlainTextEdit widget to indicate an error (Red), and appends
1113
        the error message to the widget.
1114
        """
UNCOV
1115
        data = self.running_task_process.readAllStandardError().data().decode('utf-8', 'ignore').strip()
×
UNCOV
1116
        self.tabLog.appendText(data, 'Red')
×
UNCOV
1117
        if self.debug:
×
UNCOV
1118
            print(data)
×
1119

1120
    def _on_task_finished(self, exit_code, exit_status):
2✔
UNCOV
1121
        self.tabLog.appendText('\nSubprocess finished.', 'White')
×
UNCOV
1122
        if exit_code:
×
UNCOV
1123
            msg_box = QtWidgets.QMessageBox(parent=self)
×
UNCOV
1124
            msg_box.setWindowTitle('Oh no!')
×
UNCOV
1125
            msg_box.setText('The task was terminated with an error.\nPlease check the log for details.')
×
UNCOV
1126
            msg_box.setIcon(QtWidgets.QMessageBox().Critical)
×
UNCOV
1127
            msg_box.exec_()
×
1128

UNCOV
1129
        self.running_task_process = None
×
1130

1131
        # re-enable UI elements
UNCOV
1132
        self.uiPushStart.setText('Start')
×
UNCOV
1133
        self.uiPushStart.setStatusTip('start the session')
×
UNCOV
1134
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
1135
        self._enable_ui_elements()
×
1136

1137
        # recall state of Bpod status LED
UNCOV
1138
        bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1139
        bpod.set_status_led(self.uiPushStatusLED.isChecked())
×
1140

UNCOV
1141
        if (task_settings_file := Path(self.model.raw_data_folder).joinpath('_iblrig_taskSettings.raw.json')).exists():
×
UNCOV
1142
            with open(task_settings_file) as fid:
×
UNCOV
1143
                session_data = json.load(fid)
×
1144

1145
            # check if session was a dud
UNCOV
1146
            if (
×
1147
                (ntrials := session_data['NTRIALS']) < 42
1148
                and not any([x in self.model.task_name for x in ('spontaneous', 'passive')])
1149
                and not self.append_session
1150
            ):
UNCOV
1151
                answer = QtWidgets.QMessageBox.question(
×
1152
                    self,
1153
                    'Is this a dud?',
1154
                    f'The session consisted of only {ntrials:d} trial'
1155
                    f'{"s" if ntrials > 1 else ""} and appears to be a dud.\n\n'
1156
                    f'Should it be deleted?',
1157
                )
UNCOV
1158
                if answer == QtWidgets.QMessageBox.Yes:
×
UNCOV
1159
                    shutil.rmtree(self.model.session_folder)
×
UNCOV
1160
                    self.previous_subject = None
×
UNCOV
1161
                    return
×
UNCOV
1162
            self.previous_subject = self.model.subject
×
1163

1164
            # manage poop count
UNCOV
1165
            dlg = QtWidgets.QInputDialog()
×
UNCOV
1166
            droppings, ok = dlg.getInt(
×
1167
                self,
1168
                'Droppings',
1169
                'Number of droppings:',
1170
                value=0,
1171
                min=0,
1172
                flags=dlg.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint,
1173
            )
UNCOV
1174
            session_data['POOP_COUNT'] = droppings
×
UNCOV
1175
            with open(task_settings_file, 'w') as fid:
×
UNCOV
1176
                json.dump(session_data, fid, indent=4, sort_keys=True, default=str)
×
1177

1178
    def flush(self):
2✔
1179
        # paint button blue when in toggled state
UNCOV
1180
        self.uiPushFlush.setStyleSheet(
×
1181
            'QPushButton {background-color: rgb(128, 128, 255);}' if self.uiPushFlush.isChecked() else ''
1182
        )
UNCOV
1183
        self._enable_ui_elements()
×
1184

UNCOV
1185
        try:
×
UNCOV
1186
            bpod = Bpod(
×
1187
                self.hardware_settings['device_bpod']['COM_BPOD'],
1188
                skip_initialization=True,
1189
                disable_behavior_ports=[1, 2, 3],
1190
            )
UNCOV
1191
            bpod.open_valve(self.uiPushFlush.isChecked())
×
UNCOV
1192
        except (OSError, BpodErrorException):
×
UNCOV
1193
            print(traceback.format_exc())
×
UNCOV
1194
            print('Cannot find bpod - is it connected?')
×
UNCOV
1195
            self.uiPushFlush.setChecked(False)
×
UNCOV
1196
            self.uiPushFlush.setStyleSheet('')
×
UNCOV
1197
            return
×
1198

1199
    def toggle_status_led(self, is_toggled: bool):
2✔
1200
        # paint button green when in toggled state
UNCOV
1201
        self.uiPushStatusLED.setStyleSheet('QPushButton {background-color: rgb(128, 255, 128);}' if is_toggled else '')
×
UNCOV
1202
        self._enable_ui_elements()
×
1203

UNCOV
1204
        try:
×
UNCOV
1205
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
UNCOV
1206
            bpod.set_status_led(is_toggled)
×
UNCOV
1207
        except (OSError, BpodErrorException, AttributeError):
×
UNCOV
1208
            self.uiPushStatusLED.setChecked(False)
×
UNCOV
1209
            self.uiPushStatusLED.setStyleSheet('')
×
1210

1211
    def _enable_ui_elements(self):
2✔
UNCOV
1212
        is_running = self.uiPushStart.text() == 'Stop'
×
1213

UNCOV
1214
        self.uiPushStart.setEnabled(
×
1215
            not self.uiPushFlush.isChecked()
1216
            and len(self.uiListProjects.selectedIndexes()) > 0
1217
            and len(self.uiListProcedures.selectedIndexes()) > 0
1218
        )
UNCOV
1219
        self.tabLog.plainTextEditNarrative.setEnabled(is_running)
×
UNCOV
1220
        self.uiPushPause.setEnabled(is_running)
×
UNCOV
1221
        self.uiPushFlush.setEnabled(not is_running)
×
UNCOV
1222
        self.uiPushReward.setEnabled(not is_running)
×
UNCOV
1223
        self.uiPushStatusLED.setEnabled(not is_running)
×
UNCOV
1224
        self.uiGroupParameters.setEnabled(not is_running)
×
UNCOV
1225
        self.uiGroupTaskParameters.setEnabled(not is_running)
×
UNCOV
1226
        self.uiGroupTools.setEnabled(not is_running)
×
UNCOV
1227
        self.repaint()
×
1228

1229

1230
class LoginWindow(QtWidgets.QDialog, Ui_login):
2✔
1231
    def __init__(self, parent: RigWizard, username: str = '', password: str = '', remember: bool = False):
2✔
UNCOV
1232
        super().__init__(parent)
×
UNCOV
1233
        self.setupUi(self)
×
UNCOV
1234
        self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
×
UNCOV
1235
        self.labelServer.setText(str(parent.iblrig_settings['ALYX_URL']))
×
UNCOV
1236
        self.lineEditUsername.setText(username)
×
UNCOV
1237
        self.lineEditPassword.setText(password)
×
UNCOV
1238
        self.checkBoxRememberMe.setChecked(remember)
×
UNCOV
1239
        self.lineEditUsername.textChanged.connect(self._on_text_changed)
×
UNCOV
1240
        self.lineEditPassword.textChanged.connect(self._on_text_changed)
×
UNCOV
1241
        self.toggle_password = self.lineEditPassword.addAction(
×
1242
            QtGui.QIcon(':/images/hide'), QtWidgets.QLineEdit.ActionPosition.TrailingPosition
1243
        )
UNCOV
1244
        self.toggle_password.triggered.connect(self._toggle_password_visibility)
×
UNCOV
1245
        self.toggle_password.setCheckable(True)
×
UNCOV
1246
        if len(username) > 0:
×
UNCOV
1247
            self.lineEditPassword.setFocus()
×
UNCOV
1248
        self._on_text_changed()
×
UNCOV
1249
        self.exec()
×
1250

1251
    def _on_text_changed(self):
2✔
UNCOV
1252
        enable_ok = len(self.lineEditUsername.text()) > 0 and len(self.lineEditPassword.text()) > 0
×
UNCOV
1253
        self.buttonBox.button(self.buttonBox.Ok).setEnabled(enable_ok)
×
1254

1255
    def _toggle_password_visibility(self):
2✔
UNCOV
1256
        if self.toggle_password.isChecked():
×
UNCOV
1257
            self.toggle_password.setIcon(QtGui.QIcon(':/images/show'))
×
UNCOV
1258
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
×
1259
        else:
UNCOV
1260
            self.toggle_password.setIcon(QtGui.QIcon(':/images/hide'))
×
UNCOV
1261
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
×
1262

1263

1264
class UpdateNotice(QtWidgets.QDialog, Ui_update):
2✔
1265
    """
1266
    A dialog for displaying update notices.
1267

1268
    This class is used to create a dialog for displaying update notices.
1269
    It shows information about the available update and provides a changelog.
1270

1271
    Parameters
1272
    ----------
1273
    parent : QtWidgets.QWidget
1274
        The parent widget associated with this dialog.
1275

1276
    update_available : bool
1277
        Indicates if an update is available.
1278

1279
    version : str
1280
        The version of the available update.
1281
    """
1282

1283
    def __init__(self, parent: QtWidgets.QWidget, version: str) -> None:
2✔
UNCOV
1284
        super().__init__(parent)
×
UNCOV
1285
        self.setupUi(self)
×
UNCOV
1286
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
×
UNCOV
1287
        self.uiLabelHeader.setText(f'Update to iblrig {version} is available.')
×
UNCOV
1288
        self.uiTextBrowserChanges.setMarkdown(get_changelog())
×
UNCOV
1289
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
×
UNCOV
1290
        self.exec()
×
1291

1292

1293
def main():
2✔
1294
    # argument parser
UNCOV
1295
    parser = argparse.ArgumentParser()
×
UNCOV
1296
    parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='increase logging verbosity')
×
UNCOV
1297
    parser.add_argument(
×
1298
        '-r', '--remote_devices', action='store_true', dest='remote_devices', help='show controls for remote devices'
1299
    )
UNCOV
1300
    args = parser.parse_args()
×
1301

1302
    # set logging verbosity
UNCOV
1303
    if args.debug:
×
UNCOV
1304
        setup_logger(name=None, level='DEBUG')
×
1305
    else:
UNCOV
1306
        setup_logger(name='iblrig', level='INFO')
×
1307

1308
    # set app information
UNCOV
1309
    QtCore.QCoreApplication.setOrganizationName('International Brain Laboratory')
×
UNCOV
1310
    QtCore.QCoreApplication.setOrganizationDomain('internationalbrainlab.org')
×
UNCOV
1311
    QtCore.QCoreApplication.setApplicationName('IBLRIG Wizard')
×
UNCOV
1312
    if os.name == 'nt':
×
UNCOV
1313
        app_id = f'IBL.iblrig.wizard.{iblrig.__version__}'
×
UNCOV
1314
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
×
1315

1316
    # instantiate app
UNCOV
1317
    app = QtWidgets.QApplication(['', '--no-sandbox'])
×
UNCOV
1318
    app.setStyle('Fusion')
×
UNCOV
1319
    w = RigWizard(debug=args.debug, remote_devices=args.remote_devices)
×
UNCOV
1320
    w.show()
×
UNCOV
1321
    app.exec()
×
1322

1323

1324
if __name__ == '__main__':
2✔
UNCOV
1325
    main()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc