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

int-brain-lab / iblrig / 9031936551

10 May 2024 12:05PM UTC coverage: 48.538% (+1.7%) from 46.79%
9031936551

Pull #643

github

53c3e3
web-flow
Merge 3c8214f78 into ec2d8e4fe
Pull Request #643: 8.19.0

377 of 1073 new or added lines in 38 files covered. (35.14%)

977 existing lines in 19 files now uncovered.

3253 of 6702 relevant lines covered (48.54%)

0.97 hits per line

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

21.47
/iblrig/gui/wizard.py
1
import argparse
2✔
2
import ctypes
2✔
3
import importlib
2✔
4
import json
2✔
5
import logging
2✔
6
import os
2✔
7
import re
2✔
8
import shutil
2✔
9
import subprocess
2✔
10
import sys
2✔
11
import traceback
2✔
12
import webbrowser
2✔
13
from collections import OrderedDict
2✔
14
from collections.abc import Iterable
2✔
15
from dataclasses import dataclass
2✔
16
from pathlib import Path
2✔
17

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

28
import iblrig.hardware_validation
2✔
29
import iblrig.path_helper
2✔
30
import iblrig_tasks
2✔
31
from iblrig.base_tasks import EmptySession, ValveMixin
2✔
32
from iblrig.choiceworld import get_subject_training_info, training_phase_from_contrast_set
2✔
33
from iblrig.constants import BASE_DIR, COPYRIGHT_YEAR
2✔
34
from iblrig.gui.frame2ttl import Frame2TTLCalibrationDialog
2✔
35
from iblrig.gui.splash import Splash
2✔
36
from iblrig.gui.tools import Worker
2✔
37
from iblrig.gui.ui_login import Ui_login
2✔
38
from iblrig.gui.ui_update import Ui_update
2✔
39
from iblrig.gui.ui_wizard import Ui_wizard
2✔
40
from iblrig.gui.validation import SystemValidationDialog
2✔
41
from iblrig.gui.valve import ValveCalibrationDialog
2✔
42
from iblrig.hardware import Bpod
2✔
43
from iblrig.hardware_validation import Status
2✔
44
from iblrig.misc import _get_task_argument_parser
2✔
45
from iblrig.path_helper import load_pydantic_yaml
2✔
46
from iblrig.pydantic_definitions import HardwareSettings, RigSettings
2✔
47
from iblrig.tools import alyx_reachable, get_anydesk_id, internet_available
2✔
48
from iblrig.version_management import check_for_updates, get_changelog
2✔
49
from iblutil.util import setup_logger
2✔
50
from one.webclient import AlyxClient
2✔
51
from pybpodapi.exceptions.bpod_error import BpodErrorException
2✔
52

53
try:
2✔
54
    import iblrig_custom_tasks
2✔
55

56
    CUSTOM_TASKS = True
2✔
UNCOV
57
except ImportError:
×
UNCOV
58
    CUSTOM_TASKS = False
×
UNCOV
59
    pass
×
60

61
log = logging.getLogger(__name__)
2✔
62
pg.setConfigOption('foreground', 'k')
2✔
63
pg.setConfigOptions(antialias=True)
2✔
64

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

75
URL_DOC = 'https://int-brain-lab.github.io/iblrig'
2✔
76
URL_REPO = 'https://github.com/int-brain-lab/iblrig/tree/iblrigv8'
2✔
77
URL_ISSUES = 'https://github.com/int-brain-lab/iblrig/issues'
2✔
78
URL_DISCUSSION = 'https://github.com/int-brain-lab/iblrig/discussions'
2✔
79

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

91

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

100

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

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

123
        # calculate free reward time
124
        class FakeSession(EmptySession, ValveMixin):
2✔
125
            pass
2✔
126

127
        fake_session = FakeSession(
2✔
128
            subject='gui_init_subject',
129
            file_hardware_settings=self.file_hardware_settings,
130
            file_iblrig_settings=self.file_iblrig_settings,
131
        )
132
        fake_session.task_params.update({'AUTOMATIC_CALIBRATION': True, 'REWARD_AMOUNT_UL': 10})
2✔
133
        fake_session.init_mixin_valve()
2✔
134
        self.free_reward_time = fake_session.compute_reward_time(self.hardware_settings.device_valve.FREE_REWARD_VOLUME_UL)
2✔
135

136
        if self.iblrig_settings.ALYX_URL is not None:
2✔
UNCOV
137
            self.alyx = AlyxClient(base_url=str(self.iblrig_settings.ALYX_URL), silent=True)
×
138

139
        self.all_users = [self.iblrig_settings['ALYX_USER']] if self.iblrig_settings['ALYX_USER'] else []
2✔
140
        self.all_procedures = sorted(PROCEDURES)
2✔
141
        self.all_projects = sorted(PROJECTS)
2✔
142

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

149
        # get the subjects from iterating over folders in the the iblrig data path
150
        if self.iblrig_settings['iblrig_local_data_path'] is None:
2✔
151
            self.all_subjects = [self.test_subject_name]
2✔
152
        else:
UNCOV
153
            folder_subjects = Path(self.iblrig_settings['iblrig_local_data_path']).joinpath(
×
154
                self.iblrig_settings['ALYX_LAB'], 'Subjects'
155
            )
UNCOV
156
            self.all_subjects = [self.test_subject_name] + sorted(
×
157
                [f.name for f in folder_subjects.glob('*') if f.is_dir() and f.name != self.test_subject_name]
158
            )
159

160
    def get_task_extra_parser(self, task_name=None):
2✔
161
        """
162
        Get the extra kwargs from the task, by importing the task and parsing the extra_parser static method
163
        This parser will give us a list of arguments and their types so we can build a custom dialog for this task
164
        :return:
165
        """
166
        assert task_name
2✔
167
        spec = importlib.util.spec_from_file_location('task', self.all_tasks[task_name])
2✔
168
        task = importlib.util.module_from_spec(spec)
2✔
169
        sys.modules[spec.name] = task
2✔
170
        spec.loader.exec_module(task)
2✔
171
        return task.Session.extra_parser()
2✔
172

173
    def login(
2✔
174
        self,
175
        username: str,
176
        password: str | None = None,
177
        do_cache: bool = False,
178
        alyx_client: AlyxClient | None = None,
179
        gui: bool = False,
180
    ) -> bool:
181
        # Use predefined AlyxClient for testing purposes:
182
        if alyx_client is not None:
2✔
183
            self.alyx = alyx_client
2✔
184

185
        # Alternatively, try to log in:
186
        else:
187
            try:
×
188
                self.alyx.authenticate(username, password, do_cache, force=password is not None)
×
189
                if self.alyx.is_logged_in and self.alyx.user == username:
×
190
                    self.user = self.alyx.user
×
191
                    log.info(f'Logged into {self.alyx.base_url} as {self.alyx.user}')
×
192
                else:
193
                    return False
×
194
            except HTTPError as e:
×
195
                if e.errno == 400 and any(x in e.response.text for x in ('credentials', 'required')):
×
UNCOV
196
                    log.error(e.filename)
×
197
                    return False
×
198
                else:
199
                    raise e
×
200

201
        # validate connection and some parameters now that we're connected
202
        try:
2✔
203
            self.alyx.rest('locations', 'read', id=self.hardware_settings.RIG_NAME)
2✔
NEW
204
        except HTTPError as ex:
×
NEW
205
            if ex.response.status_code not in (404, 400):  # file not found; auth error
×
206
                # Likely Alyx is down or server-side issue
NEW
207
                message = 'Failed to determine lab location on Alyx'
×
NEW
208
                solution = 'Check if Alyx is reachable'
×
209
            else:
NEW
210
                message = f'Could not find rig name {self.hardware_settings.RIG_NAME} in Alyx'
×
NEW
211
                solution = (
×
212
                    f'Please check the RIG_NAME key in hardware_settings.yaml and make sure it is created in Alyx here: '
213
                    f'{self.iblrig_settings.ALYX_URL}/admin/misc/lablocation/'
214
                )
NEW
215
            QtWidgets.QMessageBox().critical(None, 'Error', f'{message}\n\n{solution}')
×
216

217
        # get subjects from Alyx: this is the set of subjects that are alive and not stock in the lab defined in settings
218
        rest_subjects = self.alyx.rest('subjects', 'list', alive=True, stock=False, lab=self.iblrig_settings['ALYX_LAB'])
2✔
219
        self.all_subjects.remove(self.test_subject_name)
2✔
220
        self.all_subjects = [self.test_subject_name] + sorted(set(self.all_subjects + [s['nickname'] for s in rest_subjects]))
2✔
221

222
        # then get the projects that map to the current user
223
        rest_projects = self.alyx.rest('projects', 'list')
2✔
224
        projects = [p['name'] for p in rest_projects if (username in p['users'] or len(p['users']) == 0)]
2✔
225
        self.all_projects = sorted(set(projects + self.all_projects))
2✔
226

227
        return True
2✔
228

229
    def logout(self):
2✔
230
        if not self.alyx.is_logged_in or self.alyx.user is not self.user:
×
231
            return
×
232
        log.info(f'User {self.user} logged out')
×
233
        self.alyx.logout()
×
UNCOV
234
        self.user = None
×
235
        self.__post_init__()
×
236

237
    def free_reward(self):
2✔
238
        try:
×
UNCOV
239
            bpod = Bpod(
×
240
                self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True, disable_behavior_ports=[1, 2, 3]
241
            )
UNCOV
242
            bpod.pulse_valve(open_time_s=self.free_reward_time)
×
UNCOV
243
        except (OSError, BpodErrorException):
×
UNCOV
244
            log.error('Cannot find bpod - is it connected?')
×
UNCOV
245
            return
×
246

247
    def get_subject_details(self, subject):
2✔
248
        self.subject_details_worker = SubjectDetailsWorker(subject)
×
249
        self.subject_details_worker.finished.connect(self.process_subject_details)
×
UNCOV
250
        self.subject_details_worker.start()
×
251

252
    def process_subject_details(self):
2✔
253
        self.subject_details = SubjectDetailsWorker.result
×
254

255

256
class RigWizard(QtWidgets.QMainWindow, Ui_wizard):
2✔
257
    def __init__(self, **kwargs):
2✔
UNCOV
258
        super().__init__()
×
UNCOV
259
        self.setupUi(self)
×
260

261
        # show splash-screen / store validation results
NEW
262
        splash_screen = Splash()
×
NEW
263
        splash_screen.exec()
×
NEW
264
        self.validation_results = splash_screen.validation_results
×
265

UNCOV
266
        self.debug = kwargs.get('debug', False)
×
UNCOV
267
        self.settings = QtCore.QSettings()
×
UNCOV
268
        self.move(self.settings.value('pos', self.pos(), QtCore.QPoint))
×
269

UNCOV
270
        try:
×
UNCOV
271
            self.model = RigWizardModel()
×
UNCOV
272
        except ValidationError as e:
×
UNCOV
273
            yml = (
×
274
                'hardware_settings.yaml'
275
                if 'hardware' in e.title
276
                else 'iblrig_settings.yaml'
277
                if 'iblrig' in e.title
278
                else 'Settings File'
279
            )
UNCOV
280
            description = ''
×
UNCOV
281
            for error in e.errors():
×
UNCOV
282
                key = '.'.join(error.get('loc', ''))
×
UNCOV
283
                val = error.get('input', '')
×
UNCOV
284
                msg = error.get('msg', '')
×
UNCOV
285
                description += (
×
286
                    f'<table>'
287
                    f'<tr><td><b>key:</b></td><td><td>{key}</td></tr>\n'
288
                    f'<tr><td><b>value:</b></td><td><td>{val}</td></tr>\n'
289
                    f'<tr><td><b>error:</b></td><td><td>{msg}</td></tr></table><br>\n'
290
                )
291
            self._show_error_dialog(title=f'Error validating {yml}', description=description.strip())
×
292
            raise e
×
293
        self.model2view()
×
294

295
        # default to biasedChoiceWorld
296
        if (idx := self.uiComboTask.findText('_iblrig_tasks_biasedChoiceWorld')) >= 0:
×
UNCOV
297
            self.uiComboTask.setCurrentIndex(idx)
×
298

299
        # connect widgets signals to slots
NEW
300
        self.uiActionValidateHardware.triggered.connect(self._on_validate_hardware)
×
UNCOV
301
        self.uiActionCalibrateFrame2ttl.triggered.connect(self._on_calibrate_frame2ttl)
×
UNCOV
302
        self.uiActionCalibrateValve.triggered.connect(self._on_calibrate_valve)
×
NEW
303
        self.uiActionTrainingLevelV7.triggered.connect(self._on_menu_training_level_v7)
×
304
        self.uiComboTask.currentTextChanged.connect(self.controls_for_extra_parameters)
×
305
        self.uiComboSubject.currentTextChanged.connect(self.model.get_subject_details)
×
306
        self.uiPushStart.clicked.connect(self.start_stop)
×
307
        self.uiPushPause.clicked.connect(self.pause)
×
308
        self.uiListProjects.clicked.connect(self._enable_ui_elements)
×
309
        self.uiListProcedures.clicked.connect(self._enable_ui_elements)
×
310
        self.lineEditSubject.textChanged.connect(self._filter_subjects)
×
311

312
        self.running_task_process = None
×
313
        self.task_arguments = dict()
×
314
        self.task_settings_widgets = None
×
315

UNCOV
316
        self.uiPushStart.installEventFilter(self)
×
317
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
318
        self.uiPushPause.setIcon(self.style().standardIcon(QStyle.SP_MediaPause))
×
319

320
        self.controller2model()
×
321

322
        self.tabWidget.currentChanged.connect(self._on_switch_tab)
×
323

324
        # username
325
        if self.model.iblrig_settings.ALYX_URL is not None:
×
326
            self.uiLineEditUser.returnPressed.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
UNCOV
327
            self.uiPushButtonLogIn.released.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
328
        else:
329
            self.uiLineEditUser.setPlaceholderText('')
×
330
            self.uiPushButtonLogIn.setEnabled(False)
×
331

332
        # tools
333
        self.uiPushFlush.clicked.connect(self.flush)
×
334
        self.uiPushReward.clicked.connect(self.model.free_reward)
×
335
        self.uiPushReward.setStatusTip(
×
336
            f'Click to grant a free reward ({self.model.hardware_settings.device_valve.FREE_REWARD_VOLUME_UL:.1f} μL)'
337
        )
338
        self.uiPushStatusLED.setChecked(self.settings.value('bpod_status_led', True, bool))
×
339
        self.uiPushStatusLED.toggled.connect(self.toggle_status_led)
×
340
        self.toggle_status_led(self.uiPushStatusLED.isChecked())
×
341

342
        # tab: log
UNCOV
343
        font = QtGui.QFont('Monospace')
×
344
        font.setStyleHint(QtGui.QFont.TypeWriter)
×
UNCOV
345
        font.setPointSize(9)
×
346
        self.uiPlainTextEditLog.setFont(font)
×
347

348
        # tab: documentation
349
        self.uiPushWebHome.clicked.connect(lambda: self.webEngineView.load(QtCore.QUrl(URL_DOC)))
×
350
        self.uiPushWebBrowser.clicked.connect(lambda: webbrowser.open(str(self.webEngineView.url().url())))
×
UNCOV
351
        self.webEngineView.setPage(CustomWebEnginePage(self))
×
352
        self.webEngineView.setUrl(QtCore.QUrl(URL_DOC))
×
353
        self.webEngineView.urlChanged.connect(self._on_doc_url_changed)
×
354

355
        # tab: about
NEW
356
        self.uiLabelCopyright.setText(f'**IBLRIG v{iblrig.__version__}**\n\n© {COPYRIGHT_YEAR}, International Brain Laboratory')
×
357
        self.commandLinkButtonGitHub.clicked.connect(lambda: webbrowser.open(URL_REPO))
×
UNCOV
358
        self.commandLinkButtonDoc.clicked.connect(lambda: webbrowser.open(URL_DOC))
×
UNCOV
359
        self.commandLinkButtonIssues.clicked.connect(lambda: webbrowser.open(URL_ISSUES))
×
360
        self.commandLinkButtonDiscussion.clicked.connect(lambda: webbrowser.open(URL_DISCUSSION))
×
361

362
        # disk stats
363
        local_data = self.model.iblrig_settings['iblrig_local_data_path']
×
364
        local_data = Path(local_data) if local_data else Path.home().joinpath('iblrig_data')
×
UNCOV
365
        v8data_size = sum(file.stat().st_size for file in Path(local_data).rglob('*'))
×
UNCOV
366
        total_space, total_used, total_free = shutil.disk_usage(local_data.anchor)
×
367
        self.uiProgressDiskSpace = QtWidgets.QProgressBar(self)
×
368
        self.uiProgressDiskSpace.setMaximumWidth(70)
×
UNCOV
369
        self.uiProgressDiskSpace.setValue(round(total_used / total_space * 100))
×
UNCOV
370
        self.uiProgressDiskSpace.setStatusTip(
×
371
            f'local IBLRIG data: {v8data_size / 1024**3: .1f} GB  •  ' f'available space: {total_free / 1024**3: .1f} GB'
372
        )
UNCOV
373
        if self.uiProgressDiskSpace.value() > 90:
×
UNCOV
374
            p = self.uiProgressDiskSpace.palette()
×
UNCOV
375
            p.setColor(QtGui.QPalette.Highlight, QtGui.QColor('red'))
×
376
            self.uiProgressDiskSpace.setPalette(p)
×
377

378
        # statusbar
379
        self.statusbar.setContentsMargins(0, 0, 6, 0)
×
380
        self.statusbar.addPermanentWidget(self.uiProgressDiskSpace)
×
UNCOV
381
        self.controls_for_extra_parameters()
×
382

383
        # self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
UNCOV
384
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowFullscreenButtonHint)
×
385

386
        # disable control of LED if Bpod does not have the respective capability
NEW
387
        try:
×
NEW
388
            bpod = Bpod(self.model.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
NEW
389
            self.uiPushStatusLED.setEnabled(bpod.can_control_led)
×
NEW
390
        except SerialException:
×
NEW
391
            pass
×
392

NEW
393
        self.show()
×
394

395
        # show validation errors / warnings:
NEW
396
        if any(results := [r for r in self.validation_results if r.status in (Status.FAIL, Status.WARN)]):
×
NEW
397
            msg_box = QtWidgets.QMessageBox(parent=self)
×
NEW
398
            msg_box.setWindowTitle('IBLRIG System Validation')
×
NEW
399
            msg_box.setIcon(QtWidgets.QMessageBox().Warning)
×
NEW
400
            msg_box.setTextFormat(QtCore.Qt.TextFormat.RichText)
×
NEW
401
            text = f"The following issue{'s were' if len(results) > 1 else ' was'} detected:"
×
NEW
402
            for result in results:
×
NEW
403
                text = (
×
404
                    text + f"<br><br>\n"
405
                    f"<b>{'Warning' if result.status == Status.WARN else 'Failure'}:</b> {result.message}<br>\n"
406
                    f"{('<b>Suggestion:</b> ' + result.solution) if result.solution is not None else ''}"
407
                )
NEW
408
            text = text + '<br><br>\nPlease refer to the System Validation tool for more details.'
×
NEW
409
            msg_box.setText(text)
×
NEW
410
            msg_box.exec()
×
411

412
        # get AnyDesk ID
413
        anydesk_worker = Worker(get_anydesk_id, True)
×
414
        anydesk_worker.signals.result.connect(self._on_get_anydesk_result)
×
415
        QThreadPool.globalInstance().tryStart(anydesk_worker)
×
416

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

422
    def _show_error_dialog(
2✔
423
        self,
424
        title: str,
425
        description: str,
426
        issues: list[str] | None = None,
427
        suggestions: list[str] | None = None,
428
        leads: list[str] | None = None,
429
    ):
430
        text = description.strip()
×
431

432
        def build_list(items: list[str] or None, header_singular: str, header_plural: str | None = None):
×
433
            nonlocal text
UNCOV
434
            if items is None or len(items) == 0:
×
435
                return
×
436
            if len(items) > 1:
×
437
                if header_plural is None:
×
438
                    header_plural = header_singular.strip() + 's'
×
439
                text += f'<br><br>{header_plural}:<ul>'
×
440
            else:
UNCOV
441
                text += f'<br><br>{header_singular.strip()}:<ul>'
×
442
            for item in items:
×
UNCOV
443
                text += f'<li>{item.strip()}</li>'
×
UNCOV
444
            text += '</ul>'
×
445

446
        build_list(issues, 'Possible issue')
×
447
        build_list(suggestions, 'Suggested action')
×
448
        build_list(leads, 'Possible lead')
×
UNCOV
449
        QtWidgets.QMessageBox.critical(self, title, text)
×
450

451
    def _on_switch_tab(self, index):
2✔
452
        # if self.tabWidget.tabText(index) == 'Session':
453
        # QtCore.QTimer.singleShot(1, lambda: self.resize(self.minimumSizeHint()))
454
        # self.adjustSize()
455
        pass
×
456

457
    def _on_validate_hardware(self) -> None:
2✔
NEW
458
        SystemValidationDialog(self, hardware_settings=self.model.hardware_settings, rig_settings=self.model.iblrig_settings)
×
459

460
    def _on_calibrate_frame2ttl(self) -> None:
2✔
NEW
461
        Frame2TTLCalibrationDialog(self, hardware_settings=self.model.hardware_settings)
×
462

463
    def _on_calibrate_valve(self) -> None:
2✔
NEW
464
        ValveCalibrationDialog(self)
×
465

466
    def _on_menu_training_level_v7(self) -> None:
2✔
467
        """
468
        Prompt user for a session path to get v7 training level.
469

470
        This code will be removed and is here only for convenience while users transition from v7 to v8
471
        """
472
        if not (local_path := Path(r'C:\iblrig_data\Subjects')).exists():
×
473
            local_path = self.model.iblrig_settings['iblrig_local_data_path']
×
UNCOV
474
        session_path = QtWidgets.QFileDialog.getExistingDirectory(
×
475
            self, 'Select Session Path', str(local_path), QtWidgets.QFileDialog.ShowDirsOnly
476
        )
477
        if session_path is None or session_path == '':
×
UNCOV
478
            return
×
UNCOV
479
        file_jsonable = next(Path(session_path).glob('raw_behavior_data/_iblrig_taskData.raw.jsonable'), None)
×
480
        if file_jsonable is None:
×
481
            QtWidgets.QMessageBox().critical(self, 'Error', f'No jsonable found in {session_path}')
×
482
            return
×
483
        trials_table, _ = iblrig.raw_data_loaders.load_task_jsonable(file_jsonable)
×
484
        if trials_table.empty:
×
485
            QtWidgets.QMessageBox().critical(self, 'Error', f'No trials found in {session_path}')
×
UNCOV
486
            return
×
487

488
        last_trial = trials_table.iloc[-1]
×
UNCOV
489
        training_phase = training_phase_from_contrast_set(last_trial['contrast_set'])
×
490
        reward_amount = last_trial['reward_amount']
×
491
        stim_gain = last_trial['stim_gain']
×
492

493
        box = QtWidgets.QMessageBox(parent=self)
×
494
        box.setIcon(QtWidgets.QMessageBox.Information)
×
UNCOV
495
        box.setModal(False)
×
496
        box.setWindowTitle('Training Level')
×
497
        box.setText(
×
498
            f'{session_path}\n\n'
499
            f'training phase:\t{training_phase}\n'
500
            f'reward:\t{reward_amount} uL\n'
501
            f'stimulus gain:\t{stim_gain}'
502
        )
503
        if self.uiComboTask.currentText() == '_iblrig_tasks_trainingChoiceWorld':
×
504
            box.setStandardButtons(QtWidgets.QMessageBox.Apply | QtWidgets.QMessageBox.Close)
×
505
        else:
506
            box.setStandardButtons(QtWidgets.QMessageBox.Close)
×
507
        box.exec()
×
508
        if box.clickedButton() == box.button(QtWidgets.QMessageBox.Apply):
×
509
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--adaptive_gain').setValue(stim_gain)
×
UNCOV
510
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--adaptive_reward').setValue(reward_amount)
×
UNCOV
511
            self.uiGroupTaskParameters.findChild(QtWidgets.QWidget, '--training_phase').setValue(training_phase)
×
512

513
    def _on_check_update_result(self, result: tuple[bool, str]) -> None:
2✔
514
        """
515
        Handle the result of checking for updates.
516

517
        Parameters
518
        ----------
519
        result : tuple[bool, str | None]
520
            A tuple containing a boolean flag indicating update availability (result[0])
521
            and the remote version string (result[1]).
522

523
        Returns
524
        -------
525
        None
526
        """
UNCOV
527
        if result[0]:
×
528
            UpdateNotice(parent=self, version=result[1])
×
529

530
    def _on_get_anydesk_result(self, result: str | None) -> None:
2✔
531
        """
532
        Handle the result of checking for the user's AnyDesk ID.
533

534
        Parameters
535
        ----------
536
        result : str | None
537
            The user's AnyDesk ID, if available.
538

539
        Returns
540
        -------
541
        None
542
        """
543
        if result is not None:
×
UNCOV
544
            self.uiLabelAnyDesk.setText(f'Your AnyDesk ID: {result}')
×
545

546
    def _on_doc_url_changed(self):
2✔
547
        self.uiPushWebBack.setEnabled(len(self.webEngineView.history().backItems(1)) > 0)
×
548
        self.uiPushWebForward.setEnabled(len(self.webEngineView.history().forwardItems(1)) > 0)
×
549

550
    def _log_in_or_out(self, username: str) -> bool:
2✔
551
        # Routine for logging out:
552
        if self.uiPushButtonLogIn.text() == 'Log Out':
×
UNCOV
553
            self.model.logout()
×
554
            self.uiLineEditUser.setText('')
×
555
            self.uiLineEditUser.setReadOnly(False)
×
556
            for action in self.uiLineEditUser.actions():
×
557
                self.uiLineEditUser.removeAction(action)
×
558
            self.uiLineEditUser.setStyleSheet('')
×
559
            self.uiLineEditUser.actions()
×
UNCOV
560
            self.uiPushButtonLogIn.setText('Log In')
×
UNCOV
561
            return True
×
562

563
        # Routine for logging in:
564
        # 1) Try to log in with just the username. This will succeed if the credentials for the respective user are cached. We
565
        #    also try to catch connection issues and show helpful error messages.
566
        try:
×
UNCOV
567
            logged_in = self.model.login(username, gui=True)
×
UNCOV
568
        except ConnectionError:
×
569
            if not internet_available(timeout=1, force_update=True):
×
570
                self._show_error_dialog(
×
571
                    title='Error connecting to Alyx',
572
                    description='Your computer appears to be offline.',
573
                    suggestions=['Check your internet connection.'],
574
                )
575
            elif not alyx_reachable():
×
576
                self._show_error_dialog(
×
577
                    title='Error connecting to Alyx',
578
                    description=f'Cannot connect to {self.model.iblrig_settings.ALYX_URL}',
579
                    leads=[
580
                        'Is `ALYX_URL` in `iblrig_settings.yaml` set correctly?',
581
                        'Is your machine allowed to connect to Alyx?',
582
                        'Is the Alyx server up and running nominally?',
583
                    ],
584
                )
UNCOV
585
            return False
×
586

587
        # 2) If there is no cached session for the given user and we can connect to Alyx: show the password dialog and loop
588
        #    until, either, the login was successful or the cancel button was pressed.
589
        if not logged_in:
×
590
            password = ''
×
591
            remember = False
×
592
            while not logged_in:
×
593
                dlg = LoginWindow(parent=self, username=username, password=password, remember=remember)
×
UNCOV
594
                if dlg.result():
×
UNCOV
595
                    username = dlg.lineEditUsername.text()
×
UNCOV
596
                    password = dlg.lineEditPassword.text()
×
UNCOV
597
                    remember = dlg.checkBoxRememberMe.isChecked()
×
598
                    dlg.deleteLater()
×
599
                    logged_in = self.model.login(username=username, password=password, do_cache=remember, gui=True)
×
600
                else:
601
                    dlg.deleteLater()
×
602
                    break
×
603

604
        # 3) Finally, if the login was successful, we need to apply some changes to the GUI
UNCOV
605
        if logged_in:
×
606
            self.uiLineEditUser.addAction(QtGui.QIcon(':/images/check'), QtWidgets.QLineEdit.ActionPosition.TrailingPosition)
×
607
            self.uiLineEditUser.setText(username)
×
UNCOV
608
            self.uiLineEditUser.setReadOnly(True)
×
UNCOV
609
            self.uiLineEditUser.setStyleSheet('background-color: rgb(246, 245, 244);')
×
610
            self.uiPushButtonLogIn.setText('Log Out')
×
611
            self.model2view()
×
612
        return logged_in
×
613

614
    @override
2✔
615
    def eventFilter(self, obj, event):
2✔
616
        if obj == self.uiPushStart and event.type() in [QtCore.QEvent.HoverEnter, QtCore.QEvent.HoverLeave]:
×
617
            for widget in [self.uiListProcedures, self.uiListProjects]:
×
UNCOV
618
                if len(widget.selectedIndexes()) > 0:
×
619
                    continue
×
620
                match event.type():
×
621
                    case QtCore.QEvent.HoverEnter:
×
622
                        widget.setStyleSheet('QListView { background-color: pink; border: 1px solid red; }')
×
UNCOV
623
                    case _:
×
UNCOV
624
                        widget.setStyleSheet('')
×
625
            return True
×
626
        return False
×
627

628
    @override
2✔
629
    def closeEvent(self, event) -> None:
2✔
630
        def accept() -> None:
×
631
            self.settings.setValue('pos', self.pos())
×
632
            self.settings.setValue('bpod_status_led', self.uiPushStatusLED.isChecked())
×
UNCOV
633
            self.toggle_status_led(is_toggled=True)
×
UNCOV
634
            bpod = Bpod(self.model.hardware_settings['device_bpod']['COM_BPOD'])  # bpod is a singleton
×
635
            bpod.close()
×
636
            event.accept()
×
637

UNCOV
638
        if self.running_task_process is None:
×
UNCOV
639
            accept()
×
640
        else:
641
            msg_box = QtWidgets.QMessageBox(parent=self)
×
642
            msg_box.setWindowTitle('Hold on')
×
UNCOV
643
            msg_box.setText('A task is running - do you really want to quit?')
×
UNCOV
644
            msg_box.setStandardButtons(QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes)
×
645
            msg_box.setIcon(QtWidgets.QMessageBox().Question)
×
646
            match msg_box.exec_():
×
UNCOV
647
                case QtWidgets.QMessageBox.No:
×
648
                    event.ignore()
×
649
                case QtWidgets.QMessageBox.Yes:
×
650
                    self.setEnabled(False)
×
UNCOV
651
                    self.repaint()
×
UNCOV
652
                    self.start_stop()
×
653
                    accept()
×
654

655
    def model2view(self):
2✔
656
        # stores the current values in the model
657
        self.controller2model()
×
658
        # set the default values
UNCOV
659
        self.uiComboTask.setModel(QtCore.QStringListModel(list(self.model.all_tasks.keys())))
×
UNCOV
660
        self.uiComboSubject.setModel(QtCore.QStringListModel(self.model.all_subjects))
×
UNCOV
661
        self.uiListProcedures.setModel(QtCore.QStringListModel(self.model.all_procedures))
×
662
        self.uiListProjects.setModel(QtCore.QStringListModel(self.model.all_projects))
×
663
        # set the selections
664
        self.uiComboTask.setCurrentText(self.model.task_name)
×
UNCOV
665
        self.uiComboSubject.setCurrentText(self.model.subject)
×
666
        _set_list_view_from_string_list(self.uiListProcedures, self.model.procedures)
×
667
        _set_list_view_from_string_list(self.uiListProjects, self.model.projects)
×
668
        self._enable_ui_elements()
×
669

670
    def controller2model(self):
2✔
671
        self.model.procedures = [i.data() for i in self.uiListProcedures.selectedIndexes()]
×
672
        self.model.projects = [i.data() for i in self.uiListProjects.selectedIndexes()]
×
673
        self.model.task_name = self.uiComboTask.currentText()
×
674
        self.model.subject = self.uiComboSubject.currentText()
×
675

676
    def controls_for_extra_parameters(self):
2✔
UNCOV
677
        self.controller2model()
×
UNCOV
678
        self.task_arguments = dict()
×
679

680
        # collect & filter list of parser arguments (general & task specific)
681
        args = sorted(_get_task_argument_parser()._actions, key=lambda x: x.dest)
×
UNCOV
682
        args = [
×
683
            x
684
            for x in args
685
            if not any(
686
                set(x.option_strings).intersection(
687
                    [
688
                        '--subject',
689
                        '--user',
690
                        '--projects',
691
                        '--log-level',
692
                        '--procedures',
693
                        '--weight',
694
                        '--help',
695
                        '--append',
696
                        '--no-interactive',
697
                        '--stub',
698
                        '--wizard',
699
                    ]
700
                )
701
            )
702
        ]
703
        args = sorted(self.model.get_task_extra_parser(self.model.task_name)._actions, key=lambda x: x.dest) + args
×
704

705
        group = self.uiGroupTaskParameters
×
706
        layout = group.layout()
×
707
        self.task_settings_widgets = [None] * len(args)
×
708

UNCOV
709
        while layout.rowCount():
×
UNCOV
710
            layout.removeRow(0)
×
711

UNCOV
712
        for arg in args:
×
UNCOV
713
            param = max(arg.option_strings, key=len)
×
UNCOV
714
            label = param.replace('_', ' ').replace('--', '').title()
×
715

716
            # create widget for bool arguments
UNCOV
717
            if isinstance(arg, argparse._StoreTrueAction | argparse._StoreFalseAction):
×
UNCOV
718
                widget = QtWidgets.QCheckBox()
×
UNCOV
719
                widget.setTristate(False)
×
UNCOV
720
                if arg.default:
×
UNCOV
721
                    widget.setCheckState(arg.default * 2)
×
UNCOV
722
                widget.toggled.connect(lambda val, p=param: self._set_task_arg(p, val > 0))
×
UNCOV
723
                widget.toggled.emit(widget.isChecked() > 0)
×
724

725
            # create widget for string arguments
UNCOV
726
            elif arg.type in (str, None):
×
727
                # string options (-> combo-box)
UNCOV
728
                if isinstance(arg.choices, list):
×
UNCOV
729
                    widget = QtWidgets.QComboBox()
×
UNCOV
730
                    widget.addItems(arg.choices)
×
UNCOV
731
                    if arg.default:
×
UNCOV
732
                        widget.setCurrentIndex([widget.itemText(x) for x in range(widget.count())].index(arg.default))
×
UNCOV
733
                    widget.currentTextChanged.connect(lambda val, p=param: self._set_task_arg(p, val))
×
UNCOV
734
                    widget.currentTextChanged.emit(widget.currentText())
×
735

736
                # list of strings (-> line-edit)
UNCOV
737
                elif arg.nargs == '+':
×
UNCOV
738
                    widget = QtWidgets.QLineEdit()
×
UNCOV
739
                    if arg.default:
×
UNCOV
740
                        widget.setText(', '.join(arg.default))
×
UNCOV
741
                    widget.editingFinished.connect(
×
742
                        lambda p=param, w=widget: self._set_task_arg(p, [x.strip() for x in w.text().split(',')])
743
                    )
UNCOV
744
                    widget.editingFinished.emit()
×
745

746
                # single string (-> line-edit)
747
                else:
UNCOV
748
                    widget = QtWidgets.QLineEdit()
×
UNCOV
749
                    if arg.default:
×
UNCOV
750
                        widget.setText(arg.default)
×
UNCOV
751
                    widget.editingFinished.connect(lambda p=param, w=widget: self._set_task_arg(p, w.text()))
×
UNCOV
752
                    widget.editingFinished.emit()
×
753

754
            # create widget for list of floats
UNCOV
755
            elif arg.type == float and arg.nargs == '+':
×
UNCOV
756
                widget = QtWidgets.QLineEdit()
×
UNCOV
757
                if arg.default:
×
UNCOV
758
                    widget.setText(str(arg.default)[1:-1])
×
UNCOV
759
                widget.editingFinished.connect(
×
760
                    lambda p=param, w=widget: self._set_task_arg(p, [x.strip() for x in w.text().split(',')])
761
                )
UNCOV
762
                widget.editingFinished.emit()
×
763

764
            # create widget for numerical arguments
UNCOV
765
            elif arg.type in [float, int]:
×
UNCOV
766
                if arg.type == float:
×
UNCOV
767
                    widget = QtWidgets.QDoubleSpinBox()
×
UNCOV
768
                    widget.setDecimals(1)
×
769
                else:
UNCOV
770
                    widget = QtWidgets.QSpinBox()
×
UNCOV
771
                if arg.default:
×
UNCOV
772
                    widget.setValue(arg.default)
×
UNCOV
773
                widget.valueChanged.connect(lambda val, p=param: self._set_task_arg(p, str(val)))
×
UNCOV
774
                widget.valueChanged.emit(widget.value())
×
775

776
            # no other argument types supported for now
777
            else:
UNCOV
778
                continue
×
779

780
            # add custom widget properties
UNCOV
781
            widget.setObjectName(param)
×
UNCOV
782
            widget.setProperty('parameter_name', param)
×
UNCOV
783
            widget.setProperty('parameter_dest', arg.dest)
×
784

785
            # display help strings as status tip
786
            if arg.help:
×
787
                widget.setStatusTip(arg.help)
×
788

789
            # some customizations
UNCOV
790
            match widget.property('parameter_dest'):
×
UNCOV
791
                case 'probability_left' | 'probability_opto_stim':
×
UNCOV
792
                    widget.setMinimum(0.0)
×
UNCOV
793
                    widget.setMaximum(1.0)
×
UNCOV
794
                    widget.setSingleStep(0.1)
×
UNCOV
795
                    widget.setDecimals(2)
×
796

UNCOV
797
                case 'contrast_set_probability_type':
×
UNCOV
798
                    label = 'Probability Type'
×
799

UNCOV
800
                case 'session_template_id':
×
UNCOV
801
                    label = 'Session Template ID'
×
802
                    widget.setMinimum(0)
×
803
                    widget.setMaximum(11)
×
804

UNCOV
805
                case 'delay_secs':
×
806
                    label = 'Initial Delay, s'
×
807
                    widget.setMaximum(86400)
×
808

UNCOV
809
                case 'training_phase':
×
UNCOV
810
                    widget.setSpecialValueText('automatic')
×
811
                    widget.setMaximum(5)
×
UNCOV
812
                    widget.setMinimum(-1)
×
UNCOV
813
                    widget.setValue(-1)
×
814

UNCOV
815
                case 'adaptive_reward':
×
UNCOV
816
                    label = 'Reward Amount, μl'
×
NEW
817
                    minimum = 1.4
×
UNCOV
818
                    widget.setSpecialValueText('automatic')
×
UNCOV
819
                    widget.setMaximum(3)
×
UNCOV
820
                    widget.setSingleStep(0.1)
×
NEW
821
                    widget.setMinimum(minimum)
×
UNCOV
822
                    widget.setValue(widget.minimum())
×
UNCOV
823
                    widget.valueChanged.connect(
×
824
                        lambda val, a=arg, m=minimum: self._set_task_arg(a.option_strings[0], str(val if val > m else -1))
825
                    )
UNCOV
826
                    widget.valueChanged.emit(widget.value())
×
827

NEW
828
                case 'reward_set_ul':
×
NEW
829
                    label = 'Reward Set, μl'
×
830

UNCOV
831
                case 'adaptive_gain':
×
UNCOV
832
                    label = 'Stimulus Gain'
×
NEW
833
                    minimum = 0
×
UNCOV
834
                    widget.setSpecialValueText('automatic')
×
UNCOV
835
                    widget.setSingleStep(0.1)
×
NEW
836
                    widget.setMinimum(minimum)
×
UNCOV
837
                    widget.setValue(widget.minimum())
×
UNCOV
838
                    widget.valueChanged.connect(
×
839
                        lambda val, a=arg, m=minimum: self._set_task_arg(a.option_strings[0], str(val if val > m else -1))
840
                    )
UNCOV
841
                    widget.valueChanged.emit(widget.value())
×
842

UNCOV
843
                case 'reward_amount_ul':
×
UNCOV
844
                    label = 'Reward Amount, μl'
×
UNCOV
845
                    widget.setSingleStep(0.1)
×
846
                    widget.setMinimum(0)
×
847

848
                case 'stim_gain':
×
849
                    label = 'Stimulus Gain'
×
850

851
            widget.wheelEvent = lambda event: None
×
852
            layout.addRow(self.tr(label), widget)
×
853

854
        # add label to indicate absence of task specific parameters
UNCOV
855
        if layout.rowCount() == 0:
×
UNCOV
856
            layout.addRow(self.tr('(none)'), None)
×
UNCOV
857
            layout.itemAt(0, 0).widget().setEnabled(False)
×
858

859
    def _set_task_arg(self, key, value):
2✔
UNCOV
860
        self.task_arguments[key] = value
×
861

862
    def alyx_connect(self):
2✔
863
        self.model.connect(gui=True)
×
UNCOV
864
        self.model2view()
×
865

866
    def _filter_subjects(self):
2✔
UNCOV
867
        filter_str = self.lineEditSubject.text().lower()
×
UNCOV
868
        result = [s for s in self.model.all_subjects if filter_str in s.lower()]
×
UNCOV
869
        if len(result) == 0:
×
870
            result = [self.model.test_subject_name]
×
871
        self.uiComboSubject.setModel(QtCore.QStringListModel(result))
×
872

873
    def pause(self):
2✔
874
        self.uiPushPause.setStyleSheet('QPushButton {background-color: yellow;}' if self.uiPushPause.isChecked() else '')
×
875
        match self.uiPushPause.isChecked():
×
876
            case True:
×
877
                print('Pausing after current trial ...')
×
878
                if self.model.session_folder.exists():
×
879
                    self.model.session_folder.joinpath('.pause').touch()
×
880
            case False:
×
881
                print('Resuming ...')
×
UNCOV
882
                if self.model.session_folder.joinpath('.pause').exists():
×
UNCOV
883
                    self.model.session_folder.joinpath('.pause').unlink()
×
884

885
    def start_stop(self):
2✔
UNCOV
886
        match self.uiPushStart.text():
×
UNCOV
887
            case 'Start':
×
UNCOV
888
                self.uiPushStart.setText('Stop')
×
UNCOV
889
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
890
                self._enable_ui_elements()
×
891

UNCOV
892
                dlg = QtWidgets.QInputDialog()
×
UNCOV
893
                weight, ok = dlg.getDouble(
×
894
                    self,
895
                    'Subject Weight',
896
                    'Subject Weight (g):',
897
                    value=0,
898
                    min=0,
899
                    decimals=2,
900
                    flags=dlg.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint,
901
                )
UNCOV
902
                if not ok or weight == 0:
×
UNCOV
903
                    self.uiPushStart.setText('Start')
×
UNCOV
904
                    self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
905
                    self._enable_ui_elements()
×
UNCOV
906
                    return
×
907

UNCOV
908
                self.controller2model()
×
UNCOV
909
                task = EmptySession(subject=self.model.subject, append=self.uiCheckAppend.isChecked(), interactive=False)
×
UNCOV
910
                self.model.session_folder = task.paths['SESSION_FOLDER']
×
UNCOV
911
                if self.model.session_folder.joinpath('.stop').exists():
×
UNCOV
912
                    self.model.session_folder.joinpath('.stop').unlink()
×
UNCOV
913
                self.model.raw_data_folder = task.paths['SESSION_RAW_DATA_FOLDER']
×
914

915
                # disable Bpod status LED
UNCOV
916
                bpod = Bpod(self.model.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
917
                bpod.set_status_led(False)
×
918

919
                # close Bpod singleton so subprocess can access use the port
UNCOV
920
                bpod.close()
×
921

922
                # runs the python command
923
                # cmd = [shutil.which('python')]
UNCOV
924
                cmd = []
×
UNCOV
925
                if self.model.task_name:
×
UNCOV
926
                    cmd.extend([str(self.model.all_tasks[self.model.task_name])])
×
UNCOV
927
                if self.model.user:
×
UNCOV
928
                    cmd.extend(['--user', self.model.user])
×
UNCOV
929
                if self.model.subject:
×
UNCOV
930
                    cmd.extend(['--subject', self.model.subject])
×
UNCOV
931
                if self.model.procedures:
×
UNCOV
932
                    cmd.extend(['--procedures', *self.model.procedures])
×
UNCOV
933
                if self.model.projects:
×
UNCOV
934
                    cmd.extend(['--projects', *self.model.projects])
×
UNCOV
935
                for key in self.task_arguments:
×
UNCOV
936
                    if isinstance(self.task_arguments[key], Iterable) and not isinstance(self.task_arguments[key], str):
×
UNCOV
937
                        cmd.extend([str(key)])
×
UNCOV
938
                        for value in self.task_arguments[key]:
×
UNCOV
939
                            cmd.extend([value])
×
940
                    else:
UNCOV
941
                        cmd.extend([key, self.task_arguments[key]])
×
UNCOV
942
                cmd.extend(['--weight', f'{weight}'])
×
UNCOV
943
                cmd.extend(['--log-level', 'DEBUG' if self.debug else 'INFO'])
×
UNCOV
944
                cmd.append('--wizard')
×
UNCOV
945
                if self.uiCheckAppend.isChecked():
×
UNCOV
946
                    cmd.append('--append')
×
UNCOV
947
                if self.running_task_process is None:
×
UNCOV
948
                    self.uiPlainTextEditLog.clear()
×
UNCOV
949
                    self._set_plaintext_char_color(self.uiPlainTextEditLog, 'White')
×
UNCOV
950
                    self.uiPlainTextEditLog.appendPlainText(f'Starting subprocess: {self.model.task_name} ...\n')
×
UNCOV
951
                    log.info('Starting subprocess')
×
UNCOV
952
                    log.info(subprocess.list2cmdline(cmd))
×
UNCOV
953
                    self.running_task_process = QtCore.QProcess()
×
UNCOV
954
                    self.running_task_process.setWorkingDirectory(BASE_DIR)
×
UNCOV
955
                    self.running_task_process.setProcessChannelMode(QtCore.QProcess.SeparateChannels)
×
UNCOV
956
                    self.running_task_process.finished.connect(self._on_task_finished)
×
UNCOV
957
                    self.running_task_process.readyReadStandardOutput.connect(self._on_read_standard_output)
×
UNCOV
958
                    self.running_task_process.readyReadStandardError.connect(self._on_read_standard_error)
×
UNCOV
959
                    self.running_task_process.start(shutil.which('python'), cmd)
×
UNCOV
960
                self.uiPushStart.setStatusTip('stop the session after the current trial')
×
UNCOV
961
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
962
                self.tabWidget.setCurrentIndex(self.tabWidget.indexOf(self.tabLog))
×
UNCOV
963
            case 'Stop':
×
UNCOV
964
                self.uiPushStart.setEnabled(False)
×
UNCOV
965
                if self.model.session_folder and self.model.session_folder.exists():
×
UNCOV
966
                    self.model.session_folder.joinpath('.stop').touch()
×
967

968
    @staticmethod
2✔
969
    def _set_plaintext_char_color(widget: QtWidgets.QPlainTextEdit, color: str = 'White') -> None:
2✔
970
        """
971
        Set the foreground color of characters in a QPlainTextEdit widget.
972

973
        Parameters
974
        ----------
975
        widget : QtWidgets.QPlainTextEdit
976
            The QPlainTextEdit widget whose character color is to be set.
977

978
        color : str, optional
979
            The name of the color to set. Default is 'White'. Should be a valid color name
980
            recognized by QtGui.QColorConstants. If the provided color name is not found,
981
            it defaults to QtGui.QColorConstants.White.
982
        """
UNCOV
983
        color = getattr(QtGui.QColorConstants, color, QtGui.QColorConstants.White)
×
UNCOV
984
        char_format = widget.currentCharFormat()
×
UNCOV
985
        char_format.setForeground(QtGui.QBrush(color))
×
UNCOV
986
        widget.setCurrentCharFormat(char_format)
×
987

988
    def _on_read_standard_output(self):
2✔
989
        """
990
        Read and process standard output entries.
991

992
        Reads standard output from a running task process, processes each entry,
993
        extracts color information, sets character color in the QPlainTextEdit widget,
994
        and appends time and message information to the widget.
995
        """
UNCOV
996
        data = self.running_task_process.readAllStandardOutput().data().decode('utf-8', 'ignore').strip()
×
UNCOV
997
        entries = re.finditer(REGEX_STDOUT, data)
×
UNCOV
998
        for entry in entries:
×
UNCOV
999
            color = ANSI_COLORS.get(entry.groupdict().get('color', '37'), 'White')
×
UNCOV
1000
            self._set_plaintext_char_color(self.uiPlainTextEditLog, color)
×
UNCOV
1001
            time = entry.groupdict().get('time', '')
×
UNCOV
1002
            msg = entry.groupdict().get('message', '')
×
UNCOV
1003
            self.uiPlainTextEditLog.appendPlainText(f'{time} {msg}')
×
UNCOV
1004
        if self.debug:
×
UNCOV
1005
            print(data)
×
1006

1007
    def _on_read_standard_error(self):
2✔
1008
        """
1009
        Read and process standard error entries.
1010

1011
        Reads standard error from a running task process, sets character color
1012
        in the QPlainTextEdit widget to indicate an error (Red), and appends
1013
        the error message to the widget.
1014
        """
UNCOV
1015
        data = self.running_task_process.readAllStandardError().data().decode('utf-8', 'ignore').strip()
×
UNCOV
1016
        self._set_plaintext_char_color(self.uiPlainTextEditLog, 'Red')
×
UNCOV
1017
        self.uiPlainTextEditLog.appendPlainText(data)
×
UNCOV
1018
        if self.debug:
×
UNCOV
1019
            print(data)
×
1020

1021
    def _on_task_finished(self, exit_code, exit_status):
2✔
UNCOV
1022
        self._set_plaintext_char_color(self.uiPlainTextEditLog, 'White')
×
UNCOV
1023
        self.uiPlainTextEditLog.appendPlainText('\nSubprocess finished.')
×
UNCOV
1024
        if exit_code:
×
UNCOV
1025
            msg_box = QtWidgets.QMessageBox(parent=self)
×
UNCOV
1026
            msg_box.setWindowTitle('Oh no!')
×
UNCOV
1027
            msg_box.setText('The task was terminated with an error.\nPlease check the log for details.')
×
UNCOV
1028
            msg_box.setIcon(QtWidgets.QMessageBox().Critical)
×
UNCOV
1029
            msg_box.exec_()
×
1030

UNCOV
1031
        self.running_task_process = None
×
1032

1033
        # re-enable UI elements
UNCOV
1034
        self.uiPushStart.setText('Start')
×
UNCOV
1035
        self.uiPushStart.setStatusTip('start the session')
×
UNCOV
1036
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
1037
        self._enable_ui_elements()
×
1038

1039
        # recall state of Bpod status LED
UNCOV
1040
        bpod = Bpod(self.model.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1041
        bpod.set_status_led(self.uiPushStatusLED.isChecked())
×
1042

UNCOV
1043
        if (task_settings_file := Path(self.model.raw_data_folder).joinpath('_iblrig_taskSettings.raw.json')).exists():
×
UNCOV
1044
            with open(task_settings_file) as fid:
×
UNCOV
1045
                session_data = json.load(fid)
×
1046

1047
            # check if session was a dud
UNCOV
1048
            if (
×
1049
                (ntrials := session_data['NTRIALS']) < 42
1050
                and not any([x in self.model.task_name for x in ('spontaneous', 'passive')])
1051
                and not self.uiCheckAppend.isChecked()
1052
            ):
UNCOV
1053
                answer = QtWidgets.QMessageBox.question(
×
1054
                    self,
1055
                    'Is this a dud?',
1056
                    f"The session consisted of only {ntrials:d} trial"
1057
                    f"{'s' if ntrials > 1 else ''} and appears to be a dud.\n\n"
1058
                    f"Should it be deleted?",
1059
                )
UNCOV
1060
                if answer == QtWidgets.QMessageBox.Yes:
×
UNCOV
1061
                    shutil.rmtree(self.model.session_folder)
×
UNCOV
1062
                    return
×
1063

1064
            # manage poop count
UNCOV
1065
            dlg = QtWidgets.QInputDialog()
×
UNCOV
1066
            droppings, ok = dlg.getInt(
×
1067
                self,
1068
                'Droppings',
1069
                'Number of droppings:',
1070
                value=0,
1071
                min=0,
1072
                flags=dlg.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint,
1073
            )
UNCOV
1074
            session_data['POOP_COUNT'] = droppings
×
UNCOV
1075
            with open(task_settings_file, 'w') as fid:
×
UNCOV
1076
                json.dump(session_data, fid, indent=4, sort_keys=True, default=str)
×
1077

1078
    def flush(self):
2✔
1079
        # paint button blue when in toggled state
UNCOV
1080
        self.uiPushFlush.setStyleSheet(
×
1081
            'QPushButton {background-color: rgb(128, 128, 255);}' if self.uiPushFlush.isChecked() else ''
1082
        )
UNCOV
1083
        self._enable_ui_elements()
×
1084

UNCOV
1085
        try:
×
UNCOV
1086
            bpod = Bpod(
×
1087
                self.model.hardware_settings['device_bpod']['COM_BPOD'],
1088
                skip_initialization=True,
1089
                disable_behavior_ports=[1, 2, 3],
1090
            )
UNCOV
1091
            bpod.open_valve(self.uiPushFlush.isChecked())
×
UNCOV
1092
        except (OSError, BpodErrorException):
×
UNCOV
1093
            print(traceback.format_exc())
×
UNCOV
1094
            print('Cannot find bpod - is it connected?')
×
UNCOV
1095
            self.uiPushFlush.setChecked(False)
×
UNCOV
1096
            self.uiPushFlush.setStyleSheet('')
×
UNCOV
1097
            return
×
1098

1099
    def toggle_status_led(self, is_toggled: bool):
2✔
1100
        # paint button green when in toggled state
UNCOV
1101
        self.uiPushStatusLED.setStyleSheet('QPushButton {background-color: rgb(128, 255, 128);}' if is_toggled else '')
×
UNCOV
1102
        self._enable_ui_elements()
×
1103

UNCOV
1104
        try:
×
UNCOV
1105
            bpod = Bpod(self.model.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
UNCOV
1106
            bpod.set_status_led(is_toggled)
×
UNCOV
1107
        except (OSError, BpodErrorException, AttributeError):
×
UNCOV
1108
            self.uiPushStatusLED.setChecked(False)
×
UNCOV
1109
            self.uiPushStatusLED.setStyleSheet('')
×
1110

1111
    def _enable_ui_elements(self):
2✔
UNCOV
1112
        is_running = self.uiPushStart.text() == 'Stop'
×
1113

UNCOV
1114
        self.uiPushStart.setEnabled(
×
1115
            not self.uiPushFlush.isChecked()
1116
            and len(self.uiListProjects.selectedIndexes()) > 0
1117
            and len(self.uiListProcedures.selectedIndexes()) > 0
1118
        )
UNCOV
1119
        self.uiPushPause.setEnabled(is_running)
×
UNCOV
1120
        self.uiPushFlush.setEnabled(not is_running)
×
UNCOV
1121
        self.uiPushReward.setEnabled(not is_running)
×
UNCOV
1122
        self.uiPushStatusLED.setEnabled(not is_running)
×
UNCOV
1123
        self.uiCheckAppend.setEnabled(not is_running)
×
UNCOV
1124
        self.uiGroupParameters.setEnabled(not is_running)
×
UNCOV
1125
        self.uiGroupTaskParameters.setEnabled(not is_running)
×
UNCOV
1126
        self.uiGroupTools.setEnabled(not is_running)
×
UNCOV
1127
        self.repaint()
×
1128

1129

1130
class SubjectDetailsWorker(QThread):
2✔
1131
    subject_name: str | None = None
2✔
1132
    result: tuple[dict, dict] | None = None
2✔
1133

1134
    def __init__(self, subject_name):
2✔
UNCOV
1135
        super().__init__()
×
UNCOV
1136
        self.subject_name = subject_name
×
1137

1138
    def run(self):
2✔
UNCOV
1139
        self.result = get_subject_training_info(self.subject_name)
×
1140

1141

1142
class LoginWindow(QtWidgets.QDialog, Ui_login):
2✔
1143
    def __init__(self, parent: RigWizard, username: str = '', password: str = '', remember: bool = False):
2✔
UNCOV
1144
        super().__init__(parent)
×
UNCOV
1145
        self.setupUi(self)
×
UNCOV
1146
        self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
×
UNCOV
1147
        self.labelServer.setText(str(parent.model.iblrig_settings['ALYX_URL']))
×
UNCOV
1148
        self.lineEditUsername.setText(username)
×
UNCOV
1149
        self.lineEditPassword.setText(password)
×
UNCOV
1150
        self.checkBoxRememberMe.setChecked(remember)
×
NEW
1151
        self.lineEditUsername.textChanged.connect(self._on_text_changed)
×
NEW
1152
        self.lineEditPassword.textChanged.connect(self._on_text_changed)
×
UNCOV
1153
        self.toggle_password = self.lineEditPassword.addAction(
×
1154
            QtGui.QIcon(':/images/hide'), QtWidgets.QLineEdit.ActionPosition.TrailingPosition
1155
        )
UNCOV
1156
        self.toggle_password.triggered.connect(self._toggle_password_visibility)
×
UNCOV
1157
        self.toggle_password.setCheckable(True)
×
UNCOV
1158
        if len(username) > 0:
×
UNCOV
1159
            self.lineEditPassword.setFocus()
×
NEW
1160
        self._on_text_changed()
×
UNCOV
1161
        self.exec()
×
1162

1163
    def _on_text_changed(self):
2✔
UNCOV
1164
        enable_ok = len(self.lineEditUsername.text()) > 0 and len(self.lineEditPassword.text()) > 0
×
UNCOV
1165
        self.buttonBox.button(self.buttonBox.Ok).setEnabled(enable_ok)
×
1166

1167
    def _toggle_password_visibility(self):
2✔
UNCOV
1168
        if self.toggle_password.isChecked():
×
UNCOV
1169
            self.toggle_password.setIcon(QtGui.QIcon(':/images/show'))
×
UNCOV
1170
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
×
1171
        else:
UNCOV
1172
            self.toggle_password.setIcon(QtGui.QIcon(':/images/hide'))
×
UNCOV
1173
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
×
1174

1175

1176
class UpdateNotice(QtWidgets.QDialog, Ui_update):
2✔
1177
    """
1178
    A dialog for displaying update notices.
1179

1180
    This class is used to create a dialog for displaying update notices.
1181
    It shows information about the available update and provides a changelog.
1182

1183
    Parameters
1184
    ----------
1185
    parent : QtWidgets.QWidget
1186
        The parent widget associated with this dialog.
1187

1188
    update_available : bool
1189
        Indicates if an update is available.
1190

1191
    version : str
1192
        The version of the available update.
1193

1194
    Attributes
1195
    ----------
1196
    None
1197

1198
    Methods
1199
    -------
1200
    None
1201
    """
1202

1203
    def __init__(self, parent: QtWidgets.QWidget, version: str) -> None:
2✔
UNCOV
1204
        super().__init__(parent)
×
UNCOV
1205
        self.setupUi(self)
×
UNCOV
1206
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
×
UNCOV
1207
        self.uiLabelHeader.setText(f'Update to iblrig {version} is available.')
×
UNCOV
1208
        self.uiTextBrowserChanges.setMarkdown(get_changelog())
×
UNCOV
1209
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
×
UNCOV
1210
        self.exec()
×
1211

1212

1213
class CustomWebEnginePage(QWebEnginePage):
2✔
1214
    """
1215
    Custom implementation of QWebEnginePage to handle navigation requests.
1216

1217
    This class overrides the acceptNavigationRequest method to handle link clicks.
1218
    If the navigation type is a link click and the clicked URL does not start with
1219
    a specific prefix (URL_DOC), it opens the URL in the default web browser.
1220
    Otherwise, it delegates the handling to the base class.
1221

1222
    Adapted from: https://www.pythonguis.com/faq/qwebengineview-open-links-new-window/
1223
    """
1224

1225
    @override
2✔
1226
    def acceptNavigationRequest(self, url: QtCore.QUrl, navigation_type: QWebEnginePage.NavigationType, is_main_frame: bool):
2✔
1227
        """
1228
        Decide whether to allow or block a navigation request.
1229

1230
        Parameters
1231
        ----------
1232
        url : QUrl
1233
            The URL being navigated to.
1234

1235
        navigation_type : QWebEnginePage.NavigationType
1236
            The type of navigation request.
1237

1238
        is_main_frame : bool
1239
            Indicates whether the request is for the main frame.
1240

1241
        Returns
1242
        -------
1243
        bool
1244
            True if the navigation request is accepted, False otherwise.
1245
        """
UNCOV
1246
        if navigation_type == QWebEnginePage.NavigationTypeLinkClicked and not url.url().startswith(URL_DOC):
×
UNCOV
1247
            webbrowser.open(url.url())
×
UNCOV
1248
            return False
×
UNCOV
1249
        return super().acceptNavigationRequest(url, navigation_type, is_main_frame)
×
1250

1251

1252
def main():
2✔
UNCOV
1253
    parser = argparse.ArgumentParser()
×
UNCOV
1254
    parser.add_argument('--debug', action='store_true', dest='debug', help='increase logging verbosity')
×
UNCOV
1255
    args = parser.parse_args()
×
1256

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

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

1274

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