|
| 1 | +""" |
| 2 | +A very simple task: Blink an LED |
| 3 | +
|
| 4 | +Written by @mikewehr in the ``mike`` branch: https://github.com/wehr-lab/autopilot/blob/mike/autopilot/tasks/blink.py |
| 5 | +
|
| 6 | +Demonstrates the basic structure of a task with one stage, |
| 7 | +described in the comments throughout the task. |
| 8 | +
|
| 9 | +See the main tutorial for more detail: https://docs.auto-pi-lot.com/en/latest/guide.task.html# |
| 10 | +
|
| 11 | +This page is rendered in the docs here in order to provide links to the mentioned objects/classes/etc., but |
| 12 | +this example was intended to be read as source code, as some comments will only be visible there. |
| 13 | +
|
| 14 | +.. note:: |
| 15 | + Currently, after completion, the task needs to be explicitly imported and added to :data:`.tasks.TASK_LIST` , |
| 16 | + this will be remedied shortly (see the ``registries`` branch). |
| 17 | +""" |
| 18 | +import itertools |
| 19 | +import tables |
| 20 | +import time |
| 21 | +from datetime import datetime |
| 22 | + |
| 23 | +from autopilot.hardware import gpio |
| 24 | +from autopilot.tasks import Task |
| 25 | +from collections import OrderedDict as odict |
| 26 | + |
| 27 | +class Blink(Task): |
| 28 | + """ |
| 29 | + Blink an LED. |
| 30 | +
|
| 31 | + Note that we subclass the :class:`.tasks.Task` class (``Blink(Task)``) to provide us with some methods |
| 32 | + useful for all Tasks. |
| 33 | +
|
| 34 | + Args: |
| 35 | + pulse_duration (int, float): Duration the LED should be on, in ms |
| 36 | + pulse_interval (int, float): Duration the LED should be off, in ms |
| 37 | +
|
| 38 | + """ |
| 39 | + # Tasks need to have a few class attributes defined to be integrated into the rest of the system |
| 40 | + # See here for more about class vs. instance attributes https://www.toptal.com/python/python-class-attributes-an-overly-thorough-guide |
| 41 | + |
| 42 | + STAGE_NAMES = ["pulse"] # type: list |
| 43 | + """ |
| 44 | + An (optional) list or tuple of names of methods that will be used as stages for the task. |
| 45 | + |
| 46 | + See :attr:`.Blink.stages` for more information |
| 47 | + """ |
| 48 | + |
| 49 | + PARAMS = odict() |
| 50 | + """ |
| 51 | + A dictionary that specifies the parameters that control the operation of the task -- each task presumably has some |
| 52 | + range of options that allow slight variations (eg. different stimuli, etc.) on a shared task structure. This |
| 53 | + dictionary specifies each ``PARAM`` as a human-readable ``tag`` and a ``type`` that is used by the gui to |
| 54 | + create an appropriate input object. For example:: |
| 55 | + |
| 56 | + PARAMS['pulse_duration'] = {'tag': 'LED Pulse Duration (ms)', 'type': 'int'} |
| 57 | + |
| 58 | + When instantiated, these params are passed to the ``__init__`` method. |
| 59 | + |
| 60 | + A :class:`collections.OrderedDict` is used so that parameters can be presented in a predictable way to users. |
| 61 | + """ |
| 62 | + PARAMS['pulse_duration'] = {'tag': 'LED Pulse Duration (ms)', 'type': 'int'} |
| 63 | + PARAMS['pulse_interval'] = {'tag': 'LED Pulse Interval (ms)', 'type': 'int'} |
| 64 | + |
| 65 | + class TrialData(tables.IsDescription): |
| 66 | + """ |
| 67 | + This class declares the data that will be returned for each "trial" -- or complete set of executed task |
| 68 | + stages. It is used by the :class:`.subject.Subject` object to make a data table with the |
| 69 | + correct data types. Declare each piece of data using a pytables Column descriptor |
| 70 | + (see https://www.pytables.org/usersguide/libref/declarative_classes.html#col-sub-classes for available |
| 71 | + data types, and the pytables guide: https://www.pytables.org/usersguide/tutorials.html for more information) |
| 72 | +
|
| 73 | + For each trial, we'll return two timestamps, the time we turned the LED on, the time we turned it off, |
| 74 | + and the trial number. Note that we use a 26-character :class:`tables.StringCol` for the timestamps, |
| 75 | + which are given as an isoformatted string like ``'2021-02-16T18:11:35.752110'`` |
| 76 | + """ |
| 77 | + trial_num = tables.Int32Col() |
| 78 | + timestamp_on = tables.StringCol(26) |
| 79 | + timestamp_off = tables.StringCol(26) |
| 80 | + |
| 81 | + |
| 82 | + HARDWARE = { |
| 83 | + 'LEDS': { |
| 84 | + 'dLED': gpio.Digital_Out |
| 85 | + } |
| 86 | + } |
| 87 | + """ |
| 88 | + Declare the hardware that will be used in the task. Each hardware object is specified with a ``group`` and |
| 89 | + an ``id`` as nested dictionaries. These descriptions require a set of hardware parameters in the autopilot |
| 90 | + ``prefs.json`` (typically generated by :mod:`autopilot.setup.setup_autopilot` ) with a matching ``group`` and |
| 91 | + ``id`` structure. For example, an LED declared like this in the :attr:`.Blink.HARDWARE` attribute:: |
| 92 | + |
| 93 | + HARDWARE = {'LEDS': {'dLED': gpio.Digital_Out}} |
| 94 | + |
| 95 | + requires an entry in ``prefs.json`` like this:: |
| 96 | + |
| 97 | + "HARDWARE": {"LEDS": {"dLED": { |
| 98 | + "pin": 1, |
| 99 | + "polarity": 1 |
| 100 | + }}} |
| 101 | + |
| 102 | + that will be used to instantiate the :class:`.hardware.gpio.Digital_Out` object, which is then available for use |
| 103 | + in the task like:: |
| 104 | + |
| 105 | + self.hardware['LEDS']['dLED'].set(1) |
| 106 | + """ |
| 107 | + |
| 108 | + def __init__(self, stage_block=None, pulse_duration=100, pulse_interval=500, *args, **kwargs): |
| 109 | + # first we call the superclass ('Task')'s initialization method. All tasks should accept ``*args`` |
| 110 | + # and ``**kwargs`` to pass parameters not explicitly specified by subclass up to the superclass. |
| 111 | + super(Blink, self).__init__(*args, **kwargs) |
| 112 | + |
| 113 | + # store parameters given on instantiation as instance attributes |
| 114 | + self.pulse_duration = int(pulse_duration) |
| 115 | + self.pulse_interval = int(pulse_interval) |
| 116 | + self.stage_block = stage_block # type: "threading.Event" |
| 117 | + |
| 118 | + # This allows us to cycle through the task by just repeatedly calling self.stages.next() |
| 119 | + self.stages = itertools.cycle([self.pulse]) |
| 120 | + """ |
| 121 | + Some generator that returns the stage methods that define the operation of the task. |
| 122 | + |
| 123 | + To run a task, the :class:`.pilot.Pilot` object will call each stage function, which can return some dictionary |
| 124 | + of data (see :meth:`.Blink.pulse` ) and wait until some flag (:attr:`.Blink.stage_block` ) is set to compute the |
| 125 | + next stage. Since in this case we want to call the same method (:meth:`.Blink.pulse` ) over and over again, |
| 126 | + we use an :class:`itertools.cycle` object (if we have more than one stage to call in a cycle, we could provide |
| 127 | + them like ``itertools.cycle([self.stage_method_1, self.stage_method_2])`` . More complex tasks can define a custom |
| 128 | + generator for finer control over stage progression. |
| 129 | + """ |
| 130 | + |
| 131 | + self.trial_counter = itertools.count() |
| 132 | + """ |
| 133 | + Some counter to keep track of the trial number |
| 134 | + """ |
| 135 | + |
| 136 | + |
| 137 | + self.init_hardware() |
| 138 | + """ |
| 139 | + Hardware is initialized by the superclass's :meth:`.Task.init_hardware` method, which creates all the |
| 140 | + hardware objects defined in :attr:`.Blink.HARDWARE` according to their parameterization in |
| 141 | + ``prefs.json`` , and makes them available in the :attr:`.Blink.hardware` dictionary. |
| 142 | + """ |
| 143 | + self.logger.debug('Hardware initialized') |
| 144 | + """ |
| 145 | + All task subclass objects have an :attr:`.Task.logger` -- a :class:`logging.Logger` that allows |
| 146 | + users to easily debug their tasks and see feedback about their operation. To prevent stdout from |
| 147 | + getting clogged, logging messages are printed and stored according to the ``LOGLEVEL`` pref -- so this |
| 148 | + message would only appear if ``LOGLEVEL == "DEBUG"`` |
| 149 | + """ |
| 150 | + |
| 151 | + self.stage_block.set() |
| 152 | + """ |
| 153 | + We set the stage block and never clear it so that the :class:`.Pilot` doesn't wait for a trigger |
| 154 | + to call the next stage -- it just does it as soon as the previous one completes. |
| 155 | + |
| 156 | + See :meth:`.Pilot.run_task` for more detail on this loop. |
| 157 | + """ |
| 158 | + |
| 159 | + |
| 160 | + ################################################################################## |
| 161 | + # Stage Functions |
| 162 | + ################################################################################## |
| 163 | + def pulse(self, *args, **kwargs): |
| 164 | + """ |
| 165 | + Turn an LED on and off according to :attr:`.Blink.pulse_duration` and :attr:`.Blink.pulse_interval` |
| 166 | +
|
| 167 | + Returns: |
| 168 | + dict: A dictionary containing the trial number and two timestamps. |
| 169 | + """ |
| 170 | + # ------------- |
| 171 | + # turn light on |
| 172 | + |
| 173 | + # use :meth:`.hardware.gpio.Digital_Out.set` method to turn the LED on |
| 174 | + self.hardware['LEDS']['dLED'].set(1) |
| 175 | + # store the timestamp |
| 176 | + timestamp_on = datetime.now().isoformat() |
| 177 | + # log status as a debug message |
| 178 | + self.logger.debug('light on') |
| 179 | + # sleep for the pulse_duration |
| 180 | + time.sleep(self.pulse_duration / 1000) |
| 181 | + |
| 182 | + # ------------ |
| 183 | + # turn light off, same as turning it on. |
| 184 | + |
| 185 | + self.hardware['LEDS']['dLED'].set(0) |
| 186 | + timestamp_off = datetime.now().isoformat() |
| 187 | + self.logger.debug('light off') |
| 188 | + time.sleep(self.pulse_interval / 1000) |
| 189 | + |
| 190 | + # count and store the number of the current trial |
| 191 | + self.current_trial = next(self.trial_counter) |
| 192 | + |
| 193 | + |
| 194 | + data = { |
| 195 | + 'trial_num': self.current_trial, |
| 196 | + 'timestamp_on': timestamp_on, |
| 197 | + 'timestamp_off': timestamp_off |
| 198 | + } |
| 199 | + """ |
| 200 | + Create the data dictionary to be returned from the stage. Note that each of the keys in the dictionary |
| 201 | + must correspond to the names of the columns declared in the :attr:`.Blink.TrialData` descriptor. |
| 202 | + |
| 203 | + At the conclusion of running the task, we will be able to access the data from the run with |
| 204 | + :meth:`.Subject.get_trial_data`, which will be a :class:`pandas.DataFrame` with a row for each trial, and |
| 205 | + a column for each of the fields here. |
| 206 | + """ |
| 207 | + |
| 208 | + # return the data dictionary from the stage method and yr done :) |
| 209 | + return data |
0 commit comments