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

int-brain-lab / iblrig / 12279337432

11 Dec 2024 03:15PM UTC coverage: 47.031% (+0.2%) from 46.79%
12279337432

Pull #751

github

d4edef
web-flow
Merge eea51f2f7 into 2f9d65d86
Pull Request #751: Fiber trajectory GUI

0 of 114 new or added lines in 1 file covered. (0.0%)

1076 existing lines in 22 files now uncovered.

4246 of 9028 relevant lines covered (47.03%)

0.94 hits per line

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

20.66
/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.util import module_from_spec, spec_from_file_location
2✔
14
from pathlib import Path
2✔
15

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

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

57
try:
2✔
58
    import iblrig_custom_tasks
2✔
59

60
    CUSTOM_TASKS = True
2✔
UNCOV
61
except ImportError:
×
UNCOV
62
    CUSTOM_TASKS = False
×
UNCOV
63
    pass
×
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
        # get the subjects from iterating over folders in the the iblrig data path
136
        if self.iblrig_settings['iblrig_local_data_path'] is None:
2✔
137
            self.all_subjects = [self.test_subject_name]
2✔
138
        else:
139
            folder_subjects = Path(self.iblrig_settings['iblrig_local_data_path']).joinpath(
×
140
                self.iblrig_settings['ALYX_LAB'], 'Subjects'
141
            )
UNCOV
142
            self.all_subjects = [self.test_subject_name] + sorted(
×
143
                [f.name for f in folder_subjects.glob('*') if f.is_dir() and f.name != self.test_subject_name]
144
            )
145

146
    def get_session(self, task_name: str) -> BaseSession:
2✔
147
        """
148
        Get a session object for the given task name.
149

150
        Parameters
151
        ----------
152
        task_name: str
153
            The name of the task
154

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

166
    def get_task_extra_parser(self, task_name: str):
2✔
167
        """
168
        Get an extra parser for the given task name.
169

170
        Parameters
171
        ----------
172
        task_name
173
            The name of the task
174

175
        Returns
176
        -------
177
        ArgumentParser
178
            The extra parser for the given task name
179
        """
180
        return self.get_session(task_name).extra_parser()
2✔
181

182
    def get_task_parameters(self, task_name: str) -> Bunch:
2✔
183
        """
184
        Return parameters for the given task.
185

186
        Parameters
187
        ----------
188
        task_name
189
            The name of the task
190

191
        Returns
192
        -------
193
        Bunch
194
            The parameters for the given task
195
        """
196
        return self.get_session(task_name).read_task_parameter_files()
×
197

198
    @property
2✔
199
    def task_file(self) -> Path:
2✔
200
        return self.all_tasks.get(self.task_name, None)
×
201

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

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

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

251
        # get subjects from Alyx: this is the set of subjects that are alive and not stock in the lab defined in settings
252
        rest_subjects = self.alyx.rest(
2✔
253
            'subjects', 'list', alive=True, stock=False, lab=self.iblrig_settings['ALYX_LAB'], no_cache=True
254
        )
255
        self.all_subjects.remove(self.test_subject_name)
2✔
256
        self.all_subjects = [self.test_subject_name] + sorted(set(self.all_subjects + [s['nickname'] for s in rest_subjects]))
2✔
257

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

263
        return True
2✔
264

265
    def logout(self):
2✔
UNCOV
266
        if not self.alyx.is_logged_in or self.alyx.user is not self.user:
×
UNCOV
267
            return
×
UNCOV
268
        log.info(f'User {self.user} logged out')
×
UNCOV
269
        self.alyx.logout()
×
UNCOV
270
        self.user = None
×
UNCOV
271
        self.__post_init__()
×
272

273
    def free_reward(self):
2✔
UNCOV
274
        try:
×
275
            bpod = Bpod(
×
276
                self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True, disable_behavior_ports=[1, 2, 3]
277
            )
278
            bpod.pulse_valve(open_time_s=self.free_reward_time)
×
279
        except (OSError, BpodErrorException):
×
280
            log.error('Cannot find bpod - is it connected?')
×
UNCOV
281
            return
×
282

283

284
class RigWizard(QtWidgets.QMainWindow, Ui_wizard):
2✔
285
    training_info: dict = {}
2✔
286
    session_info: dict = {}
2✔
287
    task_parameters: dict | None = None
2✔
288
    new_subject_details = QtCore.pyqtSignal()
2✔
289
    append_session: bool = False
2✔
290
    previous_subject: str | None = None
2✔
291

292
    def __init__(self, debug: bool = False, remote_devices: bool = False):
2✔
293
        super().__init__()
×
294
        self.setupUi(self)
×
295

296
        # load tabs
297
        self.tabLog = TabLog(parent=self.tabWidget)
×
UNCOV
298
        self.tabData = TabData(parent=self.tabWidget)
×
UNCOV
299
        self.tabDocs = TabDocs(parent=self.tabWidget)
×
300
        self.tabAbout = TabAbout(parent=self.tabWidget)
×
301
        self.tabWidget.addTab(self.tabLog, QtGui.QIcon(':/images/log'), 'Log')
×
302
        self.tabWidget.addTab(self.tabData, QtGui.QIcon(':/images/sessions'), 'Data')
×
303
        self.tabWidget.addTab(self.tabDocs, QtGui.QIcon(':/images/help'), 'Docs')
×
304
        self.tabWidget.addTab(self.tabAbout, QtGui.QIcon(':/images/about'), 'About')
×
305
        self.tabWidget.setCurrentIndex(0)
×
306

UNCOV
307
        self.debug = debug
×
308
        self.settings = QtCore.QSettings()
×
309

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

334
        # remote devices (only show if at least one device was found)
335
        if remote_devices:
×
336
            self.remoteDevicesModel = RemoteDevicesItemModel(iblrig_settings=self.model.iblrig_settings)
×
337
            self.listViewRemoteDevices.setModel(self.remoteDevicesModel)
×
338
        else:
339
            self.listViewRemoteDevices.setVisible(False)
×
340
            self.labelRemoteDevices.setVisible(False)
×
341

342
        # task parameters and subject details
343
        self.uiComboTask.currentTextChanged.connect(self._controls_for_task_arguments)
×
344
        self.uiComboTask.currentTextChanged.connect(self._get_task_parameters)
×
345
        self.uiComboSubject.currentTextChanged.connect(self._get_subject_details)
×
346
        self.new_subject_details.connect(self._set_automatic_values)
×
347
        self.model2view()
×
348

349
        # connect widgets signals to slots
350
        self.uiActionValidateHardware.triggered.connect(self._on_validate_hardware)
×
351
        self.uiActionCalibrateFrame2ttl.triggered.connect(self._on_calibrate_frame2ttl)
×
UNCOV
352
        self.uiActionCalibrateValve.triggered.connect(self._on_calibrate_valve)
×
UNCOV
353
        self.uiActionTrainingLevelV7.triggered.connect(self._on_menu_training_level_v7)
×
354

355
        self.uiPushStart.clicked.connect(self.start_stop)
×
UNCOV
356
        self.uiPushPause.clicked.connect(self.pause)
×
UNCOV
357
        self.uiListProjects.clicked.connect(self._enable_ui_elements)
×
UNCOV
358
        self.uiListProcedures.clicked.connect(self._enable_ui_elements)
×
359
        self.lineEditSubject.textChanged.connect(self._filter_subjects)
×
360

361
        self.running_task_process = None
×
362
        self.task_arguments = dict()
×
363
        self.task_settings_widgets = None
×
364

365
        self.uiPushStart.installEventFilter(self)
×
366
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
367
        self.uiPushPause.setIcon(self.style().standardIcon(QStyle.SP_MediaPause))
×
368

369
        self.controller2model()
×
370

UNCOV
371
        self.tabWidget.currentChanged.connect(self._on_switch_tab)
×
372

373
        # username
374
        if self.iblrig_settings.ALYX_URL is not None:
×
375
            self.uiLineEditUser.returnPressed.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
376
            self.uiPushButtonLogIn.released.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
377
        else:
378
            self.uiLineEditUser.setPlaceholderText('')
×
379
            self.uiPushButtonLogIn.setEnabled(False)
×
380

381
        # tools
382
        self.uiPushFlush.clicked.connect(self.flush)
×
383
        self.uiPushReward.clicked.connect(self.model.free_reward)
×
384
        self.uiPushReward.setStatusTip(
×
385
            f'Click to grant a free reward ({self.hardware_settings.device_valve.FREE_REWARD_VOLUME_UL:.1f} μL)'
386
        )
387
        self.uiPushStatusLED.setChecked(self.settings.value('bpod_status_led', True, bool))
×
388
        self.uiPushStatusLED.toggled.connect(self.toggle_status_led)
×
UNCOV
389
        self.toggle_status_led(self.uiPushStatusLED.isChecked())
×
390

391
        # statusbar / disk stats
UNCOV
392
        local_data = self.iblrig_settings['iblrig_local_data_path']
×
393
        local_data = Path(local_data) if local_data else Path.home().joinpath('iblrig_data')
×
394
        self.uiDiskSpaceIndicator = DiskSpaceIndicator(parent=self.statusbar, directory=local_data)
×
395
        self.uiDiskSpaceIndicator.setMaximumWidth(70)
×
396
        self.statusbar.addPermanentWidget(self.uiDiskSpaceIndicator)
×
UNCOV
397
        self.statusbar.setContentsMargins(0, 0, 6, 0)
×
398

399
        # disable control of LED if Bpod does not have the respective capability
UNCOV
400
        try:
×
401
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
402
            self.uiPushStatusLED.setEnabled(bpod.can_control_led)
×
403
        except SerialException:
×
404
            pass
×
405

406
        # show splash-screen / store validation results
UNCOV
407
        splash_screen = Splash(parent=self)
×
408
        splash_screen.exec()
×
UNCOV
409
        self.validation_results = splash_screen.validation_results
×
410

411
        # check for update
412
        update_worker = Worker(check_for_updates)
×
413
        update_worker.signals.result.connect(self._on_check_update_result)
×
414
        QThreadPool.globalInstance().start(update_worker)
×
415

416
        # show GUI
417
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowFullscreenButtonHint)
×
418
        self.move(self.settings.value('pos', self.pos(), QtCore.QPoint))
×
419
        self.resize(self.settings.value('size', self.size(), QtCore.QSize))
×
UNCOV
420
        self.show()
×
421

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

439
    @property
2✔
440
    def iblrig_settings(self) -> RigSettings:
2✔
441
        return self.model.iblrig_settings
×
442

443
    @property
2✔
444
    def hardware_settings(self) -> HardwareSettings:
2✔
445
        return self.model.hardware_settings
×
446

447
    def _get_task_parameters(self, task_name):
2✔
448
        worker = Worker(self.model.get_task_parameters, task_name)
×
UNCOV
449
        worker.signals.result.connect(self._on_task_parameters_result)
×
450
        QThreadPool.globalInstance().start(worker)
×
451

452
    def _on_task_parameters_result(self, result):
2✔
453
        self.task_parameters = result
×
454
        self._get_subject_details(self.uiComboSubject.currentText())
×
455

456
    def _get_subject_details(self, subject_name: str):
2✔
457
        if not isinstance(subject_name, str) or self.task_parameters is None:
×
UNCOV
458
            return
×
459
        worker = Worker(
×
460
            get_subject_training_info,
461
            subject_name=subject_name,
462
            task_name=self.uiComboTask.currentText(),
463
            stim_gain=self.task_parameters.get('AG_INIT_VALUE'),
464
            stim_gain_on_error=self.task_parameters.get('STIM_GAIN'),
465
            default_reward=self.task_parameters.get('REWARD_AMOUNT_UL'),
466
        )
UNCOV
467
        worker.signals.result.connect(self._on_subject_details_result)
×
UNCOV
468
        QThreadPool.globalInstance().start(worker)
×
469

470
    def _on_subject_details_result(self, result):
2✔
471
        self.training_info, self.session_info = result
×
472
        self.new_subject_details.emit()
×
473

474
    def _show_error_dialog(
2✔
475
        self,
476
        title: str,
477
        description: str,
478
        issues: list[str] | None = None,
479
        suggestions: list[str] | None = None,
480
        leads: list[str] | None = None,
481
    ):
UNCOV
482
        text = description.strip()
×
483

484
        def build_list(items: list[str] or None, header_singular: str, header_plural: str | None = None):
×
485
            nonlocal text
486
            if items is None or len(items) == 0:
×
UNCOV
487
                return
×
488
            if len(items) > 1:
×
489
                if header_plural is None:
×
UNCOV
490
                    header_plural = header_singular.strip() + 's'
×
491
                text += f'<br><br>{header_plural}:<ul>'
×
492
            else:
UNCOV
493
                text += f'<br><br>{header_singular.strip()}:<ul>'
×
494
            for item in items:
×
495
                text += f'<li>{item.strip()}</li>'
×
496
            text += '</ul>'
×
497

UNCOV
498
        build_list(issues, 'Possible issue')
×
499
        build_list(suggestions, 'Suggested action')
×
UNCOV
500
        build_list(leads, 'Possible lead')
×
UNCOV
501
        QtWidgets.QMessageBox.critical(self, title, text)
×
502

503
    def _on_switch_tab(self, index):
2✔
504
        # if self.tabWidget.tabText(index) == 'Session':
505
        # QtCore.QTimer.singleShot(1, lambda: self.resize(self.minimumSizeHint()))
506
        # self.adjustSize()
UNCOV
507
        pass
×
508

509
    def _on_validate_hardware(self) -> None:
2✔
510
        SystemValidationDialog(self, hardware_settings=self.hardware_settings, rig_settings=self.iblrig_settings)
×
511

512
    def _on_calibrate_frame2ttl(self) -> None:
2✔
513
        Frame2TTLCalibrationDialog(self, hardware_settings=self.hardware_settings)
×
514

515
    def _on_calibrate_valve(self) -> None:
2✔
516
        ValveCalibrationDialog(self)
×
517

518
    def _on_menu_training_level_v7(self) -> None:
2✔
519
        """
520
        Prompt user for a session path to get v7 training level.
521

522
        This code will be removed and is here only for convenience while users transition from v7 to v8
523
        """
524
        # get session path
525
        if not (local_path := Path(r'C:\iblrig_data\Subjects')).exists():
×
UNCOV
526
            local_path = self.iblrig_settings.iblrig_local_data_path
×
UNCOV
527
        session_path = QtWidgets.QFileDialog.getExistingDirectory(
×
528
            self, 'Select Session Path', str(local_path), QtWidgets.QFileDialog.ShowDirsOnly
529
        )
530
        if session_path is None or session_path == '':
×
531
            return
×
532

533
        # get trials table
534
        file_jsonable = next(Path(session_path).glob('raw_behavior_data/_iblrig_taskData.raw.jsonable'), None)
×
535
        if file_jsonable is None:
×
UNCOV
536
            QtWidgets.QMessageBox().critical(self, 'Error', f'No jsonable found in {session_path}')
×
537
            return
×
538
        trials_table, _ = load_task_jsonable(file_jsonable)
×
539
        if trials_table.empty:
×
540
            QtWidgets.QMessageBox().critical(self, 'Error', f'No trials found in {session_path}')
×
541
            return
×
542

543
        # get task settings
544
        task_settings = load_settings(session_path, task_collection='raw_behavior_data')
×
545
        if task_settings is None:
×
546
            QtWidgets.QMessageBox().critical(self, 'Error', f'No task settings found in {session_path}')
×
547
            return
×
548

549
        # compute values
UNCOV
550
        contrast_set = trials_table['signed_contrast'].abs().unique()
×
551
        training_phase = training_phase_from_contrast_set(contrast_set)
×
552
        previous_reward_volume = (
×
553
            task_settings.get('ADAPTIVE_REWARD_AMOUNT_UL')
554
            or task_settings.get('REWARD_AMOUNT_UL')
555
            or task_settings.get('REWARD_AMOUNT')
556
        )
UNCOV
557
        reward_amount = compute_adaptive_reward_volume(
×
558
            subject_weight_g=task_settings['SUBJECT_WEIGHT'],
559
            reward_volume_ul=previous_reward_volume,
560
            delivered_volume_ul=trials_table['reward_amount'].sum(),
561
            ntrials=trials_table.shape[0],
562
        )
563
        stim_gain = trials_table['stim_gain'].values[-1]
×
564

565
        # display results
566
        box = QtWidgets.QMessageBox(parent=self)
×
567
        box.setIcon(QtWidgets.QMessageBox.Information)
×
568
        box.setModal(False)
×
569
        box.setWindowTitle('Training Level')
×
570
        box.setText(
×
571
            f'{session_path}\n\n'
572
            f'training phase:\t{training_phase}\n'
573
            f'reward:\t{reward_amount:.2f} uL\n'
574
            f'stimulus gain:\t{stim_gain}'
575
        )
576
        if self.uiComboTask.currentText() == '_iblrig_tasks_trainingChoiceWorld':
×
577
            box.setStandardButtons(QtWidgets.QMessageBox.Apply | QtWidgets.QMessageBox.Close)
×
578
        else:
579
            box.setStandardButtons(QtWidgets.QMessageBox.Close)
×
580
        box.exec()
×
581
        if box.clickedButton() == box.button(QtWidgets.QMessageBox.Apply):
×
582
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--adaptive_gain').setValue(stim_gain)
×
UNCOV
583
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--adaptive_reward').setValue(reward_amount)
×
UNCOV
584
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--training_phase').setValue(training_phase)
×
585

586
    def _on_check_update_result(self, result: tuple[bool, str]) -> None:
2✔
587
        """
588
        Handle the result of checking for updates.
589

590
        Parameters
591
        ----------
592
        result : tuple[bool, str | None]
593
            A tuple containing a boolean flag indicating update availability (result[0])
594
            and the remote version string (result[1]).
595

596
        Returns
597
        -------
598
        None
599
        """
600
        if result[0]:
×
601
            UpdateNotice(parent=self, version=result[1])
×
602

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

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

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

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

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

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

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

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

724
    def controller2model(self):
2✔
UNCOV
725
        self.model.procedures = [i.data() for i in self.uiListProcedures.selectedIndexes()]
×
UNCOV
726
        self.model.projects = [i.data() for i in self.uiListProjects.selectedIndexes()]
×
UNCOV
727
        self.model.task_name = self.uiComboTask.currentText()
×
UNCOV
728
        self.model.subject = self.uiComboSubject.currentText()
×
729

730
    def _controls_for_task_arguments(self, task_name: str):
2✔
UNCOV
731
        self.controller2model()
×
UNCOV
732
        self.task_arguments = dict()
×
733

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

UNCOV
760
        group = self.uiGroupTaskParameters
×
UNCOV
761
        layout = group.layout()
×
UNCOV
762
        self.task_settings_widgets = [None] * len(args)
×
763

UNCOV
764
        while layout.rowCount():
×
UNCOV
765
            layout.removeRow(0)
×
766

UNCOV
767
        for arg in args:
×
UNCOV
768
            param = str(max(arg.option_strings, key=len))
×
UNCOV
769
            label = param.replace('_', ' ').replace('--', '').title()
×
770

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

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

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

801
                # single string (-> line-edit)
802
                else:
UNCOV
803
                    widget = QtWidgets.QLineEdit()
×
UNCOV
804
                    if arg.default:
×
UNCOV
805
                        widget.setText(arg.default)
×
UNCOV
806
                    widget.editingFinished.connect(lambda p=param, w=widget: self._set_task_arg(p, w.text()))
×
UNCOV
807
                    widget.editingFinished.emit()
×
808

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

819
            # create widget for adaptive gain
UNCOV
820
            elif arg.dest == 'adaptive_gain':
×
UNCOV
821
                widget = QtWidgets.QDoubleSpinBox()
×
UNCOV
822
                widget.setDecimals(1)
×
823

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

836
            # no other argument types supported for now
837
            else:
838
                continue
×
839

840
            # add custom widget properties
UNCOV
841
            widget.setObjectName(param)
×
UNCOV
842
            widget.setProperty('parameter_name', param)
×
UNCOV
843
            widget.setProperty('parameter_dest', arg.dest)
×
844

845
            # display help strings as status tip
UNCOV
846
            if arg.help:
×
UNCOV
847
                widget.setStatusTip(arg.help)
×
848

849
            # some customizations
UNCOV
850
            match widget.property('parameter_dest'):
×
UNCOV
851
                case 'probability_left' | 'probability_opto_stim':
×
852
                    widget.setMinimum(0.0)
×
UNCOV
853
                    widget.setMaximum(1.0)
×
UNCOV
854
                    widget.setSingleStep(0.1)
×
UNCOV
855
                    widget.setDecimals(2)
×
856

857
                case 'contrast_set_probability_type':
×
858
                    label = 'Probability Type'
×
859

860
                case 'session_template_id':
×
861
                    label = 'Session Template ID'
×
862
                    widget.setMinimum(0)
×
863
                    widget.setMaximum(11)
×
864

865
                case 'delay_secs':
×
866
                    label = 'Initial Delay, min'
×
867
                    widget.setMaximum(60)
×
868

UNCOV
869
                case 'training_phase':
×
UNCOV
870
                    widget.setSpecialValueText('automatic')
×
871
                    widget.setMaximum(5)
×
UNCOV
872
                    widget.setMinimum(-1)
×
UNCOV
873
                    widget.setValue(-1)
×
874

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

UNCOV
888
                case 'reward_set_ul':
×
UNCOV
889
                    label = 'Reward Set, μl'
×
890

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

UNCOV
903
                case 'reward_amount_ul':
×
UNCOV
904
                    label = 'Reward Amount, μl'
×
UNCOV
905
                    widget.setSingleStep(0.1)
×
UNCOV
906
                    widget.setMinimum(0)
×
907

UNCOV
908
                case 'stim_gain':
×
UNCOV
909
                    label = 'Stimulus Gain'
×
910

UNCOV
911
                case 'stim_reverse':
×
UNCOV
912
                    label = 'Reverse Stimulus'
×
913

UNCOV
914
                case 'duration_spontaneous':
×
UNCOV
915
                    label = 'Spontaneous Activity, s'
×
UNCOV
916
                    widget.setMinimum(0)
×
UNCOV
917
                    widget.setMaximum(60 * 60 * 24 - 1)
×
UNCOV
918
                    widget.setValue(arg.default)
×
919

UNCOV
920
            widget.wheelEvent = lambda event: None
×
UNCOV
921
            layout.addRow(self.tr(label), widget)
×
922

923
        # add label to indicate absence of task specific parameters
UNCOV
924
        if layout.rowCount() == 0:
×
UNCOV
925
            layout.addRow(self.tr('(none)'), None)
×
UNCOV
926
            layout.itemAt(0, 0).widget().setEnabled(False)
×
927

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

UNCOV
939
        _helper('training_phase', '--training_phase', 'd')
×
UNCOV
940
        _helper('adaptive_reward', '--adaptive_reward', '0.1f')
×
UNCOV
941
        _helper('adaptive_gain', '--adaptive_gain', '0.1f')
×
942

943
    def _set_task_arg(self, key, value):
2✔
UNCOV
944
        self.task_arguments[key] = value
×
945

946
    def _filter_subjects(self):
2✔
UNCOV
947
        filter_str = self.lineEditSubject.text().lower()
×
UNCOV
948
        result = [s for s in self.model.all_subjects if filter_str in s.lower()]
×
UNCOV
949
        if len(result) == 0:
×
UNCOV
950
            result = [self.model.test_subject_name]
×
UNCOV
951
        self.uiComboSubject.setModel(QtCore.QStringListModel(result))
×
952

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

965
    def start_stop(self):
2✔
UNCOV
966
        match self.uiPushStart.text():
×
UNCOV
967
            case 'Start':
×
UNCOV
968
                self.uiPushStart.setText('Stop')
×
UNCOV
969
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
970
                self._enable_ui_elements()
×
971

UNCOV
972
                self.tabLog.plainTextEditNarrative.clear()
×
UNCOV
973
                self.tabLog.narrativeUpdated.connect(self._on_updated_narrative)
×
974

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

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

UNCOV
1006
                self.controller2model()
×
1007

UNCOV
1008
                logging.disable(logging.INFO)
×
UNCOV
1009
                task = EmptySession(subject=self.model.subject, append=self.append_session, interactive=False)
×
UNCOV
1010
                logging.disable(logging.NOTSET)
×
UNCOV
1011
                self.model.session_folder = task.paths['SESSION_FOLDER']
×
UNCOV
1012
                if self.model.session_folder.joinpath('.stop').exists():
×
UNCOV
1013
                    self.model.session_folder.joinpath('.stop').unlink()
×
UNCOV
1014
                self.model.raw_data_folder = task.paths['SESSION_RAW_DATA_FOLDER']
×
1015

1016
                # disable Bpod status LED
UNCOV
1017
                bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1018
                bpod.set_status_led(False)
×
1019

1020
                # close Bpod singleton so subprocess can access use the port
UNCOV
1021
                bpod.close()
×
1022

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

UNCOV
1069
                self.tabLog.narrativeTimerTimeout()
×
UNCOV
1070
                self.tabLog.narrativeUpdated.disconnect()
×
UNCOV
1071
                self.tabLog.plainTextEditNarrative.setEnabled(False)
×
1072

UNCOV
1073
                if self.model.session_folder and self.model.session_folder.exists():
×
UNCOV
1074
                    self.model.session_folder.joinpath('.stop').touch()
×
1075

1076
    @pyqtSlot(bytes)
2✔
1077
    def _on_updated_narrative(self, narrative: bytes):
2✔
1078
        """Update narrative.txt if text-field has been modified."""
UNCOV
1079
        self.model.session_folder.mkdir(parents=True, exist_ok=True)
×
UNCOV
1080
        with self.model.session_folder.joinpath('narrative.txt').open('w+b') as f:
×
UNCOV
1081
            f.write(narrative)
×
1082

1083
    def _on_read_standard_output(self):
2✔
1084
        """
1085
        Read and process standard output entries.
1086

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

1101
    def _on_read_standard_error(self):
2✔
1102
        """
1103
        Read and process standard error entries.
1104

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

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

UNCOV
1123
        self.running_task_process = None
×
1124

1125
        # re-enable UI elements
UNCOV
1126
        self.uiPushStart.setText('Start')
×
UNCOV
1127
        self.uiPushStart.setStatusTip('start the session')
×
UNCOV
1128
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
1129
        self._enable_ui_elements()
×
1130

1131
        # recall state of Bpod status LED
UNCOV
1132
        bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1133
        bpod.set_status_led(self.uiPushStatusLED.isChecked())
×
1134

UNCOV
1135
        if (task_settings_file := Path(self.model.raw_data_folder).joinpath('_iblrig_taskSettings.raw.json')).exists():
×
UNCOV
1136
            with open(task_settings_file) as fid:
×
UNCOV
1137
                session_data = json.load(fid)
×
1138

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

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

1172
    def flush(self):
2✔
1173
        # paint button blue when in toggled state
UNCOV
1174
        self.uiPushFlush.setStyleSheet(
×
1175
            'QPushButton {background-color: rgb(128, 128, 255);}' if self.uiPushFlush.isChecked() else ''
1176
        )
UNCOV
1177
        self._enable_ui_elements()
×
1178

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

1193
    def toggle_status_led(self, is_toggled: bool):
2✔
1194
        # paint button green when in toggled state
UNCOV
1195
        self.uiPushStatusLED.setStyleSheet('QPushButton {background-color: rgb(128, 255, 128);}' if is_toggled else '')
×
UNCOV
1196
        self._enable_ui_elements()
×
1197

UNCOV
1198
        try:
×
UNCOV
1199
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
UNCOV
1200
            bpod.set_status_led(is_toggled)
×
UNCOV
1201
        except (OSError, BpodErrorException, AttributeError):
×
UNCOV
1202
            self.uiPushStatusLED.setChecked(False)
×
UNCOV
1203
            self.uiPushStatusLED.setStyleSheet('')
×
1204

1205
    def _enable_ui_elements(self):
2✔
UNCOV
1206
        is_running = self.uiPushStart.text() == 'Stop'
×
1207

UNCOV
1208
        self.uiPushStart.setEnabled(
×
1209
            not self.uiPushFlush.isChecked()
1210
            and len(self.uiListProjects.selectedIndexes()) > 0
1211
            and len(self.uiListProcedures.selectedIndexes()) > 0
1212
        )
UNCOV
1213
        self.tabLog.plainTextEditNarrative.setEnabled(is_running)
×
UNCOV
1214
        self.uiPushPause.setEnabled(is_running)
×
UNCOV
1215
        self.uiPushFlush.setEnabled(not is_running)
×
UNCOV
1216
        self.uiPushReward.setEnabled(not is_running)
×
UNCOV
1217
        self.uiPushStatusLED.setEnabled(not is_running)
×
UNCOV
1218
        self.uiGroupParameters.setEnabled(not is_running)
×
UNCOV
1219
        self.uiGroupTaskParameters.setEnabled(not is_running)
×
UNCOV
1220
        self.uiGroupTools.setEnabled(not is_running)
×
UNCOV
1221
        self.repaint()
×
1222

1223

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

1245
    def _on_text_changed(self):
2✔
UNCOV
1246
        enable_ok = len(self.lineEditUsername.text()) > 0 and len(self.lineEditPassword.text()) > 0
×
UNCOV
1247
        self.buttonBox.button(self.buttonBox.Ok).setEnabled(enable_ok)
×
1248

1249
    def _toggle_password_visibility(self):
2✔
UNCOV
1250
        if self.toggle_password.isChecked():
×
UNCOV
1251
            self.toggle_password.setIcon(QtGui.QIcon(':/images/show'))
×
UNCOV
1252
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
×
1253
        else:
UNCOV
1254
            self.toggle_password.setIcon(QtGui.QIcon(':/images/hide'))
×
UNCOV
1255
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
×
1256

1257

1258
class UpdateNotice(QtWidgets.QDialog, Ui_update):
2✔
1259
    """
1260
    A dialog for displaying update notices.
1261

1262
    This class is used to create a dialog for displaying update notices.
1263
    It shows information about the available update and provides a changelog.
1264

1265
    Parameters
1266
    ----------
1267
    parent : QtWidgets.QWidget
1268
        The parent widget associated with this dialog.
1269

1270
    update_available : bool
1271
        Indicates if an update is available.
1272

1273
    version : str
1274
        The version of the available update.
1275
    """
1276

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

1286

1287
def main():
2✔
1288
    # argument parser
UNCOV
1289
    parser = argparse.ArgumentParser()
×
UNCOV
1290
    parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='increase logging verbosity')
×
UNCOV
1291
    parser.add_argument(
×
1292
        '-r', '--remote_devices', action='store_true', dest='remote_devices', help='show controls for remote devices'
1293
    )
UNCOV
1294
    args = parser.parse_args()
×
1295

1296
    # set logging verbosity
UNCOV
1297
    if args.debug:
×
UNCOV
1298
        setup_logger(name=None, level='DEBUG')
×
1299
    else:
UNCOV
1300
        setup_logger(name='iblrig', level='INFO')
×
1301

1302
    # set app information
UNCOV
1303
    QtCore.QCoreApplication.setOrganizationName('International Brain Laboratory')
×
UNCOV
1304
    QtCore.QCoreApplication.setOrganizationDomain('internationalbrainlab.org')
×
UNCOV
1305
    QtCore.QCoreApplication.setApplicationName('IBLRIG Wizard')
×
UNCOV
1306
    if os.name == 'nt':
×
UNCOV
1307
        app_id = f'IBL.iblrig.wizard.{iblrig.__version__}'
×
UNCOV
1308
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
×
1309

1310
    # instantiate app
UNCOV
1311
    app = QtWidgets.QApplication(['', '--no-sandbox'])
×
UNCOV
1312
    app.setStyle('Fusion')
×
UNCOV
1313
    w = RigWizard(debug=args.debug, remote_devices=args.remote_devices)
×
UNCOV
1314
    w.show()
×
UNCOV
1315
    app.exec()
×
1316

1317

1318
if __name__ == '__main__':
2✔
UNCOV
1319
    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