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

int-brain-lab / iblrig / 10568073180

26 Aug 2024 10:13PM UTC coverage: 47.538% (+0.7%) from 46.79%
10568073180

Pull #711

github

eeff82
web-flow
Merge 599c9edfb into ad41db25f
Pull Request #711: 8.23.2

121 of 135 new or added lines in 8 files covered. (89.63%)

1025 existing lines in 22 files now uncovered.

4084 of 8591 relevant lines covered (47.54%)

0.95 hits per line

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

20.6
/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
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('subjects', 'list', alive=True, stock=False, lab=self.iblrig_settings['ALYX_LAB'])
2✔
253
        self.all_subjects.remove(self.test_subject_name)
2✔
254
        self.all_subjects = [self.test_subject_name] + sorted(set(self.all_subjects + [s['nickname'] for s in rest_subjects]))
2✔
255

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

261
        return True
2✔
262

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

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

281

282
class RigWizard(QtWidgets.QMainWindow, Ui_wizard):
2✔
283
    training_info: dict = {}
2✔
284
    session_info: dict = {}
2✔
285
    task_parameters: dict | None = None
2✔
286
    new_subject_details = QtCore.pyqtSignal()
2✔
287

288
    def __init__(self, debug: bool = False, remote_devices: bool = False):
2✔
289
        super().__init__()
×
290
        self.setupUi(self)
×
291

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

303
        self.debug = debug
×
304
        self.settings = QtCore.QSettings()
×
305

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

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

338
        # task parameters and subject details
339
        self.uiComboTask.currentTextChanged.connect(self._controls_for_task_arguments)
×
340
        self.uiComboTask.currentTextChanged.connect(self._get_task_parameters)
×
UNCOV
341
        self.uiComboSubject.currentTextChanged.connect(self._get_subject_details)
×
UNCOV
342
        self.new_subject_details.connect(self._set_automatic_values)
×
343
        self.model2view()
×
344

345
        # connect widgets signals to slots
346
        self.uiActionValidateHardware.triggered.connect(self._on_validate_hardware)
×
347
        self.uiActionCalibrateFrame2ttl.triggered.connect(self._on_calibrate_frame2ttl)
×
UNCOV
348
        self.uiActionCalibrateValve.triggered.connect(self._on_calibrate_valve)
×
UNCOV
349
        self.uiActionTrainingLevelV7.triggered.connect(self._on_menu_training_level_v7)
×
350

351
        self.uiPushStart.clicked.connect(self.start_stop)
×
UNCOV
352
        self.uiPushPause.clicked.connect(self.pause)
×
UNCOV
353
        self.uiListProjects.clicked.connect(self._enable_ui_elements)
×
354
        self.uiListProcedures.clicked.connect(self._enable_ui_elements)
×
355
        self.lineEditSubject.textChanged.connect(self._filter_subjects)
×
356

UNCOV
357
        self.running_task_process = None
×
UNCOV
358
        self.task_arguments = dict()
×
359
        self.task_settings_widgets = None
×
360

361
        self.uiPushStart.installEventFilter(self)
×
362
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
363
        self.uiPushPause.setIcon(self.style().standardIcon(QStyle.SP_MediaPause))
×
364

365
        self.controller2model()
×
366

UNCOV
367
        self.tabWidget.currentChanged.connect(self._on_switch_tab)
×
368

369
        # username
370
        if self.iblrig_settings.ALYX_URL is not None:
×
UNCOV
371
            self.uiLineEditUser.returnPressed.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
UNCOV
372
            self.uiPushButtonLogIn.released.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
373
        else:
374
            self.uiLineEditUser.setPlaceholderText('')
×
375
            self.uiPushButtonLogIn.setEnabled(False)
×
376

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

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

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

402
        # show splash-screen / store validation results
403
        splash_screen = Splash(parent=self)
×
404
        splash_screen.exec()
×
405
        self.validation_results = splash_screen.validation_results
×
406

407
        # check for update
408
        update_worker = Worker(check_for_updates)
×
UNCOV
409
        update_worker.signals.result.connect(self._on_check_update_result)
×
UNCOV
410
        QThreadPool.globalInstance().start(update_worker)
×
411

412
        # show GUI
413
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowFullscreenButtonHint)
×
414
        self.move(self.settings.value('pos', self.pos(), QtCore.QPoint))
×
UNCOV
415
        self.resize(self.settings.value('size', self.size(), QtCore.QSize))
×
416
        self.show()
×
417

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

435
    @property
2✔
436
    def iblrig_settings(self) -> RigSettings:
2✔
437
        return self.model.iblrig_settings
×
438

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

443
    def _get_task_parameters(self, task_name):
2✔
444
        worker = Worker(self.model.get_task_parameters, task_name)
×
445
        worker.signals.result.connect(self._on_task_parameters_result)
×
UNCOV
446
        QThreadPool.globalInstance().start(worker)
×
447

448
    def _on_task_parameters_result(self, result):
2✔
UNCOV
449
        self.task_parameters = result
×
450
        self._get_subject_details(self.uiComboSubject.currentText())
×
451

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

466
    def _on_subject_details_result(self, result):
2✔
UNCOV
467
        self.training_info, self.session_info = result
×
UNCOV
468
        self.new_subject_details.emit()
×
469

470
    def _show_error_dialog(
2✔
471
        self,
472
        title: str,
473
        description: str,
474
        issues: list[str] | None = None,
475
        suggestions: list[str] | None = None,
476
        leads: list[str] | None = None,
477
    ):
478
        text = description.strip()
×
479

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

494
        build_list(issues, 'Possible issue')
×
495
        build_list(suggestions, 'Suggested action')
×
496
        build_list(leads, 'Possible lead')
×
UNCOV
497
        QtWidgets.QMessageBox.critical(self, title, text)
×
498

499
    def _on_switch_tab(self, index):
2✔
500
        # if self.tabWidget.tabText(index) == 'Session':
501
        # QtCore.QTimer.singleShot(1, lambda: self.resize(self.minimumSizeHint()))
502
        # self.adjustSize()
UNCOV
503
        pass
×
504

505
    def _on_validate_hardware(self) -> None:
2✔
506
        SystemValidationDialog(self, hardware_settings=self.hardware_settings, rig_settings=self.iblrig_settings)
×
507

508
    def _on_calibrate_frame2ttl(self) -> None:
2✔
509
        Frame2TTLCalibrationDialog(self, hardware_settings=self.hardware_settings)
×
510

511
    def _on_calibrate_valve(self) -> None:
2✔
512
        ValveCalibrationDialog(self)
×
513

514
    def _on_menu_training_level_v7(self) -> None:
2✔
515
        """
516
        Prompt user for a session path to get v7 training level.
517

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

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

539
        # get task settings
540
        task_settings = load_settings(session_path, task_collection='raw_behavior_data')
×
541
        if task_settings is None:
×
UNCOV
542
            QtWidgets.QMessageBox().critical(self, 'Error', f'No task settings found in {session_path}')
×
543
            return
×
544

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

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

582
    def _on_check_update_result(self, result: tuple[bool, str]) -> None:
2✔
583
        """
584
        Handle the result of checking for updates.
585

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

592
        Returns
593
        -------
594
        None
595
        """
596
        if result[0]:
×
UNCOV
597
            UpdateNotice(parent=self, version=result[1])
×
598

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

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

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

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

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

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

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

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

720
    def controller2model(self):
2✔
UNCOV
721
        self.model.procedures = [i.data() for i in self.uiListProcedures.selectedIndexes()]
×
UNCOV
722
        self.model.projects = [i.data() for i in self.uiListProjects.selectedIndexes()]
×
UNCOV
723
        self.model.task_name = self.uiComboTask.currentText()
×
UNCOV
724
        self.model.subject = self.uiComboSubject.currentText()
×
725

726
    def _controls_for_task_arguments(self, task_name: str):
2✔
UNCOV
727
        self.controller2model()
×
UNCOV
728
        self.task_arguments = dict()
×
729

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

UNCOV
756
        group = self.uiGroupTaskParameters
×
UNCOV
757
        layout = group.layout()
×
UNCOV
758
        self.task_settings_widgets = [None] * len(args)
×
759

UNCOV
760
        while layout.rowCount():
×
UNCOV
761
            layout.removeRow(0)
×
762

UNCOV
763
        for arg in args:
×
UNCOV
764
            param = str(max(arg.option_strings, key=len))
×
UNCOV
765
            label = param.replace('_', ' ').replace('--', '').title()
×
766

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

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

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

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

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

815
            # create widget for adaptive gain
UNCOV
816
            elif arg.dest == 'adaptive_gain':
×
UNCOV
817
                widget = QtWidgets.QDoubleSpinBox()
×
UNCOV
818
                widget.setDecimals(1)
×
819

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

832
            # no other argument types supported for now
833
            else:
834
                continue
×
835

836
            # add custom widget properties
837
            widget.setObjectName(param)
×
838
            widget.setProperty('parameter_name', param)
×
839
            widget.setProperty('parameter_dest', arg.dest)
×
840

841
            # display help strings as status tip
UNCOV
842
            if arg.help:
×
UNCOV
843
                widget.setStatusTip(arg.help)
×
844

845
            # some customizations
UNCOV
846
            match widget.property('parameter_dest'):
×
UNCOV
847
                case 'probability_left' | 'probability_opto_stim':
×
848
                    widget.setMinimum(0.0)
×
849
                    widget.setMaximum(1.0)
×
UNCOV
850
                    widget.setSingleStep(0.1)
×
UNCOV
851
                    widget.setDecimals(2)
×
852

UNCOV
853
                case 'contrast_set_probability_type':
×
UNCOV
854
                    label = 'Probability Type'
×
855

856
                case 'session_template_id':
×
857
                    label = 'Session Template ID'
×
858
                    widget.setMinimum(0)
×
UNCOV
859
                    widget.setMaximum(11)
×
860

861
                case 'delay_secs':
×
862
                    label = 'Initial Delay, s'
×
863
                    widget.setMaximum(86400)
×
864

865
                case 'training_phase':
×
866
                    widget.setSpecialValueText('automatic')
×
867
                    widget.setMaximum(5)
×
UNCOV
868
                    widget.setMinimum(-1)
×
UNCOV
869
                    widget.setValue(-1)
×
870

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

UNCOV
884
                case 'reward_set_ul':
×
UNCOV
885
                    label = 'Reward Set, μl'
×
886

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

UNCOV
899
                case 'reward_amount_ul':
×
UNCOV
900
                    label = 'Reward Amount, μl'
×
UNCOV
901
                    widget.setSingleStep(0.1)
×
UNCOV
902
                    widget.setMinimum(0)
×
903

UNCOV
904
                case 'stim_gain':
×
UNCOV
905
                    label = 'Stimulus Gain'
×
906

UNCOV
907
                case 'stim_reverse':
×
UNCOV
908
                    label = 'Reverse Stimulus'
×
909

UNCOV
910
                case 'duration_spontaneous':
×
UNCOV
911
                    label = 'Spontaneous Activity, s'
×
UNCOV
912
                    widget.setMinimum(0)
×
UNCOV
913
                    widget.setMaximum(60 * 60 * 24 - 1)
×
UNCOV
914
                    widget.setValue(arg.default)
×
915

UNCOV
916
            widget.wheelEvent = lambda event: None
×
UNCOV
917
            layout.addRow(self.tr(label), widget)
×
918

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

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

UNCOV
935
        _helper('training_phase', '--training_phase', 'd')
×
UNCOV
936
        _helper('adaptive_reward', '--adaptive_reward', '0.1f')
×
UNCOV
937
        _helper('adaptive_gain', '--adaptive_gain', '0.1f')
×
938

939
    def _set_task_arg(self, key, value):
2✔
UNCOV
940
        self.task_arguments[key] = value
×
941

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

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

961
    def start_stop(self):
2✔
UNCOV
962
        match self.uiPushStart.text():
×
UNCOV
963
            case 'Start':
×
UNCOV
964
                self.uiPushStart.setText('Stop')
×
UNCOV
965
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
966
                self._enable_ui_elements()
×
967

UNCOV
968
                dlg = QtWidgets.QInputDialog()
×
UNCOV
969
                weight, ok = dlg.getDouble(
×
970
                    self,
971
                    'Subject Weight',
972
                    'Subject Weight (g):',
973
                    value=0,
974
                    min=0,
975
                    decimals=2,
976
                    flags=dlg.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint,
977
                )
UNCOV
978
                if not ok or weight == 0:
×
UNCOV
979
                    self.uiPushStart.setText('Start')
×
UNCOV
980
                    self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
981
                    self._enable_ui_elements()
×
UNCOV
982
                    return
×
983

UNCOV
984
                self.controller2model()
×
985

UNCOV
986
                logging.disable(logging.INFO)
×
UNCOV
987
                task = EmptySession(subject=self.model.subject, append=self.uiCheckAppend.isChecked(), interactive=False)
×
UNCOV
988
                logging.disable(logging.NOTSET)
×
UNCOV
989
                self.model.session_folder = task.paths['SESSION_FOLDER']
×
UNCOV
990
                if self.model.session_folder.joinpath('.stop').exists():
×
UNCOV
991
                    self.model.session_folder.joinpath('.stop').unlink()
×
UNCOV
992
                self.model.raw_data_folder = task.paths['SESSION_RAW_DATA_FOLDER']
×
993

994
                # disable Bpod status LED
UNCOV
995
                bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
996
                bpod.set_status_led(False)
×
997

998
                # close Bpod singleton so subprocess can access use the port
UNCOV
999
                bpod.close()
×
1000

1001
                # build the argument list for the subprocess
UNCOV
1002
                cmd = []
×
UNCOV
1003
                if self.model.task_name:
×
UNCOV
1004
                    cmd.extend([str(self.model.task_file)])
×
UNCOV
1005
                if self.model.user:
×
UNCOV
1006
                    cmd.extend(['--user', self.model.user])
×
UNCOV
1007
                if self.model.subject:
×
UNCOV
1008
                    cmd.extend(['--subject', self.model.subject])
×
UNCOV
1009
                if self.model.procedures:
×
UNCOV
1010
                    cmd.extend(['--procedures', *self.model.procedures])
×
UNCOV
1011
                if self.model.projects:
×
UNCOV
1012
                    cmd.extend(['--projects', *self.model.projects])
×
UNCOV
1013
                if len(remotes := self.listViewRemoteDevices.getDevices()) > 0:
×
UNCOV
1014
                    cmd.extend(['--remote', *remotes])
×
UNCOV
1015
                for key, value in self.task_arguments.items():
×
UNCOV
1016
                    if isinstance(value, list):
×
UNCOV
1017
                        cmd.extend([key] + value)
×
UNCOV
1018
                    elif isinstance(value, bool):
×
UNCOV
1019
                        if value is True:
×
UNCOV
1020
                            cmd.append(key)
×
1021
                        else:
UNCOV
1022
                            pass
×
1023
                    else:
UNCOV
1024
                        cmd.extend([key, value])
×
UNCOV
1025
                cmd.extend(['--weight', f'{weight}'])
×
UNCOV
1026
                cmd.extend(['--log-level', 'DEBUG' if self.debug else 'INFO'])
×
UNCOV
1027
                cmd.append('--wizard')
×
UNCOV
1028
                if self.uiCheckAppend.isChecked():
×
UNCOV
1029
                    cmd.append('--append')
×
UNCOV
1030
                if self.running_task_process is None:
×
UNCOV
1031
                    self.tabLog.clear()
×
UNCOV
1032
                    self.tabLog.appendText(f'Starting subprocess: {self.model.task_name} ...\n', 'White')
×
UNCOV
1033
                    log.info('Starting subprocess')
×
UNCOV
1034
                    log.info(subprocess.list2cmdline(cmd))
×
UNCOV
1035
                    self.running_task_process = QtCore.QProcess()
×
UNCOV
1036
                    self.running_task_process.setWorkingDirectory(BASE_DIR)
×
UNCOV
1037
                    self.running_task_process.setProcessChannelMode(QtCore.QProcess.SeparateChannels)
×
UNCOV
1038
                    self.running_task_process.finished.connect(self._on_task_finished)
×
UNCOV
1039
                    self.running_task_process.readyReadStandardOutput.connect(self._on_read_standard_output)
×
UNCOV
1040
                    self.running_task_process.readyReadStandardError.connect(self._on_read_standard_error)
×
UNCOV
1041
                    self.running_task_process.start(shutil.which('python'), cmd)
×
UNCOV
1042
                self.uiPushStart.setStatusTip('stop the session after the current trial')
×
UNCOV
1043
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
1044
                self.tabWidget.setCurrentIndex(self.tabWidget.indexOf(self.tabLog))
×
UNCOV
1045
            case 'Stop':
×
UNCOV
1046
                self.uiPushStart.setEnabled(False)
×
UNCOV
1047
                if self.model.session_folder and self.model.session_folder.exists():
×
UNCOV
1048
                    self.model.session_folder.joinpath('.stop').touch()
×
1049

1050
    def _on_read_standard_output(self):
2✔
1051
        """
1052
        Read and process standard output entries.
1053

1054
        Reads standard output from a running task process, processes each entry,
1055
        extracts color information, sets character color in the QPlainTextEdit widget,
1056
        and appends time and message information to the widget.
1057
        """
UNCOV
1058
        data = self.running_task_process.readAllStandardOutput().data().decode('utf-8', 'ignore').strip()
×
UNCOV
1059
        entries = re.finditer(REGEX_STDOUT, data)
×
UNCOV
1060
        for entry in entries:
×
UNCOV
1061
            color = ANSI_COLORS.get(entry.groupdict().get('color', '37'), 'White')
×
UNCOV
1062
            time = entry.groupdict().get('time', '')
×
UNCOV
1063
            msg = entry.groupdict().get('message', '')
×
UNCOV
1064
            self.tabLog.appendText(f'{time} {msg}', color)
×
UNCOV
1065
        if self.debug:
×
UNCOV
1066
            print(data)
×
1067

1068
    def _on_read_standard_error(self):
2✔
1069
        """
1070
        Read and process standard error entries.
1071

1072
        Reads standard error from a running task process, sets character color
1073
        in the QPlainTextEdit widget to indicate an error (Red), and appends
1074
        the error message to the widget.
1075
        """
UNCOV
1076
        data = self.running_task_process.readAllStandardError().data().decode('utf-8', 'ignore').strip()
×
UNCOV
1077
        self.tabLog.appendText(data, 'Red')
×
UNCOV
1078
        if self.debug:
×
UNCOV
1079
            print(data)
×
1080

1081
    def _on_task_finished(self, exit_code, exit_status):
2✔
UNCOV
1082
        self.tabLog.appendText('\nSubprocess finished.', 'White')
×
UNCOV
1083
        if exit_code:
×
UNCOV
1084
            msg_box = QtWidgets.QMessageBox(parent=self)
×
UNCOV
1085
            msg_box.setWindowTitle('Oh no!')
×
UNCOV
1086
            msg_box.setText('The task was terminated with an error.\nPlease check the log for details.')
×
UNCOV
1087
            msg_box.setIcon(QtWidgets.QMessageBox().Critical)
×
UNCOV
1088
            msg_box.exec_()
×
1089

UNCOV
1090
        self.running_task_process = None
×
1091

1092
        # re-enable UI elements
UNCOV
1093
        self.uiPushStart.setText('Start')
×
UNCOV
1094
        self.uiPushStart.setStatusTip('start the session')
×
UNCOV
1095
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
1096
        self._enable_ui_elements()
×
1097

1098
        # recall state of Bpod status LED
UNCOV
1099
        bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1100
        bpod.set_status_led(self.uiPushStatusLED.isChecked())
×
1101

UNCOV
1102
        if (task_settings_file := Path(self.model.raw_data_folder).joinpath('_iblrig_taskSettings.raw.json')).exists():
×
UNCOV
1103
            with open(task_settings_file) as fid:
×
UNCOV
1104
                session_data = json.load(fid)
×
1105

1106
            # check if session was a dud
UNCOV
1107
            if (
×
1108
                (ntrials := session_data['NTRIALS']) < 42
1109
                and not any([x in self.model.task_name for x in ('spontaneous', 'passive')])
1110
                and not self.uiCheckAppend.isChecked()
1111
            ):
UNCOV
1112
                answer = QtWidgets.QMessageBox.question(
×
1113
                    self,
1114
                    'Is this a dud?',
1115
                    f"The session consisted of only {ntrials:d} trial"
1116
                    f"{'s' if ntrials > 1 else ''} and appears to be a dud.\n\n"
1117
                    f"Should it be deleted?",
1118
                )
UNCOV
1119
                if answer == QtWidgets.QMessageBox.Yes:
×
UNCOV
1120
                    shutil.rmtree(self.model.session_folder)
×
UNCOV
1121
                    return
×
1122

1123
            # manage poop count
UNCOV
1124
            dlg = QtWidgets.QInputDialog()
×
UNCOV
1125
            droppings, ok = dlg.getInt(
×
1126
                self,
1127
                'Droppings',
1128
                'Number of droppings:',
1129
                value=0,
1130
                min=0,
1131
                flags=dlg.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint,
1132
            )
UNCOV
1133
            session_data['POOP_COUNT'] = droppings
×
UNCOV
1134
            with open(task_settings_file, 'w') as fid:
×
UNCOV
1135
                json.dump(session_data, fid, indent=4, sort_keys=True, default=str)
×
1136

1137
    def flush(self):
2✔
1138
        # paint button blue when in toggled state
UNCOV
1139
        self.uiPushFlush.setStyleSheet(
×
1140
            'QPushButton {background-color: rgb(128, 128, 255);}' if self.uiPushFlush.isChecked() else ''
1141
        )
UNCOV
1142
        self._enable_ui_elements()
×
1143

UNCOV
1144
        try:
×
UNCOV
1145
            bpod = Bpod(
×
1146
                self.hardware_settings['device_bpod']['COM_BPOD'],
1147
                skip_initialization=True,
1148
                disable_behavior_ports=[1, 2, 3],
1149
            )
UNCOV
1150
            bpod.open_valve(self.uiPushFlush.isChecked())
×
UNCOV
1151
        except (OSError, BpodErrorException):
×
UNCOV
1152
            print(traceback.format_exc())
×
UNCOV
1153
            print('Cannot find bpod - is it connected?')
×
UNCOV
1154
            self.uiPushFlush.setChecked(False)
×
UNCOV
1155
            self.uiPushFlush.setStyleSheet('')
×
UNCOV
1156
            return
×
1157

1158
    def toggle_status_led(self, is_toggled: bool):
2✔
1159
        # paint button green when in toggled state
UNCOV
1160
        self.uiPushStatusLED.setStyleSheet('QPushButton {background-color: rgb(128, 255, 128);}' if is_toggled else '')
×
UNCOV
1161
        self._enable_ui_elements()
×
1162

UNCOV
1163
        try:
×
UNCOV
1164
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
UNCOV
1165
            bpod.set_status_led(is_toggled)
×
UNCOV
1166
        except (OSError, BpodErrorException, AttributeError):
×
UNCOV
1167
            self.uiPushStatusLED.setChecked(False)
×
UNCOV
1168
            self.uiPushStatusLED.setStyleSheet('')
×
1169

1170
    def _enable_ui_elements(self):
2✔
UNCOV
1171
        is_running = self.uiPushStart.text() == 'Stop'
×
1172

UNCOV
1173
        self.uiPushStart.setEnabled(
×
1174
            not self.uiPushFlush.isChecked()
1175
            and len(self.uiListProjects.selectedIndexes()) > 0
1176
            and len(self.uiListProcedures.selectedIndexes()) > 0
1177
        )
UNCOV
1178
        self.uiPushPause.setEnabled(is_running)
×
UNCOV
1179
        self.uiPushFlush.setEnabled(not is_running)
×
UNCOV
1180
        self.uiPushReward.setEnabled(not is_running)
×
UNCOV
1181
        self.uiPushStatusLED.setEnabled(not is_running)
×
UNCOV
1182
        self.uiCheckAppend.setEnabled(not is_running)
×
UNCOV
1183
        self.uiGroupParameters.setEnabled(not is_running)
×
UNCOV
1184
        self.uiGroupTaskParameters.setEnabled(not is_running)
×
UNCOV
1185
        self.uiGroupTools.setEnabled(not is_running)
×
UNCOV
1186
        self.repaint()
×
1187

1188

1189
class LoginWindow(QtWidgets.QDialog, Ui_login):
2✔
1190
    def __init__(self, parent: RigWizard, username: str = '', password: str = '', remember: bool = False):
2✔
UNCOV
1191
        super().__init__(parent)
×
UNCOV
1192
        self.setupUi(self)
×
UNCOV
1193
        self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
×
UNCOV
1194
        self.labelServer.setText(str(parent.iblrig_settings['ALYX_URL']))
×
UNCOV
1195
        self.lineEditUsername.setText(username)
×
UNCOV
1196
        self.lineEditPassword.setText(password)
×
UNCOV
1197
        self.checkBoxRememberMe.setChecked(remember)
×
UNCOV
1198
        self.lineEditUsername.textChanged.connect(self._on_text_changed)
×
UNCOV
1199
        self.lineEditPassword.textChanged.connect(self._on_text_changed)
×
UNCOV
1200
        self.toggle_password = self.lineEditPassword.addAction(
×
1201
            QtGui.QIcon(':/images/hide'), QtWidgets.QLineEdit.ActionPosition.TrailingPosition
1202
        )
UNCOV
1203
        self.toggle_password.triggered.connect(self._toggle_password_visibility)
×
UNCOV
1204
        self.toggle_password.setCheckable(True)
×
UNCOV
1205
        if len(username) > 0:
×
UNCOV
1206
            self.lineEditPassword.setFocus()
×
UNCOV
1207
        self._on_text_changed()
×
UNCOV
1208
        self.exec()
×
1209

1210
    def _on_text_changed(self):
2✔
UNCOV
1211
        enable_ok = len(self.lineEditUsername.text()) > 0 and len(self.lineEditPassword.text()) > 0
×
UNCOV
1212
        self.buttonBox.button(self.buttonBox.Ok).setEnabled(enable_ok)
×
1213

1214
    def _toggle_password_visibility(self):
2✔
UNCOV
1215
        if self.toggle_password.isChecked():
×
UNCOV
1216
            self.toggle_password.setIcon(QtGui.QIcon(':/images/show'))
×
UNCOV
1217
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
×
1218
        else:
UNCOV
1219
            self.toggle_password.setIcon(QtGui.QIcon(':/images/hide'))
×
UNCOV
1220
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
×
1221

1222

1223
class UpdateNotice(QtWidgets.QDialog, Ui_update):
2✔
1224
    """
1225
    A dialog for displaying update notices.
1226

1227
    This class is used to create a dialog for displaying update notices.
1228
    It shows information about the available update and provides a changelog.
1229

1230
    Parameters
1231
    ----------
1232
    parent : QtWidgets.QWidget
1233
        The parent widget associated with this dialog.
1234

1235
    update_available : bool
1236
        Indicates if an update is available.
1237

1238
    version : str
1239
        The version of the available update.
1240
    """
1241

1242
    def __init__(self, parent: QtWidgets.QWidget, version: str) -> None:
2✔
UNCOV
1243
        super().__init__(parent)
×
UNCOV
1244
        self.setupUi(self)
×
UNCOV
1245
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
×
UNCOV
1246
        self.uiLabelHeader.setText(f'Update to iblrig {version} is available.')
×
UNCOV
1247
        self.uiTextBrowserChanges.setMarkdown(get_changelog())
×
UNCOV
1248
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
×
UNCOV
1249
        self.exec()
×
1250

1251

1252
def main():
2✔
UNCOV
1253
    parser = argparse.ArgumentParser()
×
UNCOV
1254
    parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='increase logging verbosity')
×
UNCOV
1255
    parser.add_argument(
×
1256
        '-r', '--remote_devices', action='store_true', dest='remote_devices', help='show controls for remote devices'
1257
    )
UNCOV
1258
    args = parser.parse_args()
×
1259

UNCOV
1260
    if args.debug:
×
UNCOV
1261
        setup_logger(name=None, level='DEBUG')
×
1262
    else:
UNCOV
1263
        setup_logger(name='iblrig', level='INFO')
×
UNCOV
1264
    QtCore.QCoreApplication.setOrganizationName('International Brain Laboratory')
×
UNCOV
1265
    QtCore.QCoreApplication.setOrganizationDomain('internationalbrainlab.org')
×
UNCOV
1266
    QtCore.QCoreApplication.setApplicationName('IBLRIG Wizard')
×
1267

UNCOV
1268
    if os.name == 'nt':
×
UNCOV
1269
        app_id = f'IBL.iblrig.wizard.{iblrig.__version__}'
×
UNCOV
1270
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
×
UNCOV
1271
    app = QtWidgets.QApplication(['', '--no-sandbox'])
×
UNCOV
1272
    app.setStyle('Fusion')
×
UNCOV
1273
    w = RigWizard(debug=args.debug, remote_devices=args.remote_devices)
×
UNCOV
1274
    w.show()
×
UNCOV
1275
    app.exec()
×
1276

1277

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