class ChainedTransmittersBlock(TransmitterBase): """Block with stacked transmitter and receiver decorators.""" start = Transmitter(str) intermediate = Transmitter(str) def finish(self, msg): self.message = msg
class CustomTask(Task): tx = Transmitter() def rx(self): global count count += 1
class ComplicatedBlock(TransmitterBase): """Block with more complicated transmitter signatures.""" tx = Transmitter(int, tuple, float) def __init__(self): super(ComplicatedBlock, self).__init__() self.coords = None def set_coords(self, i, c, h): self.coords = c
class Timer(TransmitterBase): """Real-time one-shot timer. This is useful in situations where you want to wait for some amount of time and locking the timing to data acquisition updates is not important. For example, inserting a waiting period between trials of a task can be done by connecting the ``timeout`` transmitter to your task's :meth:`~axopy.task.Task.next_trial` method. Parameters ---------- duration : float Duration of the timer, in seconds. Attributes ---------- timeout : Transmitter Transmitted when the timer has finished. """ timeout = Transmitter() def __init__(self, duration): super(Timer, self).__init__() self.duration = duration self._qtimer = QtCore.QTimer() self._qtimer.setInterval(int(1000 * self.duration)) self._qtimer.setSingleShot(True) self._qtimer.timeout.connect(self.timeout) def start(self): """Start the timer.""" self._qtimer.start() def stop(self): """Stop the timer. If you stop the timer early, the timeout event won't be transmitted. """ self._qtimer.stop()
class Experiment(TransmitterBase): """Experiment workflow manager. Presents the researcher with a prompt for entering session details and then presents the appropriate tasks. Parameters ---------- daq : object, optional A data acquisition device that follows the AxoPy DAQ protocol. See :mod:`axopy.daq`. For mutliple devices, a dictionary, list or tuple is expected. data : str, optional Path to the data. The directory is created for you if it doesn't exist. subject : str, optional The subject ID to use. If not specified, a configuration screen is shown before running the tasks so you can enter it there. This is mostly for experiment writing (to avoid the extra configuration step). allow_overwrite : bool, optional If ``True``, overwrite protection in :class:`Storage` is disabled. This is mostly for experiment writing purposes. """ key_pressed = Transmitter(str) def __init__(self, daq=None, data='data', subject=None, allow_overwrite=False): super(Experiment, self).__init__() self.daq = daq self.storage = Storage(data, allow_overwrite=allow_overwrite) self._receive_keys = False self.subject = subject # main screen self.screen = _MainWindow() # Prepare daqstream(s) self._prepare_daqstream() def configure(self, **options): """Configure the experiment with custom options. This method allows you to specify a number of options that you want to configure with a graphical interface prior to running the tasks. Use keyword arguments to specify which options you want to configure. The options selected/specified in the graphical interface are then returned by this method so that you can alter setup before running the experiment. Each keyword argument should list the data type to configure, such as ``float``, ``str``, or ``int``. You can also provide a list or tuple of available choices for that option. You *do not* need to add an option for the subject name/ID -- that is added automatically if the subject ID was not specified when creating the experiment. """ options['subject'] = str config = _SessionConfig(options).run() self.subject = config['subject'] return config def run(self, *tasks): """Run the experimental tasks.""" if self.subject is None: self.configure() self.screen.key_pressed.connect(self.key_press) # screen to show "Ready" between tasks self.confirm_screen = Canvas(draw_border=False) self.confirm_screen.add_item(Text("Ready (enter to start)")) self.storage.subject_id = self.subject self.tasks = tasks self.current_task = None self.task_iter = iter(self.tasks) self._task_finished() self.screen.run() @property def status(self): return "subject: {} | task: {}".format( self.subject, self.current_task.__class__.__name__) def _run_task(self): self._receive_keys = False # wait for task to finish self.current_task.finished.connect(self._task_finished) # forward key presses to the task self.key_pressed.connect(self.current_task.key_press) self.screen.set_status(self.status) # add a task view con = self.screen.new_container() self.current_task.prepare_graphics(con) self.current_task.prepare_daq(self.daqstream) self.current_task.prepare_storage(self.storage) self.current_task.run() def _task_finished(self): if self.current_task is not None: self.current_task.disconnect_all() self.current_task.finished.disconnect(self._task_finished) self.key_pressed.disconnect(self.current_task.key_press) try: self.current_task = next(self.task_iter) except StopIteration: self.screen.quit() self.screen.set_container(self.confirm_screen) self._receive_keys = True def key_press(self, key): if self._receive_keys: if key == util.key_escape: self.screen.quit() elif key == util.key_return: self._run_task() else: self.key_pressed.emit(key) def _prepare_daqstream(self): if isinstance(self.daq, (list, tuple)): self.daqstream = [] for daq_ in self.daq: self.daqstream.append(DaqStream(daq_)) elif isinstance(self.daq, dict): self.daqstream = dict() for daq_name, daq_ in self.daq.items(): self.daqstream[daq_name] = DaqStream(daq_) else: self.daqstream = DaqStream(self.daq)
class EventTransmitterBlock(TransmitterBase): """Block with a blank transmitter.""" trigger = Transmitter() def on_event(self): self.event_occurred = True
class RelayBlock(TransmitterBase): """Just transmits the data its transmitter is called with.""" relay = Transmitter(int)
class StepCounter(Counter): """Multiple step counter. Counts to a given number then transmits a timeout event. Transmits events at multiple values up to timeout. Parameters ---------- max_count : int Number of iterations to go through before transmitting the `timeout` event. Must be greater than 1. reset_on_timeout : bool, optional Specifies whether or not the timer should reset its count back to zero once the timeout event occurs. The default behavior is to reset. Attributes ---------- count : int Current count. timeout : Transmitter Transmitted when ``max_count`` has been reached. Examples -------- Basic usage: >>> from axopy.timing import StepCounter >>> timer = StepCounter(3) >>> timer.add_step(1, function1) >>> timer.add_step(2, function2) >>> timer.increment() "function 1" >>> timer.increment() "function 2" >>> timer.increment() >>> timer.count 0 """ timeout = Transmitter() # README: No way to create an array of pyqtSignal(s) ... # https://stackoverflow.com/questions/38506979/creating-an-array-of-pyqtsignal step_max = 10 for i in range(step_max): vars()['step' + str(i)] = Transmitter() def __init__(self, max_count=1, reset_on_timeout=True): super(StepCounter, self).__init__(max_count, reset_on_timeout) self.step_inc = 0 self.step_count = [] @staticmethod def _dummy(): """A default method for add_step.""" pass def add_step(self, count=0, event=_dummy): """Add a step if we have enough emitters.""" if self.step_inc < self.step_max: getattr(self, 'step' + str(self.step_inc)).connect(event) self.step_count.append(count) self.step_inc += 1 def increment(self): """Increment the counter. If a count is reached which is found in `step_count` the event associated with the count is transmitted. If `max_count` is reached, the ``timeout`` event is transmitted. If `reset_on_timeout` has been set to True (default), the timer is also reset. """ self.count += 1 if self.count in self.step_count: # _index = self.step_count.index(self.count) # getattr(self, 'step' + str(_index)).emit() _index = [ i for i, x in enumerate(self.step_count) if x == self.count ] for i in _index: getattr(self, 'step' + str(i)).emit() if self.count == self.max_count: if self.reset_on_timeout: self.reset() self.timeout.emit()
class Counter(TransmitterBase): """Counts to a given number then transmits a timeout event. Parameters ---------- max_count : int Number of iterations to go through before transmitting the `timeout` event. Must be greater than 1. reset_on_timeout : bool, optional Specifies whether or not the timer should reset its count back to zero once the timeout event occurs. The default behavior is to reset. Attributes ---------- count : int Current count. timeout : Transmitter Transmitted when ``max_count`` has been reached. Examples -------- Basic usage: >>> from axopy.timing import Counter >>> timer = Counter(2) >>> timer.increment() >>> timer.count 1 >>> timer.progress 0.5 >>> timer.increment() >>> timer.count 0 """ timeout = Transmitter() def __init__(self, max_count=1, reset_on_timeout=True): super(Counter, self).__init__() max_count = int(max_count) if max_count < 1: raise ValueError('max_count must be > 1') self.reset_on_timeout = reset_on_timeout self.max_count = max_count self.count = 0 @property def progress(self): """Progress toward timeout, from 0 to 1.""" return self.count / self.max_count def increment(self): """Increment the counter. If `max_count` is reached, the ``timeout`` event is transmitted. If `reset_on_timeout` has been set to True (default), the timer is also reset. """ self.count += 1 if self.count == self.max_count: if self.reset_on_timeout: self.reset() self.timeout.emit() def reset(self): """Resets the count to 0 to start over.""" self.count = 0
class DaqStream(QtCore.QThread): """Asynchronous interface to an input device. Runs a persistent while loop wherein the device is repeatedly polled for data. When the data becomes available, it is emitted and the loop continues. There are effectively two methods of this class: start and stop. These methods do as their names suggest -- they start and stop the underlying device from sampling new data. The device used to create the DaqStream is also accessible via the ``device`` attribute so you can change settings on the underlying device any time (e.g. sampling rate, number of samples per update, etc.). Parameters ---------- device : daq Any object implementing the AxoPy data acquisition interface. See :class:`NoiseGenerator` for an example. Attributes ---------- updated : Transmitter Transmitted when the latest chunk of data is available. The data type depends on the underlying input device, but it is often a numpy ndarray. disconnected : Transmitter Transmitted if the device cannot be read from (it has disconnected somehow). finished : Transmitter Transmitted when the device has stopped and samping is finished. """ updated = Transmitter(object) disconnected = Transmitter() finished = Transmitter() def __init__(self, device): super(DaqStream, self).__init__() self.device = device self._running = False @property def running(self): """Boolean value indicating whether or not the stream is running.""" return self._running def start(self): """Start the device and begin reading from it.""" super(DaqStream, self).start() def run(self): """Implementation for the underlying QThread. Don't call this method directly -- use :meth:`start` instead. """ self._running = True self.device.start() while True: if not self._running: break try: d = self.device.read() except IOError: self.disconnected.emit() return if self._running: self.updated.emit(d) self.device.stop() self.finished.emit() def stop(self, wait=True): """Stop the stream. Parameters ---------- wait : bool, optional Whether or not to wait for the underlying device to stop before returning. """ self._running = False if wait: self.wait()
class _MainWindow(QtWidgets.QMainWindow): """The window containing all graphical content of the application. It is a very simple GUI implemented as a `QMainWindow` with a `QStackedLayout` holding a list of :class:`Container` objects. The containers, which in turn house all of the interesting graphical content. """ key_pressed = Transmitter(str) def __init__(self): app = get_qtapp() super(_MainWindow, self).__init__() app.installEventFilter(self) self._central_widget = QtWidgets.QWidget(self) self._layout = QtWidgets.QStackedLayout(self._central_widget) self.setCentralWidget(self._central_widget) status_bar = QtWidgets.QStatusBar(self) self.setStatusBar(status_bar) self._statusbar_label = QtWidgets.QLabel("status") status_bar.addPermanentWidget(self._statusbar_label) self.show() def run(self): """Start the application.""" get_qtapp().exec_() def new_container(self): """Add a new container to the stack and give it back. Returns ------- container : Container The newly added container. """ c = Container() self._layout.addWidget(c) self._layout.setCurrentWidget(c) return c def set_container(self, container): """Make the given container visible. If the container is already somewhere in the stack, it is just made visible, otherwise it is added to the stack. """ if self._layout.indexOf(container) == -1: self._layout.addWidget(container) self._layout.setCurrentWidget(container) def set_status(self, message): """Set the status bar message. Parameters ---------- message : str Message to display in the status bar. """ self._statusbar_label.setText(message) def quit(self): """Quit the application.""" get_qtapp().quit() def keyPressEvent(self, event): """Qt callback for key presses. This overrides the `QMainWindow` method. It does not need to be called directly and it doesn't need to be overriden. Connect to the ``key_pressed`` transmitter to handle key press events. """ try: key = key_map[event.key()] except KeyError: return super().keyPressEvent(event) self.key_pressed.emit(key)
class Task(TransmitterBase): """Base class for tasks. This base class handles iteration through the trials of the task in blocks. Most task implementations will want to override the `prepare` and `run_trial` methods, while the rest can be left to default behavior. If you need to implement a custom constructor (``__init__``), you *must* call the base task ``__init__``:: class CustomTask(Task): def __init__(self, custom_param): super(CustomTask, self).__init__() Attributes ---------- trial : dict Dictionary containing the current trial's attributes. advance_block_key : str Key for the user to press in order to advance to the next block. Can set to ``None`` to disable the feature (next block starts immediately after one finishes). finished : Transmitter Emitted when the last trial of the last block has run. This is primarily for the :class:`axopy.experiment.Experiment` to know when the task has finished so it can run the next one. You shouldn't need to use this transmitter at all. """ advance_block_key = util.key_return finished = Transmitter() def __init__(self): super(Task, self).__init__() self._connections = {} design = Design() self.iter = _TaskIter(design) self.prepare_design(design) def connect(self, transmitter, receiver): """Connect a transmitter to a receiver. This method helps the task keep track of connections so that all of the manually specified connections can be torn down by the :class:`axopy.experiment.Experiment`. """ name = _connection_name(transmitter, receiver) self._connections[name] = (transmitter, receiver) transmitter.connect(receiver) def disconnect(self, transmitter, receiver): """Disconnect a transmitter from a receiver.""" name = _connection_name(transmitter, receiver) try: del self._connections[name] transmitter.disconnect(receiver) except KeyError: # tx/rx pair already removed/disconnected pass def disconnect_all(self): """Disconnect all of the task's manually-created connections.""" for name, (tx, rx) in self._connections.items(): tx.disconnect(rx) self._connections.clear() def prepare_design(self, design): """Callback for setting up the task design. See :class:`axopy.design.Design` for details on how to design the task. By default, nothing is added to the design. Parameters ---------- design : Design The task design object you can use to add blocks and trials. """ pass def prepare_graphics(self, container): """Initialize graphical elements and messaging connections. This method should be overridden if the task uses any graphics (which most do). It is important to defer initializing any graphical elements until this method is called so that the graphical backend has a chance to start. Parameters ---------- container : axopy.gui.Container The graphical container you can add objects to. """ pass def prepare_daq(self, daqstream): """Set up the input device, if applicable. Parameters ---------- daqstream : DaqStream Interface to the data acquisition device. """ pass def prepare_storage(self, storage): """Initialize data storage. Override to read or write task data. A :class:`axopy.storage.Storage` object is given, which can be used to create a new :class:`axopy.storage.TaskWriter` for storing new data or a :class:`axopy.storage.TaskReader` for reading in existing data. Note that the subject ID has already been set. Parameters ---------- storage : Storage The top-level storage object with which new storage can be allocated and existing data can be read. """ pass def run(self): """Start running the task. Simply calls `next_block` to start running trials in the first block. This method is called automatically if the task is added to an :class:`~axopy.experiment.Experiment`. Tasks that have a block design shouldn't normally need to override this method. Tasks that are "free-running" for experimenter interaction (e.g. a plot visualization task that the experimenter controls) should override. """ self.next_block() def next_block(self): """Get the next block of trials and starts running them. Before starting the block, a prompt is shown to verify that the user is ready to proceed. If there are no more blocks to run, the `finish` method is called. You usually do not need to override this method. """ block = self.iter.next_block() if block is None: self.finish() return self.block = block # wait for confirmation between blocks if self.advance_block_key is None: self.next_trial() else: self._awaiting_key = True def next_trial(self): """Get the next trial in the block and starts running it. If there are no more trials in the block, the `finish_block` method is called. """ trial = self.iter.next_trial() if trial is None: self.finish_block() return self.trial = trial self.run_trial(trial) def run_trial(self, trial): """Initiate a trial. By default, this method does nothing. Override to implement what happens in a trial. When a trial is complete, use `next_trial` to start the next. Parameters ---------- trial : object Trial data. This is whatever data is put into the `design` passed in. """ pass def finish_block(self): """Finishes the block and starts the next one. Override if you need to do some cleanup between blocks. """ self.next_block() def finish(self): """Clean up at the end of the task. Override if you need to clean up once the task is completely finished. If you do override this method, you should call the base :meth:`Task.finish()` method or call the ``finished`` transmitter yourself. """ self.finished.emit() def key_press(self, key): """Handle key press events. Override this method to receive key press events. Available keys can be found in :mod:`axopy.util` (named `key_<keyname>`, e.g. `key_k`). Important note: if relying on the ``advance_block_key`` to advance the task, make sure to call this super implementation. """ if getattr(self, '_awaiting_key', False) and \ key == self.advance_block_key: self._awaiting_key = False self.next_trial()
class EnvelopeCalibrationWidget(QtGui.QWidget): """EMG envelope calibration widget. Consists of two sub-widgets, a ``PlotWidget`` and a ``BarWidget``. There are two pushbuttons and optionally a dropdown menu, all of which are connected to pyqtsignals emitting the widget's id and the selected value in the case of the dropdown menu. Parameters ---------- id : object, optional (default=None) Widget identifier. This is emitted every time a button is pressed or a selection is made from the dropdown menu. If multiple widgets are used at the same time, their id's have to be unique so that they are distinguishable. task_channels : list of str, optional (default=None) The task channels that will be offered as options in the dropdown menu. If not provided, the dropdown menu will not show up. name : str, optional (default=None) Widget name that will be displayed. size : tuple, optional (default=None) Widget size. pos : tuple, optional (default=None) Widget position. autorange : boolean, optional (default=True) If ``False`` the autorange option will be disabled from the ``PlotWidget``. yrange : tuple, optional (default=(-1, 1)) When ``autorange`` is ``False``, this is the yrange for the ``PlotWidget``. When ``autorange`` is ``True``, this will be ignored. Attributes ---------- max : Transmitter Emits the widget ``id`` (or ``None`` when not provided) when the ``max`` button is pressed. min : Transmitter Emits the widget ``id`` (or ``None`` when not provided) when the ``min`` button is pressed. reset : Transmitter Emits the widget ``id`` (or ``None`` when not provided) when the ``reset`` button is pressed. active : Transmitter Emits the widget ``id`` (or ``None`` when not provided) when the widget is activated. selected : Transmitter Emits the widget ``id`` (or ``None`` when not provided) and the selected value from the dropdown menu. """ max = Transmitter(object) min = Transmitter(object) reset = Transmitter(object) active = Transmitter(object) selected = Transmitter(object) def __init__(self, id=None, task_channels=None, name=None, size=None, pos=None, autorange=True, yrange=(-1, 1)): super(EnvelopeCalibrationWidget, self).__init__() self.id = id self.task_channels = task_channels self.name = name self.size = size self.pos = pos self.autorange = autorange self.yrange = yrange self.init_widget() def init_widget(self): """Initializes the main widget and adds sub-widgets and menus. """ if self.name is not None: self.setWindowTitle(self.name) layout = QGridLayout() layout.setSpacing(20) self.setLayout(layout) # Sub-widgets self.emgWidget = pg.PlotWidget(background=None) self.emgItem = self.emgWidget.plot(pen='b') self.emgWidget.hideAxis('left') self.emgWidget.hideAxis('bottom') if self.autorange is False: self.emgWidget.disableAutoRange(pg.ViewBox.YAxis) self.emgWidget.setYRange(*self.yrange) self.barWidget = pg.PlotWidget(background=None) self.barItem = pg.BarGraphItem(x=[1.], height=[0.], width=1, brush='b') self.barWidget.addItem(self.barItem) self.barWidget.setYRange(0, 1.3) self.barWidget.hideAxis('bottom') self.barWidget.showGrid(y=True, alpha=0.5) self.reset_button = QPushButton('Reset') self.reset_button.resize(self.reset_button.sizeHint()) self.reset_button.clicked.connect(self.resetButtonClicked) self.max_button = QPushButton('max') self.max_button.resize(self.max_button.sizeHint()) self.max_button.clicked.connect(self.maxButtonClicked) self.min_button = QPushButton('min') self.min_button.resize(self.min_button.sizeHint()) self.min_button.clicked.connect(self.minButtonClicked) if self.task_channels is not None: self.select = QComboBox() self.select.addItem('Select') for task_channel in self.task_channels: self.select.addItem(task_channel) self.select.currentIndexChanged[str].connect(self.selectActivated) layout.addWidget(self.emgWidget, 0, 0, 4, 1) layout.addWidget(self.barWidget, 0, 1, 4, 1) layout.addWidget(self.reset_button, 1, 2) layout.addWidget(self.max_button, 2, 2) layout.addWidget(self.min_button, 3, 2) if self.task_channels is not None: layout.addWidget(self.select, 0, 2) # determine layout window layout.setColumnStretch(1, 10) layout.setColumnStretch(2, 2) layout.setColumnStretch(3, 2) if self.size is not None: self.resize(*self.size) if self.pos is not None: self.move(*self.pos) self.installEventFilter(self) def maxButtonClicked(self): self.max.emit(self.id) def minButtonClicked(self): self.min.emit(self.id) def resetButtonClicked(self): if self.task_channels is not None: self.select.setCurrentText('Select') self.reset.emit(self.id) def selectActivated(self, text): if text == 'Select': value = None else: value = text self.selected.emit((self.id, value)) def eventFilter(self, obj, event): """Returns ``True`` if the widget is activated. """ if event.type() == QEvent.WindowActivate: self.active.emit(self.id) return True else: return False def keyPressEvent(self, e): """Keyboard shortcuts. """ if e.key() == QtCore.Qt.Key_Escape: self.close() if e.key() == QtCore.Qt.Key_R: self.minButtonClicked() if e.key() == QtCore.Qt.Key_C: self.maxButtonClicked() def set_emg_color(self, color): """Sets the color for the raw EMG plot widget. """ self.emgItem.setPen(color)