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

int-brain-lab / iblrig / 14196118657

01 Apr 2025 12:52PM UTC coverage: 47.634% (+0.8%) from 46.79%
14196118657

Pull #795

github

cfb5bd
web-flow
Merge 5ba5d5f25 into 58cf64236
Pull Request #795: fixes for habituation CW

11 of 12 new or added lines in 1 file covered. (91.67%)

1083 existing lines in 22 files now uncovered.

4288 of 9002 relevant lines covered (47.63%)

0.95 hits per line

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

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

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

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

58
try:
2✔
59
    import iblrig_custom_tasks
2✔
60

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

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

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

80
ANSI_COLORS: dict[str, 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
    free_reward_time: float | None = None
2✔
112
    file_iblrig_settings: Path | str | None = None
2✔
113
    file_hardware_settings: Path | str | None = None
2✔
114

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

272
        return True
2✔
273

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

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

292

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

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

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

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

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

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

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

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

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

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

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

378
        self.controller2model()
×
379

UNCOV
380
        self.tabWidget.currentChanged.connect(self._on_switch_tab)
×
381

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
881
                case 'adaptive_reward':
×
UNCOV
882
                    label = 'Reward Amount, μl'
×
UNCOV
883
                    minimum = 1.4
×
UNCOV
884
                    widget.setSpecialValueText('automatic')
×
UNCOV
885
                    widget.setMaximum(3)
×
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 -1))
891
                    )
UNCOV
892
                    widget.valueChanged.emit(widget.value())
×
893

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
1012
                self.controller2model()
×
1013

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
1130
        self.running_task_process = None
×
1131

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

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

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

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

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

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

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

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

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

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

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

1230

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

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

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

1264

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

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

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

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

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

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

1293

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

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

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

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

1324

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