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

int-brain-lab / iblrig / 10524660746

23 Aug 2024 11:07AM UTC coverage: 47.177% (+0.4%) from 46.79%
10524660746

Pull #710

github

74f4e8
web-flow
Merge 222cebb88 into db04546ad
Pull Request #710: 8.23.1

40 of 86 new or added lines in 4 files covered. (46.51%)

989 existing lines in 22 files now uncovered.

4052 of 8589 relevant lines covered (47.18%)

0.94 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, 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('locations', 'read', id=self.hardware_settings.RIG_NAME)
2✔
UNCOV
233
        except HTTPError as ex:
×
UNCOV
234
            if ex.response.status_code not in (404, 400):  # file not found; auth error
×
235
                # Likely Alyx is down or server-side issue
236
                message = 'Failed to determine lab location on Alyx'
×
237
                solution = 'Check if Alyx is reachable'
×
238
            else:
UNCOV
239
                message = f'Could not find rig name {self.hardware_settings.RIG_NAME} in Alyx'
×
UNCOV
240
                solution = (
×
241
                    f'Please check the RIG_NAME key in hardware_settings.yaml and make sure it is created in Alyx here: '
242
                    f'{self.iblrig_settings.ALYX_URL}/admin/misc/lablocation/'
243
                )
UNCOV
244
            QtWidgets.QMessageBox().critical(None, 'Error', f'{message}\n\n{solution}')
×
245

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

251
        # then get the projects that map to the current user
252
        rest_projects = self.alyx.rest('projects', 'list')
2✔
253
        projects = [p['name'] for p in rest_projects if (username in p['users'] or len(p['users']) == 0)]
2✔
254
        self.all_projects = sorted(set(projects + self.all_projects))
2✔
255

256
        return True
2✔
257

258
    def logout(self):
2✔
259
        if not self.alyx.is_logged_in or self.alyx.user is not self.user:
×
260
            return
×
UNCOV
261
        log.info(f'User {self.user} logged out')
×
UNCOV
262
        self.alyx.logout()
×
UNCOV
263
        self.user = None
×
UNCOV
264
        self.__post_init__()
×
265

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

276

277
class RigWizard(QtWidgets.QMainWindow, Ui_wizard):
2✔
278
    training_info: dict = {}
2✔
279
    session_info: dict = {}
2✔
280
    task_parameters: dict | None = None
2✔
281
    new_subject_details = QtCore.pyqtSignal()
2✔
282

283
    def __init__(self, debug: bool = False, remote_devices: bool = False):
2✔
284
        super().__init__()
×
UNCOV
285
        self.setupUi(self)
×
286

287
        # load tabs
288
        self.tabLog = TabLog(parent=self.tabWidget)
×
289
        self.tabData = TabData(parent=self.tabWidget)
×
290
        self.tabDocs = TabDocs(parent=self.tabWidget)
×
291
        self.tabAbout = TabAbout(parent=self.tabWidget)
×
292
        self.tabWidget.addTab(self.tabLog, QtGui.QIcon(':/images/log'), 'Log')
×
293
        self.tabWidget.addTab(self.tabData, QtGui.QIcon(':/images/sessions'), 'Data')
×
294
        self.tabWidget.addTab(self.tabDocs, QtGui.QIcon(':/images/help'), 'Docs')
×
295
        self.tabWidget.addTab(self.tabAbout, QtGui.QIcon(':/images/about'), 'About')
×
296
        self.tabWidget.setCurrentIndex(0)
×
297

UNCOV
298
        self.debug = debug
×
UNCOV
299
        self.settings = QtCore.QSettings()
×
300

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

325
        # remote devices (only show if at least one device was found)
UNCOV
326
        if remote_devices:
×
327
            self.remoteDevicesModel = RemoteDevicesItemModel(iblrig_settings=self.model.iblrig_settings)
×
UNCOV
328
            self.listViewRemoteDevices.setModel(self.remoteDevicesModel)
×
329
        else:
330
            self.listViewRemoteDevices.setVisible(False)
×
331
            self.labelRemoteDevices.setVisible(False)
×
332

333
        # task parameters and subject details
UNCOV
334
        self.uiComboTask.currentTextChanged.connect(self._controls_for_task_arguments)
×
335
        self.uiComboTask.currentTextChanged.connect(self._get_task_parameters)
×
336
        self.uiComboSubject.currentTextChanged.connect(self._get_subject_details)
×
337
        self.new_subject_details.connect(self._set_automatic_values)
×
338
        self.model2view()
×
339

340
        # connect widgets signals to slots
UNCOV
341
        self.uiActionValidateHardware.triggered.connect(self._on_validate_hardware)
×
UNCOV
342
        self.uiActionCalibrateFrame2ttl.triggered.connect(self._on_calibrate_frame2ttl)
×
343
        self.uiActionCalibrateValve.triggered.connect(self._on_calibrate_valve)
×
344
        self.uiActionTrainingLevelV7.triggered.connect(self._on_menu_training_level_v7)
×
345

346
        self.uiPushStart.clicked.connect(self.start_stop)
×
347
        self.uiPushPause.clicked.connect(self.pause)
×
UNCOV
348
        self.uiListProjects.clicked.connect(self._enable_ui_elements)
×
UNCOV
349
        self.uiListProcedures.clicked.connect(self._enable_ui_elements)
×
350
        self.lineEditSubject.textChanged.connect(self._filter_subjects)
×
351

UNCOV
352
        self.running_task_process = None
×
UNCOV
353
        self.task_arguments = dict()
×
354
        self.task_settings_widgets = None
×
355

UNCOV
356
        self.uiPushStart.installEventFilter(self)
×
UNCOV
357
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
358
        self.uiPushPause.setIcon(self.style().standardIcon(QStyle.SP_MediaPause))
×
359

UNCOV
360
        self.controller2model()
×
361

362
        self.tabWidget.currentChanged.connect(self._on_switch_tab)
×
363

364
        # username
365
        if self.iblrig_settings.ALYX_URL is not None:
×
366
            self.uiLineEditUser.returnPressed.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
UNCOV
367
            self.uiPushButtonLogIn.released.connect(lambda w=self.uiLineEditUser: self._log_in_or_out(username=w.text()))
×
368
        else:
369
            self.uiLineEditUser.setPlaceholderText('')
×
370
            self.uiPushButtonLogIn.setEnabled(False)
×
371

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

382
        # statusbar / disk stats
383
        local_data = self.iblrig_settings['iblrig_local_data_path']
×
384
        local_data = Path(local_data) if local_data else Path.home().joinpath('iblrig_data')
×
385
        self.uiDiskSpaceIndicator = DiskSpaceIndicator(parent=self.statusbar, directory=local_data)
×
386
        self.uiDiskSpaceIndicator.setMaximumWidth(70)
×
387
        self.statusbar.addPermanentWidget(self.uiDiskSpaceIndicator)
×
388
        self.statusbar.setContentsMargins(0, 0, 6, 0)
×
389

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

397
        # show splash-screen / store validation results
398
        splash_screen = Splash(parent=self)
×
UNCOV
399
        splash_screen.exec()
×
UNCOV
400
        self.validation_results = splash_screen.validation_results
×
401

402
        # check for update
403
        update_worker = Worker(check_for_updates)
×
404
        update_worker.signals.result.connect(self._on_check_update_result)
×
405
        QThreadPool.globalInstance().start(update_worker)
×
406

407
        # show GUI
408
        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowFullscreenButtonHint)
×
UNCOV
409
        self.move(self.settings.value('pos', self.pos(), QtCore.QPoint))
×
UNCOV
410
        self.resize(self.settings.value('size', self.size(), QtCore.QSize))
×
411
        self.show()
×
412

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

430
    @property
2✔
431
    def iblrig_settings(self) -> RigSettings:
2✔
UNCOV
432
        return self.model.iblrig_settings
×
433

434
    @property
2✔
435
    def hardware_settings(self) -> HardwareSettings:
2✔
UNCOV
436
        return self.model.hardware_settings
×
437

438
    def _get_task_parameters(self, task_name):
2✔
439
        worker = Worker(self.model.get_task_parameters, task_name)
×
440
        worker.signals.result.connect(self._on_task_parameters_result)
×
441
        QThreadPool.globalInstance().start(worker)
×
442

443
    def _on_task_parameters_result(self, result):
2✔
444
        self.task_parameters = result
×
445
        self._get_subject_details(self.uiComboSubject.currentText())
×
446

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

461
    def _on_subject_details_result(self, result):
2✔
462
        self.training_info, self.session_info = result
×
463
        self.new_subject_details.emit()
×
464

465
    def _show_error_dialog(
2✔
466
        self,
467
        title: str,
468
        description: str,
469
        issues: list[str] | None = None,
470
        suggestions: list[str] | None = None,
471
        leads: list[str] | None = None,
472
    ):
473
        text = description.strip()
×
474

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

489
        build_list(issues, 'Possible issue')
×
UNCOV
490
        build_list(suggestions, 'Suggested action')
×
491
        build_list(leads, 'Possible lead')
×
UNCOV
492
        QtWidgets.QMessageBox.critical(self, title, text)
×
493

494
    def _on_switch_tab(self, index):
2✔
495
        # if self.tabWidget.tabText(index) == 'Session':
496
        # QtCore.QTimer.singleShot(1, lambda: self.resize(self.minimumSizeHint()))
497
        # self.adjustSize()
UNCOV
498
        pass
×
499

500
    def _on_validate_hardware(self) -> None:
2✔
UNCOV
501
        SystemValidationDialog(self, hardware_settings=self.hardware_settings, rig_settings=self.iblrig_settings)
×
502

503
    def _on_calibrate_frame2ttl(self) -> None:
2✔
UNCOV
504
        Frame2TTLCalibrationDialog(self, hardware_settings=self.hardware_settings)
×
505

506
    def _on_calibrate_valve(self) -> None:
2✔
UNCOV
507
        ValveCalibrationDialog(self)
×
508

509
    def _on_menu_training_level_v7(self) -> None:
2✔
510
        """
511
        Prompt user for a session path to get v7 training level.
512

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

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

534
        # get task settings
535
        task_settings = load_settings(session_path, task_collection='raw_behavior_data')
×
UNCOV
536
        if task_settings is None:
×
537
            QtWidgets.QMessageBox().critical(self, 'Error', f'No task settings found in {session_path}')
×
538
            return
×
539

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

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

577
    def _on_check_update_result(self, result: tuple[bool, str]) -> None:
2✔
578
        """
579
        Handle the result of checking for updates.
580

581
        Parameters
582
        ----------
583
        result : tuple[bool, str | None]
584
            A tuple containing a boolean flag indicating update availability (result[0])
585
            and the remote version string (result[1]).
586

587
        Returns
588
        -------
589
        None
590
        """
591
        if result[0]:
×
592
            UpdateNotice(parent=self, version=result[1])
×
593

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

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

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

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

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

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

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

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

715
    def controller2model(self):
2✔
UNCOV
716
        self.model.procedures = [i.data() for i in self.uiListProcedures.selectedIndexes()]
×
UNCOV
717
        self.model.projects = [i.data() for i in self.uiListProjects.selectedIndexes()]
×
UNCOV
718
        self.model.task_name = self.uiComboTask.currentText()
×
UNCOV
719
        self.model.subject = self.uiComboSubject.currentText()
×
720

721
    def _controls_for_task_arguments(self, task_name: str):
2✔
UNCOV
722
        self.controller2model()
×
UNCOV
723
        self.task_arguments = dict()
×
724

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

UNCOV
751
        group = self.uiGroupTaskParameters
×
UNCOV
752
        layout = group.layout()
×
UNCOV
753
        self.task_settings_widgets = [None] * len(args)
×
754

UNCOV
755
        while layout.rowCount():
×
UNCOV
756
            layout.removeRow(0)
×
757

UNCOV
758
        for arg in args:
×
UNCOV
759
            param = str(max(arg.option_strings, key=len))
×
UNCOV
760
            label = param.replace('_', ' ').replace('--', '').title()
×
761

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

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

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

792
                # single string (-> line-edit)
793
                else:
794
                    widget = QtWidgets.QLineEdit()
×
795
                    if arg.default:
×
UNCOV
796
                        widget.setText(arg.default)
×
UNCOV
797
                    widget.editingFinished.connect(lambda p=param, w=widget: self._set_task_arg(p, w.text()))
×
798
                    widget.editingFinished.emit()
×
799

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

810
            # create widget for adaptive gain
UNCOV
811
            elif arg.dest == 'adaptive_gain':
×
UNCOV
812
                widget = QtWidgets.QDoubleSpinBox()
×
UNCOV
813
                widget.setDecimals(1)
×
814

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

827
            # no other argument types supported for now
828
            else:
UNCOV
829
                continue
×
830

831
            # add custom widget properties
832
            widget.setObjectName(param)
×
833
            widget.setProperty('parameter_name', param)
×
834
            widget.setProperty('parameter_dest', arg.dest)
×
835

836
            # display help strings as status tip
837
            if arg.help:
×
838
                widget.setStatusTip(arg.help)
×
839

840
            # some customizations
UNCOV
841
            match widget.property('parameter_dest'):
×
UNCOV
842
                case 'probability_left' | 'probability_opto_stim':
×
UNCOV
843
                    widget.setMinimum(0.0)
×
UNCOV
844
                    widget.setMaximum(1.0)
×
UNCOV
845
                    widget.setSingleStep(0.1)
×
UNCOV
846
                    widget.setDecimals(2)
×
847

848
                case 'contrast_set_probability_type':
×
849
                    label = 'Probability Type'
×
850

UNCOV
851
                case 'session_template_id':
×
852
                    label = 'Session Template ID'
×
UNCOV
853
                    widget.setMinimum(0)
×
UNCOV
854
                    widget.setMaximum(11)
×
855

856
                case 'delay_secs':
×
857
                    label = 'Initial Delay, s'
×
858
                    widget.setMaximum(86400)
×
859

860
                case 'training_phase':
×
861
                    widget.setSpecialValueText('automatic')
×
862
                    widget.setMaximum(5)
×
863
                    widget.setMinimum(-1)
×
864
                    widget.setValue(-1)
×
865

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

UNCOV
879
                case 'reward_set_ul':
×
UNCOV
880
                    label = 'Reward Set, μl'
×
881

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

UNCOV
894
                case 'reward_amount_ul':
×
UNCOV
895
                    label = 'Reward Amount, μl'
×
UNCOV
896
                    widget.setSingleStep(0.1)
×
UNCOV
897
                    widget.setMinimum(0)
×
898

UNCOV
899
                case 'stim_gain':
×
UNCOV
900
                    label = 'Stimulus Gain'
×
901

UNCOV
902
                case 'stim_reverse':
×
UNCOV
903
                    label = 'Reverse Stimulus'
×
904

UNCOV
905
                case 'duration_spontaneous':
×
UNCOV
906
                    label = 'Spontaneous Activity, s'
×
UNCOV
907
                    widget.setMinimum(0)
×
UNCOV
908
                    widget.setMaximum(60 * 60 * 24 - 1)
×
UNCOV
909
                    widget.setValue(arg.default)
×
910

UNCOV
911
            widget.wheelEvent = lambda event: None
×
UNCOV
912
            layout.addRow(self.tr(label), widget)
×
913

914
        # add label to indicate absence of task specific parameters
UNCOV
915
        if layout.rowCount() == 0:
×
UNCOV
916
            layout.addRow(self.tr('(none)'), None)
×
UNCOV
917
            layout.itemAt(0, 0).widget().setEnabled(False)
×
918

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

UNCOV
930
        _helper('training_phase', '--training_phase', 'd')
×
UNCOV
931
        _helper('adaptive_reward', '--adaptive_reward', '0.1f')
×
UNCOV
932
        _helper('adaptive_gain', '--adaptive_gain', '0.1f')
×
933

934
    def _set_task_arg(self, key, value):
2✔
UNCOV
935
        self.task_arguments[key] = value
×
936

937
    def _filter_subjects(self):
2✔
UNCOV
938
        filter_str = self.lineEditSubject.text().lower()
×
UNCOV
939
        result = [s for s in self.model.all_subjects if filter_str in s.lower()]
×
UNCOV
940
        if len(result) == 0:
×
UNCOV
941
            result = [self.model.test_subject_name]
×
UNCOV
942
        self.uiComboSubject.setModel(QtCore.QStringListModel(result))
×
943

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

956
    def start_stop(self):
2✔
UNCOV
957
        match self.uiPushStart.text():
×
UNCOV
958
            case 'Start':
×
UNCOV
959
                self.uiPushStart.setText('Stop')
×
UNCOV
960
                self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
×
UNCOV
961
                self._enable_ui_elements()
×
962

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

UNCOV
979
                self.controller2model()
×
980

UNCOV
981
                logging.disable(logging.INFO)
×
UNCOV
982
                task = EmptySession(subject=self.model.subject, append=self.uiCheckAppend.isChecked(), interactive=False)
×
UNCOV
983
                logging.disable(logging.NOTSET)
×
UNCOV
984
                self.model.session_folder = task.paths['SESSION_FOLDER']
×
UNCOV
985
                if self.model.session_folder.joinpath('.stop').exists():
×
UNCOV
986
                    self.model.session_folder.joinpath('.stop').unlink()
×
UNCOV
987
                self.model.raw_data_folder = task.paths['SESSION_RAW_DATA_FOLDER']
×
988

989
                # disable Bpod status LED
UNCOV
990
                bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
991
                bpod.set_status_led(False)
×
992

993
                # close Bpod singleton so subprocess can access use the port
UNCOV
994
                bpod.close()
×
995

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

1045
    def _on_read_standard_output(self):
2✔
1046
        """
1047
        Read and process standard output entries.
1048

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

1063
    def _on_read_standard_error(self):
2✔
1064
        """
1065
        Read and process standard error entries.
1066

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

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

UNCOV
1085
        self.running_task_process = None
×
1086

1087
        # re-enable UI elements
UNCOV
1088
        self.uiPushStart.setText('Start')
×
UNCOV
1089
        self.uiPushStart.setStatusTip('start the session')
×
UNCOV
1090
        self.uiPushStart.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
×
UNCOV
1091
        self._enable_ui_elements()
×
1092

1093
        # recall state of Bpod status LED
UNCOV
1094
        bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'])
×
UNCOV
1095
        bpod.set_status_led(self.uiPushStatusLED.isChecked())
×
1096

UNCOV
1097
        if (task_settings_file := Path(self.model.raw_data_folder).joinpath('_iblrig_taskSettings.raw.json')).exists():
×
UNCOV
1098
            with open(task_settings_file) as fid:
×
UNCOV
1099
                session_data = json.load(fid)
×
1100

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

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

1132
    def flush(self):
2✔
1133
        # paint button blue when in toggled state
UNCOV
1134
        self.uiPushFlush.setStyleSheet(
×
1135
            'QPushButton {background-color: rgb(128, 128, 255);}' if self.uiPushFlush.isChecked() else ''
1136
        )
UNCOV
1137
        self._enable_ui_elements()
×
1138

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

1153
    def toggle_status_led(self, is_toggled: bool):
2✔
1154
        # paint button green when in toggled state
UNCOV
1155
        self.uiPushStatusLED.setStyleSheet('QPushButton {background-color: rgb(128, 255, 128);}' if is_toggled else '')
×
UNCOV
1156
        self._enable_ui_elements()
×
1157

UNCOV
1158
        try:
×
UNCOV
1159
            bpod = Bpod(self.hardware_settings['device_bpod']['COM_BPOD'], skip_initialization=True)
×
UNCOV
1160
            bpod.set_status_led(is_toggled)
×
UNCOV
1161
        except (OSError, BpodErrorException, AttributeError):
×
UNCOV
1162
            self.uiPushStatusLED.setChecked(False)
×
UNCOV
1163
            self.uiPushStatusLED.setStyleSheet('')
×
1164

1165
    def _enable_ui_elements(self):
2✔
UNCOV
1166
        is_running = self.uiPushStart.text() == 'Stop'
×
1167

UNCOV
1168
        self.uiPushStart.setEnabled(
×
1169
            not self.uiPushFlush.isChecked()
1170
            and len(self.uiListProjects.selectedIndexes()) > 0
1171
            and len(self.uiListProcedures.selectedIndexes()) > 0
1172
        )
UNCOV
1173
        self.uiPushPause.setEnabled(is_running)
×
UNCOV
1174
        self.uiPushFlush.setEnabled(not is_running)
×
UNCOV
1175
        self.uiPushReward.setEnabled(not is_running)
×
UNCOV
1176
        self.uiPushStatusLED.setEnabled(not is_running)
×
UNCOV
1177
        self.uiCheckAppend.setEnabled(not is_running)
×
UNCOV
1178
        self.uiGroupParameters.setEnabled(not is_running)
×
UNCOV
1179
        self.uiGroupTaskParameters.setEnabled(not is_running)
×
UNCOV
1180
        self.uiGroupTools.setEnabled(not is_running)
×
UNCOV
1181
        self.repaint()
×
1182

1183

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

1205
    def _on_text_changed(self):
2✔
UNCOV
1206
        enable_ok = len(self.lineEditUsername.text()) > 0 and len(self.lineEditPassword.text()) > 0
×
UNCOV
1207
        self.buttonBox.button(self.buttonBox.Ok).setEnabled(enable_ok)
×
1208

1209
    def _toggle_password_visibility(self):
2✔
UNCOV
1210
        if self.toggle_password.isChecked():
×
UNCOV
1211
            self.toggle_password.setIcon(QtGui.QIcon(':/images/show'))
×
UNCOV
1212
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Normal)
×
1213
        else:
UNCOV
1214
            self.toggle_password.setIcon(QtGui.QIcon(':/images/hide'))
×
UNCOV
1215
            self.lineEditPassword.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
×
1216

1217

1218
class UpdateNotice(QtWidgets.QDialog, Ui_update):
2✔
1219
    """
1220
    A dialog for displaying update notices.
1221

1222
    This class is used to create a dialog for displaying update notices.
1223
    It shows information about the available update and provides a changelog.
1224

1225
    Parameters
1226
    ----------
1227
    parent : QtWidgets.QWidget
1228
        The parent widget associated with this dialog.
1229

1230
    update_available : bool
1231
        Indicates if an update is available.
1232

1233
    version : str
1234
        The version of the available update.
1235
    """
1236

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

1246

1247
def main():
2✔
UNCOV
1248
    parser = argparse.ArgumentParser()
×
UNCOV
1249
    parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='increase logging verbosity')
×
UNCOV
1250
    parser.add_argument(
×
1251
        '-r', '--remote_devices', action='store_true', dest='remote_devices', help='show controls for remote devices'
1252
    )
UNCOV
1253
    args = parser.parse_args()
×
1254

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

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

1272

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