diff --git a/blacs/plugins/lock_monitor/README.rst b/blacs/plugins/lock_monitor/README.rst new file mode 100644 index 00000000..13df057a --- /dev/null +++ b/blacs/plugins/lock_monitor/README.rst @@ -0,0 +1,482 @@ +Lock Monitor +============ + +.. image:: images/lock_monitor_tab_screenshot.png + :alt: A screenshot of lock monitor's blacs tab. + + +Introduction +------------ + +Lock monitor is a blacs plugin designed as a framework to help users automatically pause the blacs queue when a laser goes out of lock and optionally attempt to lock the laser automatically. +The role of lock monitor is possibly best explained by listing what is does and doesn't do. + +What Lock Monitor Does: + +* It calls user-written code to check if lasers are in lock at various points during the execution of a shot. +* It temporarily pauses the blacs queue and optionally calls user-written code to lock a laser when it is determined to be out of lock. + * The queue is unpaused if the laser is successfully locked, or remains paused if the laser fails to lock after a few attempts. +* It provides a blacs tab as a GUI for controlling various lockers. +* It provides a settings interface for adding/removing lockers. +* It provides base classes for monitoring and locking lasers, which include methods that the user must flesh out. +* It sandboxes user-written code in separate worker processes to reduce the chance that bugs there can crash blacs. + +What Lock Monitor Does NOT Do: + +* It does NOT include code to communicate with hardware, such as function generators and oscilloscopes, as the possible variety of hardware is too large. + The user must use 3rd party libraries and/or write the code to communicate with the required hardware themselves. +* It does NOT perform the required signal analysis. + * In particular it does NOT identify features oscilloscope traces of spectroscopic features to determine where to lock the laser. + +Although lock monitor was written with locking lasers in mind, it likely has other uses. +For example, a user could monitor the power transmitted through a fiber and pause the blacs queue if it drops below a certain level. +They could even write code to automatically realign light into the fiber if the mount has electronic actuators, which lock monitor could automatically call. + +Lock monitor took inspiration from, but is distinct from, the labwatch blacs plugin. + + +Using Lock Monitor +------------------ + +This section briefly describes how lock monitor is used. +More detailed instructions and information on how to write the code required to interface lock monitor with your system are provided in other sections below. + +Once the user has written their locker classes, using lock monitor is very simple. +First, lock monitor is enabled by adding the line ``lock_monitor = True`` to the ``[BLACS/plugins]`` section of the labconfig. +Instances of user-written locker classes are then added by entering their import path in the "Lock Monitor" tab of the blacs preferences menu, accessed via File -> Preferences -> Lock Monitor. +A restart of blacs is required for those changes to take effect. + +When lock monitor is enabled, a blacs tab is created with the title "Lock Monitor". +The tab contains one collapsible section per locker, each one containing the controls for the given locker. +The controls include the following: + +* ``Monitor Enable``: This controls whether or not the methods to check the laser's lock status are enabled. +* ``Locking Enable``: This controls whether or not lock monitor should attempt to lock the laser if it is found to be out of lock. + If monitoring is enabled but locking is disabled and the laser is found to be out of lock, then the current shot will be aborted and requeued, and the queue will be paused. +* ``Force Lock``: When clicked, this will force lock monitor to lock the laser when the next shot is executed, regardless of whether or not the laser is deemed to be out of lock. + Note that, as of this writing, the laser will not be locked immediately; it will be locked when the next shot is run. + Once the locker has been forced to lock, this control will automatically be turned off again. +* ``Restart Worker``: When clicked, this will instruct lock monitor to restart the worker process for the locker. + Restarting the worker will re-import the locker and re-run its initialization methods, which can be helpful if something goes wrong or if you've made changes to the locker's code. + Note that, as of this writing, this will not be done immediately; the worker will be restarted when the next shot is run. + Once the worker has begun restarting, this control will automatically be turned off again. + + +Approach +-------- + +Lock monitor works by calling methods of user-written python classes at certain points in the execution of a shot as the blacs state machine moves between states. +These methods are referred to as callbacks, as they are run when blacs executes its plugin callbacks. +Each user-written callback should return either ``True`` or ``False``. +When ``True`` is returned, blacs is allowed to continue on running the shot as usual. +However, when ``False`` is returned, the following steps are taken: + +#. Lock monitor attempts to abort the shot, though the shot may continue to run if it is too late to abort. +#. The blacs queue is paused. +#. Whether or not the shot was successfully aborted, it is NOT sent to lyse and is instead prepended to the top of the blacs queue. +#. Lock monitor then calls user-written code to lock the laser if such code is provided. +#. If the lock is successful, as determined by more user-written code, the queue is then unpaused. + If the lock is unsuccessful, a few more attempt to lock are made. + If none of those attempts are successful, or if no automatic locking code is provided for the laser, then the queue is left in the paused state. + +Put another way, the user writes callbacks which are methods of a locker class. +The callbacks should communicate with whatever hardware is necessary then return ``False`` if they determine that a laser is out of lock and ``True`` otherwise. +For automatic locking to work, the user must also write a ``lock()`` method for their class, which lock monitor will call when necessary. + + +Making ``Locker`` Classes +------------------------- + +The ``Locker`` Class +^^^^^^^^^^^^^^^^^^^^ + +The code used to check and lock lasers should be organized into a locker class. +User-created locker classes should inherit from the provided ``Locker`` class which can be imported from ``blacs.plugins.lock_monitor.lockers``. +That class provides the minimum set of properties and basic methods that locker classes should have. +The class properties and methods are described in detail in the docstrings, which should be referred to when writing a custom locker class. +Briefly, the most important attributes are: + +* ``self.logger`` for logging progress, results, etc. with commands such as ``self.logger.info("Doing some step...")``. + Logging isn't strictly necessary but it is extremely helpful when debugging and so it is strongly encouraged that users add logging statements to their locker class methods. +* ``self.display_name`` which is used to label the controls, as well as a few other things. +* ``self.plot_dir`` is the suggested directory in which to save plots, which is a subdirectory of the directory specified by ``blacs.plugins.lock_monitor.lockers.LOG_PATH``, which in turn is a subdirectory of the default labscript logging directory specified by ``labscript_utils.setup_logging.LOG_PATH``. + The subdirectory path also includes ``self.plot_root_dir_name``, as well as subdirectories for the year, month, day, and full date/time. + As with logging, generating plots isn't strictly necessary but can be extremely helpful in debugging when things go wrong, so doing so is strongly encouraged. +* ``self.plot_root_dir_name`` is the name of a folder in the path to ``self.plot_dir``, which is created in ``blacs.plugins.lock_monitor.lockers.LOG_PATH``. + Note that the suggested full path for where to save plots is ``self.plot_dir``, not ``self.plot_root_dir_name``. +* ``self.auto_close_figures`` is used to indicate whether or not methods should close figures after generating them. + This isn't enforced, but should be done, so the user's code should check the value of this property and close any figures generated if it is set to ``True``. + Generally this should be set to ``True`` when the locker class is used with lock monitor so that plots aren't left open, potentially consuming a lot of memory. + Setting this to ``False`` can be useful though when testing/developing locker classes e.g. in a Jupyter notebook + +The most important methods of the ``Locker`` class are the following: + +* ``self.__init__()``: If a user writes an ``__init__()`` method for their locker class, they should make sure that it calls the ``__init__()`` method of ``Locker`` by calling ``super().__init__()``. +* ``self.init()``: Not to be confused with ``__init__()`` (note the presence/lack of double underscores), the ``init()`` method is called by lock monitor when it starts up. + For reasons that will be apparent later on, the code included in the class's ``__init__()`` method will run when the module with the user's locker class is imported. + It is probably not desirable to connect to hardware in the ``__init__()`` method then because then any code that imports that module will immediately open a connection to the hardware. + To work around this, put the code to open connections to hardware in the ``init()`` method of the locker class so that it won't be called when the module is imported but it will still be run by lock monitor. +* ``self.close()``: Lock monitor will call each locker's ``close()`` method when it shuts down. + That makes it the appropriate place to put code to disconnect from hardware and perform any other tasks that should be done when closing. +* ``callback_*``: Callback methods are the methods called as shots are run to check if the laser is in lock. See the docstring of each of those methods to determine at what point in running a shot a given callback method is executed. + * Not all of these methods need to do anything useful; it's perfectly fine to e.g. check the lock status in one callback and have the others do nothing. + * Each callback method should generally return ``True`` except when it detects that the laser is out of lock, in which case it should return ``False``. + Returning ``True`` indicates to lock monitor that it should continue on as normal, while returning ``False`` will cause lock monitor to begin handling the fact that the laser is out of lock. + Unused callbacks, or callbacks which perform some tasks but do not themselves determine whether or not the laser is locked, should just always return ``True``. + * Keep in mind that although the order in which callbacks are run is always the same for every shot, some callbacks may be skipped. + For example, this may happen if the shot is aborted. + Therefore do NOT assume that every callback will run for every shot. + It is good practice to have ``callback_pre_transition_to_buffered()``, which is the first callback run for any shot, do any clean up required if some callbacks from a previous shot were skipped, if that is necessary. +* ``lock()``: When a callback method returns ``False`` to indicate to lock monitor that the laser is out of lock, lock monitor will call the locker's ``lock()`` method. + * If the user's locker class does not support automatic locking, then this method should simply return ``False`` which indicates to lock monitor that the laser is still not in lock. + * If the user's locker class does support automatic locking, then the ``lock()`` method should attempt to lock the laser and return ``True`` if it succeeded or ``False`` otherwise. + Because the code to check the lock status presumably already exists in the ``calback_*()`` methods, it is usually convenient for ``lock()`` to call one or more of those methods after attempting to lock the laser. + As mentioned above, it is very useful for the ``lock()`` method to use ``self.logger`` to log its progress and ideally it should save plots as well, typically in the directory specified by ``self.plot_dir``. + * Before saving any figures, ``lock()`` should call ``self.update_save_time()`` which will set a new timestamp to use when generating the year/month/day/time subdirectories in ``self.plot_dir``. + +To write a custom ``MyLocker`` class, the user should create a class which inherits from ``Locker`` then override some or all of the methods mentioned above. +The methods should be written to implement the behavior described in the docstrings for the corresponding methods of the ``Locker`` class. +The code below shows a typical starting point: + +.. code-block:: python + + from blacs.plugins.lock_monitor.lockers import Locker + + class MyLocker(Locker): + def init(self): + # Do some logging for debugging purposes. + self.logger.info(f"init() called for {self.display_name}.") + + # Do any other desired setup here. + + # init() doesn't need to return anything. + return + + # Create an instance of the MyLocker class which lock monitor will use. Of + # course make sure to provide the actual initialization arugment values. + mylocker_instance = MyLocker() + +Generally it is best to start with ``init()`` and/or ``__init__()`` (make sure to call ``super().__init__()``) and use it to add the ability for the locker class to communicate with hardware. +Next, override one or more of the ``callback_*`` methods to have the locker check if the laser is in lock. +Don't forget to add some logging statements using ``self.logger`` to help with debugging! +It is not necessary to override all of the ``callback_*`` methods; just override as many as you need. +Once that is done, optionally implement a ``lock()`` method so that lock monitor can automatically lock the laser when it goes out of lock. +In addition to logging, you'll likely also want to generate some plots and save them to ``self.plot_dir``, which can be very helpful for debugging as well. +Make sure to close those plots if ``self.auto_close_figures`` is set to ``True``. + +Developing the code for a ``Locker`` class can take a lot of trial and error. +For that reason it is recommended to develop that code in an interactive environment, such as a Jupyter notebook. +When doing so, it can be helpful to set ``auto_close_figures`` to ``False`` so that they appear in the notebook. + + +The ``ScanZoomLocker`` Class +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For convenience, lock monitor also includes a ``ScanZoomLocker`` class which inherits from ``Locker`` but includes some additional template methods for locking to a spectral feature. +Again, the user is in charge of implementing most of the methods to acquire and analyze the required data. + +``ScanZoomLocker`` is designed to lock a laser in analogy with how they are often locked by hand. +Often a user sweeps the laser's frequency and observes some spectrum on an oscilloscope. +The user then looks at the oscilloscope trace and identifies the target feature that they'd like to lock to. +Once the target feature is identified, the user repeatedly reduces the amplitude of the scan and adjusts the scan's offset to center the scan around the target feature. +At some point the user often adjusts the setpoint (aka error offset) as well. +After zooming in sufficiently far, the feedback is enabled to engage the lock. +Lastly the user verifies that the laser successfully locked. + +``ScanZoomLocker`` includes methods which the user fills out to perform each of those steps that are normally performed when locking a laser to s spectral feature manually. +It also includes an implementation of the ``lock()`` method to perform those steps in order. +Of course the user is free to override the ``lock()`` method as well if desired. + +In addition to the attributes inherited from the ``Locker`` class, ``ScanZoomLocker`` has the following notable attributes, many of which are initialization arguments of the class: + +* ``zoom_factor`` sets by what factor the scan range is shrunk by during each iteration. +* ``n_zooms`` sets the number of zooming iterations to perform before enabling the feedback loop to lock the laser. +* ``n_zooms_before_setpoint`` sets the number of zooming iterations to perform before adjusting the setpoint (aka error offset). + * Often the target spectral feature is very narrow in the initial scan, and sometimes it is wider than the final zoom's scan range. + In such cases, performing some of the zooming iterations before adjusting the setpoint makes it possible to examine the target feature and determine the desired setpoint. +* ``initial_scan_amplitude`` sets the amplitude of the scan used at the beginning of the first zooming iteration. +* ``initial_scan_feedforward`` sets the feedforward control value used at the beginning of the first zooming iteration. + +In addition to the methods inherited from the ``Locker`` class, ``ScanZoomLocker`` locker also includes the following template methods which should be implemented by the user: + +* ``check_lock()`` +* ``disable_feedback()`` +* ``enable_feedback()`` +* ``get_feedforward()`` +* ``get_lockpoint_feedforward_value()`` +* ``get_lockpoint_setpoint_value()`` +* ``get_scan_amplitude()`` +* ``get_setpoint()`` +* ``lock()`` +* ``set_feedforward()`` +* ``set_scan_amplitude()`` +* ``set_setpoint()`` +* ``zoom_in_on_lockpoint()`` + +For most of those methods their purpose is clear from their name. +For any that aren't clear, consult the method's docstring for a description of the method's intended behavior. +The code for the ``DummyScanZoomLocker`` class (discussed below) is also a good reference when implementing the methods required by ``ScanZoomLocker``. +The ``zoom_in_on_lockpoint()`` and ``lock()`` methods are already implemented (though the user is welcome to override them as desired), but the user must implement the other methods listed above in order for ``ScanZoomLocker.lock()`` to work. + + +Adding Lockers to Lock Monitor +------------------------------ + +Once your locker is ready for use, lock monitor must be configured to use it. +Lockers are added to lock monitor using its settings interface + +.. image:: images/lock_monitor_settings_screenshot.png + :alt: A screenshot of lock monitor's settings menu. + +To add a locker to lock monitor, follow the steps below: + +#. Create a python module in which an instance of your locker class is created. + * It's typically convenient, but not required, to create the instance of the locker class in the same module in which it is defined. +#. Next, make sure that the python module can be imported from the python interpreter. + * When labscript is installed, the ``pythonlib`` folder in the ``userlib`` directory is added to ``PYTHONPATH``, so that can be a good place to put the module. +#. Add the instance of your locker class to lock monitor's list of lockers. + * To do so, open blacs and select File -> Preferences then navigate to the "Lock Monitor" tab. + Once there add the import path of your locker instance (the instance of your class, NOT the class itself!) to the table and click "OK". +#. Restart blacs for the changes to take effect. + +Following from the ``MyLocker`` example above, suppose that code is included in a file ``my_locker_module.py`` which is placed in the directory ``pythonlib/lockers``. +To add that locker to lock monitor, you would add the import path ``pythonlib.lockers.my_locker_module.mylocker_instance`` in the lock monitor settings tab. +Again note that the import path ends with ``mylocker_instance`` (the instance of the class) and NOT ``MyLocker`` (the class itself). + +After blacs restarts, a new collapsible tool palette should be added to the lock monitor blacs tab which contains the controls for the locker and is labeled by the display name passed during ``mylocker_instance``'s initialization. +If no such tool palette appears, see the troubleshooting section below. + +For convenience, lock monitor also includes some "dummy" locker instances in ``blacs.plugins.lock_monitor.dummy_lockers`` which do not actually control any hardware but can still be added as lockers to lock monitor. +This can be useful for testing/debugging, or even just for seeing how lock monitor behaves when everything works correctly. +The ``DummyLocker`` class in that module is a simple locker which does nothing but randomly pretend to be out of lock on occasion. +It then pretends to lock simply by pausing for a few seconds. +An instance of that class can be added to lock monitor by including the import path ``blacs.plugins.lock_monitor.dummy_lockers.dummy_locker_instance`` in the settings. +The dummy locker module also includes the ``DummyScanZoomLocker`` class, which simulates using the ``ScanZoomLocker`` class. +It also randomly pretends to be out of lock on occasion. +Additionally it pretends to lock by generating simulated data of a spectroscopic signal and zooming in on a dispersive feature. +In the process it produces log messages and plots which can be seen in the log folder. +An instance of that class can be added with the import path ``blacs.plugins.lock_monitor.dummy_lockers.dummy_scan_zoom_locker_instance``. + + +Creating a Logger +----------------- + +Due to the importance of good logging, the ``Locker`` class requires an instance of the ``logging.Logger`` class for one of its initialization arguments. +This will be straightforward for those familiar with Python's ``logging`` module, and an example of how to set up a logger is included below to help other users. +The example sets up a logger that will both print messages to console and record them to a file in labscript's default log directory. +Of course feel free to change any of the options as desired, particularly the name for the log file. + +.. code-block:: python + + # Make the required imports. + import os + import logging + import sys + from blacs.plugins.lock_monitor.lockers import LOG_PATH + + # Get a logger. This is what should be passed to ``Locker`` during + # initialization. + logger = logging.getLogger(__name__) + + # Configure some options for the logger. In particular set it to process + # messages of priority DEBUG or higher, and set the format of the log + # messages so that they include a lot of helpful data such as the time, the + # name of the file, the name of the calling function, and so on. + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter( + '%(asctime)s:%(filename)s:%(funcName)s:%(lineno)d:%(levelname)s: %(message)s' + ) + + # Set up a console handler, which will take the messages sent to the logger + # and print them to the console. It is set to only do this for messages of + # priority level INFO or higher. + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # Set up file handler, which will take the messages sent to the logger and + # record them to a log file called 'my_locker.log'. It will record messages + # of priority DEBUG or higher. The file is opened with mode 'w' which makes + # it overwrite any existing copy of the log file on startup. + full_filename = os.path.join(LOG_PATH, 'my_locker.log') + file_handler = logging.FileHandler(full_filename, mode='w') + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # Make the first log entry. + logger.debug('Logger configured.') + +Once the logger has been configured and a locker has been instantiated with it, it is stored as the locker's ``self.logger`` attribute. +The locker can log messages from within the locker class methods as demonstrated in the example log messages below: + +.. code-block:: python + + from blacs.plugins.lock_monitor.lockers import Locker + + class MyLocker(Locker): + def init(self): + # Logging at the debug level is good for recording tedious details + # or small steps of progress. Python f-strings are very helpful + # here. + self.logger.debug("Starting some small step...") + self.logger.debug("Finished some small step.") + self.logger.debug("Record some very detailed information here...") + + # Logging at the info level is great for keeping track of higher- + # level program flow and recording some useful information, such as + # the feedback loop error signal amplitude. + self.logger.info("Starting some series of steps...") + self.logger.info("Finished some series of steps.") + self.logger.info("Measured some important value to be... ") + + # Logging at the warning level is good for indicating something + # seems wrong. + self.logger.warning("Some signal is starting to get a bit high...") + + # Logging at the error level is good to do when things do go wrong. + self.logger.error("Something has gone wrong; here's some info:...") + + # The logger.exception method can be used in except blocks to record + # the traceback of the error that was caught. + try: + # Something that will throw an error. + raise Exception("Throwing an error for demonstration purposes.") + except Exception: + # Record a message which will automatically also include the + # exception's traceback. + logging.exception("Caught the following exception:") + +As mentioned previously, in addition to logging information it can be very helpful to save plots as well. +The example below includes a method that shows how this can be done. + +.. code-block:: python + + import matplotlib.pyplot as plt + from blacs.plugins.lock_monitor.lockers import Locker + + class MyLocker(Locker): + def save_a_plot(self): + # This example method assumes that some other method has stored the + # data from an oscilloscope trace as the attribute self.scope_trace. + # Another method, likely lock(), should probably call + # self.update_save_time() as well so that a new plot directory is + # created with the appropriate timestamp. + + # Create a figure and axes to plot on. + fig = plt.figure() + axes = fig.add_subplot(111) + + # Plot the data. + axes.plot(self.scope_trace) + + # Add text information so that people know what the plot shows! + axes.set_title("Scope Trace of...") + axes.set_xlabel("Time (arbitrary units)") + axes.set_ylabel("Spectroscopy Signal (Volts)") + + # Save the figure as an image. Of course feel free to change the + # filename to something more appropriate and informative! + filename = os.path.join(self.plot_dir, 'scope_trace.png') + fig.savefig(filename) + + # Lastly, remember to close the figure if configured to do so! + if self.auto_close_figures: + plt.close(fig) + + +Tips +---- + +Below are some tips for setting up and using lock monitor: + +* Test out the dummy lockers included with lock monitor to get a sense of what it's like to use lock monitor before writing any code yourself. + * The dummy lockers don't control any real hardware but can be added to lock monitor for testing/debugging purposes. + * The dummy locker can be added by including the import path ``blacs.plugins.lock_monitor.dummy_lockers.dummy_locker_instance`` in the lock monitor settings menu. + * Similarly the dummy scan/zoom locker can be added by including ``blacs.plugins.lock_monitor.dummy_lockers.dummy_scan_zoom_locker_instance``. + * Once you are done playing around with the dummy lockers, it's best to turn off their monitoring using the control in the blacs tab, or just remove them from the list of lockers and restart blacs. + Otherwise they will continue to occasionally pretend to be out of lock and then pretend to lock themselves, which will slow down data taking. + On rare occasions they may also pretend to fail to lock several times in a row, which will cause lock monitor to give up and pause the blacs queue. +* Read the docstrings of the ``Locker`` and/or ``ScanZoomLocker`` methods when you implement them to understand what they should do. + * The code for the dummy lockers can also be a helpful reference. +* Develop your code for your locker class in an interactive environment, such as a Jupyter notebook, for quick testing and debugging. + Once you have a basic working example, then organize the code into the appropriate locker methods. +* Search the internet for code examples on how to communicate with the hardware you plan to use. + If you are lucky you may even find a python package already exists for communicating with your device. +* Have the ``init()`` method of your locker class reset any hardware to its default state before adjusting settings. + * This ensures that the instrument always starts in the same state, no matter what options someone may have changed since the last time it was used, which makes the code much more robust. +* Many instruments with controls on the instrument itself support a command to "lock" (aka disable) the manual controls. Locking the manual controls during ``init()`` also makes the system more robust, as it makes it less likely that someone will manually adjust some settings that could mess up your locker class's code. + * It can also be helpful to unlock the manual controls during the locker's ``close()`` method to restore manual operation when blacs is closed. +* Things go wrong sometimes, especially when first setting up lock monitor. And when things go wrong, having a log of what happened is invaluable. So make sure to add plenty of logging statements with ``self.logger`` to your locker classes. + * Also don't forget to save plots, typically in the ``self.plot_dir`` directory. + * This is particularly helpful to do during the ``lock()`` method, but can be done at other times as well. +* Sometimes hardware communication can be I/O bound and slow, but threading can often be used to allow your code to run in parallel with the rest of blacs. + * For example, often transferring the data from an oscilloscope can take a while, but the computer spends most of that time sitting idle waiting for the oscilloscope. + If that communication all happens during one callback, blacs will have to sit and wait until it finishes, which will reduce the repetition rate of the system. + Instead it can be quicker to use one callback to start a separate thread which requests data from the scope, then returns so that blacs can carry on. + Then another callback can join the thread and retrieve the data. +* Keep in mind that not all callbacks are run for every shot; some are skipped if shots are aborted for example. + * Make sure your code is robust to callbacks being skipped. + * It can often be a good idea to reset some things, such as attributes/variables that store data, to default empty values during ``callback_pre_transition_to_buffered()`` since that is always the first callback run for any shot. + That ensures that any data from previous runs is cleared to avoid any confusion. + * This is also important to keep in mind if any callbacks are supposed to join threads. +* Avoid sharing hardware between different locker classes. + * The locker callbacks are run in parallel in different processes, so setting up two lockers to talk to the same piece of hardware can cause them to interfere with each other. + * That said, it's not impossible for different lockers to use the same piece of hardware, but be prepared for some debugging! +* Just as with analog and digital outputs in blacs, locker controls can be "locked" to their current state by right clicking on them and selecting "lock". + * When a control is "locked" it ignores left mouse clicks, which can be useful for making sure you don't accidentally disable monitoring or automatic locking. + * A locked control can be unlocked by right clicking on it and selecting "unlock". + + +Troubleshooting +--------------- + +Below are some general troubleshooting tips when things go wrong. + +* Add plenty of logging statements when writing locker classes, and read through the log when things go wrong. + * Including actual values of data in log messages can also make them much more helpful. + * Depending on how the logger is configured, some message levels (e.g. ``DEBUG``) may be recorded in the log file but NOT printed to the console, or vice versa. +* Check blacs's log file as it can contain additional information, including information logged by the lock monitor plugin itself. + * This can be a a good place to look when a locker included in the settings doesn't appear in the lock monitor tab. + * This is a particularly good place to look if lock monitor itself won't start. +* Start blacs from the command line and keep an eye on the text that it prints to that console. + * This information is included in the blacs log as well, but much less information is printed to the console which can make it easier to find the important parts. +* Generate and save relevant figures in your locker's ``lock()`` method. + * If a locker attempts but fails to lock, refer to the saved figures to help determine why. + + +FAQs +---- + + +There is no "Lock Monitor" tab in blacs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +First, ensure lock monitor is enabled by adding the line ``lock_monitor = True`` to the ``[BLACS/plugins]`` section of the labconfig and restart blacs. +If the "Lock Monitor" tab still doesn't appear after that, try starting blacs from the command line and see if any error information is printed. +The blacs log file can also be a good place to check for error messages related to lock monitor. + + +My locker doesn't appear in the lock monitor blacs tab +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Perform the following checks: + +* Ensure that the import path for the locker is added in the settings for lock monitor by clicking "File" -> "Preferences" and navigating to the "Lock Monitor" settings tab. Restart blacs after adding it if it was not already present. + * As a debugging check you may want to try adding one of the dummy lockers mentioned above to ensure that this step works ok. +* Check that the locker can be imported in python. + * Open a python interpreter and type ``import ``, replacing ```` with the string provided to the lock monitor settings menu. If a ``ModuleNotFoundError`` or an ``ImportError`` is raised then you've found your problem. + * If importing your module opens a connection to hardware it may be best to close blacs before performing this test to avoid trying to connect to the hardware twice at the same time. +* Ensure that the import path provided to lock monitor imports an instance of your locker class, NOT the locker class itself. + * For example, if you write a class called ``MyLocker``, then your module should create an instance of the class with ``my_locker_instance = MyLocker(...)``, then the import path should end with ``.my_locker_instance`` and NOT ``MyLocker``. +* Run blacs from the command line to see its console output and/or examine its log files to look for errors. + * If a locker fails to start, it won't be added to the lock monitor blacs tab, but the error raised during its startup should be logged. + + +How do I remove a locker from lock monitor? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Open the settings menu in blacs ("File" -> "Preferences" -> "Lock Monitor") then select the import path that you'd like to remove. +Simply erase all of the text and press enter to delete the entry. +Finally restart blacs for the changes to take effect. diff --git a/blacs/plugins/lock_monitor/__init__.py b/blacs/plugins/lock_monitor/__init__.py new file mode 100644 index 00000000..b2a86353 --- /dev/null +++ b/blacs/plugins/lock_monitor/__init__.py @@ -0,0 +1,2073 @@ +##################################################################### +# # +# /plugins/lock_monitor/__init__.py # +# # +# Copyright 2021, Monash University and contributors # +# # +# This file is part of the program BLACS, in the labscript suite # +# (see http://labscriptsuite.org), and is licensed under the # +# Simplified BSD License. See the license.txt file in the root of # +# the project for the full license. # +# # +##################################################################### + +from concurrent.futures import ThreadPoolExecutor +import importlib +import logging +import os +from queue import Queue +import shutil +import threading + +from blacs.plugins import PLUGINS_DIR +from blacs.tab_base_classes import PluginTab, Worker +import labscript_utils.h5_lock +import h5py # Must be imported after labscript_utils.h5_lock, not before. +from labscript_utils.qtwidgets.digitaloutput import DigitalOutput +from labscript_utils.qtwidgets.outputbox import OutputBox +from labscript_utils.qtwidgets.toolpalette import ToolPaletteGroup +from qtutils import inmain, inmain_decorator, UiLoader +from qtutils.qt.QtCore import Qt +from qtutils.qt.QtGui import QIcon +from qtutils.qt.QtWidgets import ( + QFrame, + QLabel, + QSizePolicy, + QSpacerItem, + QSplitter, + QTableWidgetItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +name = "Lock Monitor" +module = 'lock_monitor' # should be folder name +logger = logging.getLogger('BLACS.plugin.%s' % module) + +# Work around circular import dependency by lazily importing tempfilename from +# blacs.experiment_queue only once it's needed. +tempfilename = None + + +class Plugin(object): + def __init__(self, initial_settings): + logger.info("Plugin.__init__() called.") + # Standard plugin attributes. + self.menu = None + self.notifications = {} + self.BLACS = None + + # Attributes for keeping track of workers. Workers can be uniquely + # identified by their import path. + self.workers = [] + self._worker_by_import_path_dict = {} + + # Workers control settings. The dictionaries below will use import_path + # as a key and the value will be the value of the option for that + # locker. For example, if monitoring is enabled for a locker, then + # self._monitoring_enabled(import_path) will be set to True. + self._monitoring_enabled = {} + self._locking_enabled = {} + self._force_lock = {} + self._restart_worker = {} + + # Threading locks to avoid race conditions. + self._monitoring_enabled_lock = threading.Lock() + self._locking_enabled_lock = threading.Lock() + self._force_lock_lock = threading.Lock() + self._restart_worker_lock = threading.Lock() + + # Attributes related to behavior when laser locking fails. + self._failed_lockers = [] + self._shot_requeued = False + self._lock_failure_messages = [] + + # Make matplotlib use the undisplayed Agg backend so its GUI stuff + # doesn't get messed up by blacs GUI stuff. Do this here instead of at + # the top of the file so that it only runs when a Plugin is + # instantiated. That way it won't run when just importing the module, + # which can e.g. mess up plotting in jupyter notebooks. That would be + # particularly annoying when using a jupyter notebook to develop a + # Locker class as they need to import the locker classes from this + # module. + logger.info("Setting matplotlib to use 'Agg' backend...") + import matplotlib + matplotlib.use('Agg') + logger.info("Set matplotlib to use 'Agg' backend.") + + def _start_locker_inits(self): + """Start running the `init()` method of all lockers. + + The commands to the workers will each be sent from their own thread. + Doing this in separate threads lets the workers, and the rest of blacs, + start up in parallel. The actual work is done by the workers in separate + processes so they do truly run in parallel despite the fact that the + threads sending the command aren't truly in parallel due to the GIL. + + This method starts the threads running then returns. The threads will be + joined later by `_ensure_locker_init_threads_joined()` to make sure that + they finish before any shots are run. + """ + # Start running the init() methods of the lockers. + logger.info("Starting threads to run init() for each locker...") + self._locker_init_threads = [] + # Create a dict for threads to indicate if they caught an error. + self._locker_init_errored = {} + for worker in self.workers: + thread = threading.Thread( + target=self._run_locker_init, + args=(worker,), + daemon=True, + ) + self._locker_init_threads.append((worker, thread)) + thread.start() + logger.info("Finished starting threads to run init() for each locker.") + + def _run_locker_init(self, worker): + """Instruct a worker to run it's locker's init() method. + + This method is designed to be run in its own thread. It will report back + if the locker's `init()` method raised an error by setting the value of + `self._locker_init_errored[worker]`, setting it to `True` if there was + an error or `False` otherwise. + + Args: + worker (LockMonitorWorker): The worker which should run its locker's + `init()` method. + """ + try: + logger.info(f"Starting locker's init() for {worker.import_path}...") + self.run_worker_method(worker, 'locker_init') + self._locker_init_errored[worker] = False + logger.info(f"Finished locker's init() for {worker.import_path}.") + except Exception: + logger.exception( + f"{worker.import_path} locker's init() raised an error." + ) + self._locker_init_errored[worker] = True + + def _ensure_locker_init_threads_joined(self): + """Ensure threads running lockers' `init()` methods have joined. + + `_start_locker_inits()` starts threads to run the `init()` method of + each locker, but doesn't wait for them to finish. Calling this method + ensures that they have finished, which must be done before any shots are + run. + + Admittedly the queue nature of the communication with the workers means + that this method maybe isn't necessary. However, at the very least it + makes it possible to update the blacs status to inform the user that + blacs is waiting on the `init()` methods to finish if they try to run a + shot before they have. + + This method will also mark that the threads have been joined to avoid + needing to do this check in the future. + """ + if self._locker_init_threads is not None: + # Join the threads which were started in this class's __init__() + # method to create the workers. + logger.info("Joining locker init() threads...") + self.set_status("Waiting for laser locker\ninit() methods...") + + # Iterate over each worker, one per import_path. + for worker, thread in self._locker_init_threads: + # Join the thread. + thread.join() + import_path = worker.import_path + logger.debug(f"{import_path} locker init() thread joined.") + + # Set _locker_init_threads to None to signal that they've all been + # joined. + self._locker_init_threads = None + logger.info("Finished joining locker init() threads.") + + def set_status(self, status): + """Set the blacs status message. + + The status message is the text displayed just above the shot queue and + below the pause/repeat/abort controls. + + Args: + status (str): The message to display. + """ + self.queue_manager.set_status(status) + + def pause_queue(self): + """Pause the blacs shot queue.""" + logger.warning("Pausing the experiment queue...") + self.BLACS['experiment_queue'].manager_paused = True + logger.info("Paused the experiment queue...") + + def abort_shot(self): + """Attempt to abort the currently running shot. + + Note that it can be too late to abort a shot, so calling this method + does NOT guarantee that the shot will be aborted. + """ + logger.warning("Aborting the current shot...") + self.queue_manager.current_queue.put( + ['Queue Manager', 'abort'] + ) + logger.info("Abort message sent.") + + def requeue_shot(self, path): + """Put the shot back to the front of the blacs queue. + + This method will clean the hdf5 file as needed then put the clean file + at the front of the shot queue. This method will attempt to overwrite + the file specified by `path`, but if it fails it will create a new file + instead. + + Args: + path (str): The path of to the hdf5 file of the shot to requeue. + """ + # Don't requeue the current shot more than once per attempt at running + # it. + if self._shot_requeued: + logger.info("requeue_shot() called but shot already requeued.") + return + + logger.info("Re-queueing the current shot...") + # Cleaning the hdf5 file isn't always necessary before re-queueing it + # (depends on at which callback the shot was aborted) but doing it + # doesn't hurt. + path = self.clean_h5_file(path) + + # Prepend the shot file to the front of the shot queue. + self.queue_manager.prepend(path) + self._shot_requeued = True + logger.info(f"Re-queued the current shot ({path}).") + + def is_h5_file_dirty(self, path): + """Determine if a shot hdf5 file needs to be cleaned before running it. + + When a shot is run some data is added to its hdf5 file. If the shot + needs to be rerun, then that data needs to be stripped from the file + first. This method checks if the specified hdf5 file has data which + needs to be stripped. + + The criteria for determining if the shot file is dirty is based on the + logic in `blacs.experiment_queue.QueueManager.process_request()`. + + Args: + path (str): The path to the shot's hdf5 file. + + Returns: + is_dirty (bool): Whether or not the shot hdf5 file needs to be + cleaned before it can be run. If `True` then the file needs to + be cleaned; if `False` then the file does not need to be + cleaned. + """ + with h5py.File(path, 'r') as h5_file: + is_dirty = ('data' in h5_file['/']) + logger.debug( + f"Checked if {path} was dirty and got is_dirty = {is_dirty}" + ) + return is_dirty + + def clean_h5_file(self, path): + """Clean a shot's hdf5 file. + + This method removes some data from a shot's hdf5 file that is added when + the shot is run. Removing this is necessary in order to re-run a shot. + The steps taken to do this are based on those taken in the `if + error_condition:` block in `experiment_queue.QueueManager.manage()`. + + This method will attempt to overwrite the file specified by the `path` + argument with a clean copy of the file. If it cannot overwrite that + file, then it will just create a new file and return the path to that + file. Therefore make sure to use the value for `path` returned by this + method after it runs; do NOT assume that the file specified by the + input value of `path` will be clean after this method is run. + + Args: + path (str): The path to the shot's hdf5 file. + + Returns: + path (str): The path to the shot's clean hdf5 file. This ideally is + the same path as provided for the method's argument of the same + name. However the actual path of the clean hdf5 file may be + different if this method fails to overwrite the original. + """ + logger.debug(f"Cleaning shot hdf5 file {path}...") + + # Lazily import tempfilename if it hasn't been imported already. + global tempfilename + if tempfilename is None: + import blacs.experiment_queue + tempfilename = blacs.experiment_queue.tempfilename + + # Determine if this shot was a repeat. + with h5py.File(path, 'r') as h5_file: + repeat_number = h5_file.attrs.get('run repeat', 0) + + # Create a new clean h5 file. + temp_path = tempfilename() + self.queue_manager.clean_h5_file( + path, + temp_path, + repeat_number=repeat_number, + ) + + # Try to overwrite the old shot file with the new clean shot file. + try: + shutil.move(temp_path, path) + logger.debug(f"Successfully cleaned {path}.") + except Exception: + msg = ('Couldn\'t delete failed run file %s, ' % path + + 'another process may be using it. Using alternate ' + 'filename for second attempt.') + logger.warning(msg, exc_info=True) + # Use a different name if necessary. + path = path.replace('.h5', '_retry.h5') + shutil.move(temp_path, path) + + return path + + def create_worker(self, import_path): + """Create a worker for a locker instance. + + The new worker will be appended to `self.workers`. + + If a worker for the locker specified by import path already exists, then + a warning will be issued and the method will return without creating a + new worker. If the worker fails to initialize, the error will be logged + and no new worker will be added to `self.workers`. + + Args: + import_path (str): The import path of the locker instance. Note that + this should be the import path of an instance of a locker class, + not the class itself. + """ + # Ensure this worker hasn't already been created. + if import_path in self.locker_import_paths: + logger.warning( + f"Skipping creation of worker for {import_path} because a " + "worker has already been created for it.", + ) + return + + # This is a new locker, so try to create a worker. + logger.info(f"Creating worker from import path: {import_path}...") + try: + worker = self._create_worker(import_path) + logger.info( + f"Finished creating worker from import path: {import_path}." + ) + except Exception: + logger.exception( + f"Failed to create a worker from import path: {import_path}." + ) + else: + # Keep track of successfully created workers. + self.workers.append(worker) + self._worker_by_import_path_dict[import_path] = worker + + def _create_worker(self, import_path): + # Create the worker. + worker = LockMonitorWorker( + output_redirection_port=self.tab.output_box.port, + ) + # Note that attributes set here are only available in this process; + # methods run with run_worker_methods() won't be able to access their + # values unless the attribute is also set in the worker process, either + # by run(), init(), or a method run with run_worker_method(). + worker.import_path = import_path + + # Start up the worker. Arguments and keyword arguments passed to + # start() are sent along to the worker's run() method. + worker.start( + worker_name=import_path, + device_name=module, + extraargs={}, + ) + + # Run the worker's worker_init() method. Note that this does NOT call + # the init() method of the corresponding locker class; that will be done + # later. + self.run_worker_method( + worker, + 'worker_init', + import_path, + ) + + # Get the worker's display name and store it for use in this process. + display_name = self.run_worker_method( + worker, + 'get_display_name', + ) + worker.display_name = display_name + + return worker + + @property + def locker_import_paths(self): + """The import paths of lockers for which a worker was created.""" + return [worker.import_path for worker in self.workers] + + def run_worker_method(self, worker, method_name, *args, **kwargs): + """Instruct a worker to run one of its methods and retrieve the results. + + Typically the worker is one of the entries in `self.workers`. Note that + the method will run in the worker's process, not this process. + + Args: + worker (LockMonitorWorker): The worker, typically from + `self.workers` which should run the specified method in its + process. + method_name (str): The name of the method which should be run. + *args: Additional arguments are passed to the worker's method. + **kwargs: Additional keyword arguments are passed to the worker's + method. + + Raises: + RuntimeError: A `RuntimeError` is raised if the worker does not + acknowledge that it has received the command to run the method. + RuntimeError: A `RuntimeError` is raised if the worker raises an + error while running the requested method. + + Returns: + results: The results returned by the worker's method are returned by + this method. + """ + # This method is based on blacs.tab_base_classes.Tab.mainloop(). + import_path = worker.import_path + logger.debug( + f"Running method {method_name} of locker {import_path}." + ) + + # Get queues for sending/receiving. + to_worker = worker.to_child + from_worker = worker.from_child + + # Put the instructions into the queue to the worker. + instruction = (method_name, args, kwargs) + to_worker.put(instruction) + + # Get the job acknowledgement from the worker. This just signals that + # the worker got the instruction; it doesn't return the result from + # running the method. + success, message, results = from_worker.get() + if not success: + logger.error( + f"Locker {import_path} worker reported failure to start job " + f"'{method_name}', returned message: '{message}'." + ) + raise RuntimeError(message) + logger.debug( + f"Received '{method_name}' job acknowledgement from worker for " + f"locker {import_path}." + ) + + # Now get the result of running the method. + success, message, results = from_worker.get() + if not success: + logger.error( + f"Locker {import_path} worker failed to run job " + f"'{method_name}', returned message: '{message}'." + ) + raise RuntimeError(message) + logger.debug( + f"Successfully ran method {method_name} of locker {import_path}." + ) + return results + + def _get_worker_by_import_path(self, import_path): + """Get the worker for the locker specified by its import path. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + LockMonitorWorker: The worker created for the locker specified by + `import_path`. + """ + return self._worker_by_import_path_dict[import_path] + + def get_display_name_by_import_path(self, import_path): + """Get the display name of the locker specified by its import path. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + str: The value of the `display_name` attribute of the specified + locker instance. + """ + worker = self._get_worker_by_import_path(import_path) + return worker.display_name + + def get_menu_class(self): + return None + + def get_notification_classes(self): + return [LockFailureNotification] + + def get_setting_classes(self): + return [Setting] + + def get_callbacks(self): + callbacks = { + 'pre_transition_to_buffered': self.callback_pre_transition_to_buffered, + 'science_starting': self.callback_science_starting, + 'science_over': self.callback_science_over, + 'analysis_cancel_send': self.callback_analysis_cancel_send, + 'shot_ignore_repeat': self.callback_shot_ignore_repeat, + } + return callbacks + + def get_monitoring_enabled(self, import_path): + """Get whether or not monitoring is enabled for a locker. + + When monitoring is enabled, the locker's callback methods will be run + when a shot is executed so that it checks whether or not its laser is in + lock. When monitoring is not enabled, the callbacks are not run. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + bool: Will return `True` if monitoring is enabled for the locker and + `False` otherwise. + """ + with self._monitoring_enabled_lock: + return self._monitoring_enabled[import_path] + + def set_monitoring_enabled(self, import_path, enabled): + """Set whether or not monitoring is enabled for a locker. + + When monitoring is enabled, the locker's callback methods will be run + when a shot is executed so that it checks whether or not its laser is in + lock. When monitoring is not enabled, the callbacks are not run. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + enabled (bool): Whether or not monitoring should be enabled. Set to + `True` to enable monitoring or `False` to disable it. + """ + enabled = bool(enabled) + if enabled: + logger.info(f"Enabling monitoring for {import_path}") + else: + logger.info(f"Disabling monitoring for {import_path}") + with self._monitoring_enabled_lock: + self._monitoring_enabled[import_path] = enabled + + def get_locking_enabled(self, import_path): + """Get whether or not automatic locking is enabled for a locker. + + When locking is enabled, the locker's `lock()` method will be called if + the laser is found to be out of lock. If locking is not enabled and the + laser is found to be out of lock, then `lock()` will NOT be called. + Instead lock monitor will abort the shot, pause the queue, and requeue + the shot. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + bool: Will return `True` if locking is enabled for the locker and + `False` otherwise. + """ + with self._locking_enabled_lock: + return self._locking_enabled[import_path] + + def set_locking_enabled(self, import_path, enabled): + """Set whether or not automatic locking is enabled for a locker. + + When locking is enabled, the locker's `lock()` method will be called if + the laser is found to be out of lock. If locking is not enabled and the + laser is found to be out of lock, then `lock()` will NOT be called. + Instead lock monitor will abort the shot, pause the queue, and requeue + the shot. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + enabled (bool): Whether or not automatic locking should be enabled. + Set to `True` to enable automatic locking or `False` to disable + it. + """ + enabled = bool(enabled) + if enabled: + logger.info(f"Enabling locking for {import_path}") + else: + logger.info(f"Disabling locking for {import_path}") + with self._locking_enabled_lock: + self._locking_enabled[import_path] = enabled + + def get_force_lock(self, import_path): + """Get whether or not a locker is set to force lock. + + When force lock is enabled, the locker's `lock()` method will be called + at the beginning of a shot whether or not the laser is determined to be + out of lock. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + bool: Will return `True` if force lock is enabled for the locker and + `False` otherwise. + """ + with self._force_lock_lock: + return self._force_lock[import_path] + + def set_force_lock(self, import_path, enabled): + """Set whether or not a locker is set to force lock. + + When force lock is enabled, the locker's `lock()` method will be called + at the beginning of a shot whether or not the laser is determined to be + out of lock. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + enabled (bool): Whether or not force lock should be enabled. Set to + `True` to force locking or `False` to not force it. + """ + enabled = bool(enabled) + if enabled: + logger.info(f"Flagging to force lock for {import_path}.") + else: + logger.info(f"Flagging NOT to force lock for {import_path}.") + with self._force_lock_lock: + self._force_lock[import_path] = enabled + + def get_restart_worker(self, import_path): + """Get whether or not a locker is set to restart its worker. + + When restart worker is enabled, lock monitor will restart the worker for + the locker at the beginning of the next shot. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + bool: Will return `True` if restart worker is enabled for the locker + and `False` otherwise. + """ + with self._restart_worker_lock: + return self._restart_worker[import_path] + + def set_restart_worker(self, import_path, enabled): + """Set whether or not a locker is set to restart its worker. + + When restart worker is enabled, lock monitor will restart the worker for + the locker at the beginning of the next shot. + + Threading locks are used so it is safe to call this method at any time. + + Args: + import_path (str): The import path of the locker instance. + enabled (bool): Whether or not restart workers should be enabled. + Set to `True` to instruct lock monitor to restart the worker or + `False` to instruct lock monitor to forgo restarting the worker. + """ + enabled = bool(enabled) + if enabled: + logger.info(f"Flagging to restart worker for {import_path}.") + else: + logger.info(f"Flagging NOT to restart worker for {import_path}.") + with self._restart_worker_lock: + self._restart_worker[import_path] = enabled + + def _run_callback_one_locker(self, worker, callback_name, path): + """Run the specified callback of the worker if enabled. + + Note that calling this method does NOT guarantee that the specified + callback is actually run. If the worker's locker has already failed to + lock or if monitoring is disabled for the worker, then the callback is + skipped and this method simply returns `True`. Also, if the worker does + not have the specified callback, then this method just returns `True`. + + Args: + worker (LockMonitorWorker): The worker instance, typically from + `self.workers` for which to run the callback. + callback_name (str): The name of the callback to run. + path (str): The path to the hdf5 file of the currently running shot. + + Returns: + status_ok (bool or str): This method returns `True` if the callback + is not run because the locker has already failed to lock, or + because monitoring is disabled for the worker, or because the + worker does not have a method for the specified callback. If the + worker raises an error while running the callback, this method + returns the string `'error'`. If the worker method is + successfully run, then its result is returned, which should be + `True` or `False` (assuming the user's code follows to API + specified in the documentation). + """ + import_path = worker.import_path + # Don't run callback for any lockers that have failed to lock. + if worker in self._failed_lockers: + logger.debug( + f"Skipping {callback_name}() for {import_path} because it " + "already failed to lock." + ) + return True + + # Don't run callback for any lockers that have their monitoring + # disabled. + if not self.get_monitoring_enabled(import_path): + logger.debug( + f"Skipping {callback_name}() for {import_path} because its " + "monitoring is disabled." + ) + return True + + # Call the locker's method for the specified callback_name. + logger.debug(f"Running {callback_name}() for {import_path}...") + try: + status_ok = self.run_worker_method(worker, callback_name, path) + except Exception: + # If the callback raise an error, return 'error' to inform + # _run_callback_all_lockers() of the issue. + logger.exception( + f"Callback {callback_name} for {import_path} raised an error." + ) + return 'error' + + # Log if the locker doesn't support the callback. + if status_ok == 'callback_not_supported': + logger.debug( + f"Skipped {callback_name}() for {import_path} because it " + "doesn't support that callback." + ) + return True + else: + logger.debug( + f"Finished {callback_name}() for {import_path}, which returned " + f"{status_ok}." + ) + return status_ok + + def _run_callback_all_lockers(self, callback_name, path): + """Run the specified callback for all of the lockers. + + Different threads will be used to run the callbacks so that they can run + in parallel. Using different threads allows us to send commands to each + worker before getting a response from the previous one. Each worker runs + in a separate process, so the callbacks actually run in parallel. + + If a locker indicates that its laser is out of lock and automatic + locking is enabled for that locker, then this method will call + `self.lock_locker()` to attempt to lock the laser. If a laser is not and + cannot be locked, then this method calls + `self._handle_locking_failure()`. + + Args: + callback_name (str): The name of the callback to run. + path (str): The path to the hdf5 file of the currently running shot. + """ + logger.info(f"Calling lockers' {callback_name}()...") + # Send the commands to run the callbacks in different threads. + with ThreadPoolExecutor() as executor: + def map_function(worker): + status_ok = self._run_callback_one_locker( + worker, + callback_name, + path, + ) + return status_ok + statuses = list(executor.map(map_function, self.workers)) + + # Handle any status that isn't ok (indicated by True). + for worker, status_ok in zip(self.workers, statuses): + if status_ok == 'error': + # If a callback raised an error, abort the shot and pause the + # queue without trying to lock. + failure_message = ( + f"{worker.display_name}'s {callback_name} raised an error" + ) + self._handle_locking_failure(worker, path, failure_message) + elif not status_ok: + # Try to lock the laser if set to do so, otherwise give up + # immediately. + if self.get_locking_enabled(worker.import_path): + self.lock_locker(worker, path) + else: + failure_message = ( + f"{worker.display_name} out of lock." + ) + self._handle_locking_failure(worker, path, failure_message) + + self._update_failure_notification() + logger.info(f"Finished calling lockers' {callback_name}().") + + def callback_pre_transition_to_buffered(self, path): + """Call the method of the same name of each locker. + + This is the first callback run for a shot, so this method does some + other steps as well. In particular it makes sure that the `init()` + method of each locker has successfully finished running and it locks any + lasers for which the laser has pressed the "Force Lock" control. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + """ + # This is the first callback when running a shot, so clear any info + # related to lock failures from a previous iteration. + self._failed_lockers = [] + self._shot_requeued = False + self._lock_failure_messages = [] + self.notifications[LockFailureNotification].close() + + # Make sure all the locker init() threads have finished before a shot is + # run. + self._ensure_locker_init_threads_joined() + + # Restart any workers that are set to be restarted. Use a copy of the + # list of workers to avoid editing the original while iterating over it. + for worker in self.workers.copy(): + import_path = worker.import_path + if self.get_restart_worker(import_path): + # Clear the restart worker flag and update the control. + self.set_restart_worker(import_path, False) + self.tab.set_restart_worker_enabled(worker.import_path, False) + logger.info(f"Restarting worker for {import_path}...") + self.set_status( + f"Restarting Worker For\n{worker.display_name}..." + ) + + # Shutdown the worker and remove it from the list of workers. + self._shutdown_worker(worker) + self.workers.remove(worker) + + # Start up a new worker and wait for it to initialize. + self.create_worker(import_path) + worker = self._get_worker_by_import_path(import_path) + self._run_locker_init(worker) + logger.info(f"Restarted worker for {import_path}.") + + # Abort/pause/requeue if monitoring is enabled for any worker that had + # its locker's init() method error out. + for worker in self.workers: + if self.get_monitoring_enabled(worker.import_path): + if self._locker_init_errored[worker]: + failure_message = ( + f"{worker.display_name}'s init() method errored." + ) + self._handle_locking_failure(worker, path, failure_message) + + # Lock immediately if forced to lock. + for worker in self.workers: + if self.get_force_lock(worker.import_path): + # Clear the force lock flag and update the control. + self.set_force_lock(worker.import_path, False) + self.tab.set_force_lock_enabled(worker.import_path, False) + logger.info( + f"Locking {worker.import_path} because user forced it to " + "lock." + ) + self.lock_locker(worker, path) + + # Finally run the callbacks for the lockers. + self._run_callback_all_lockers( + 'callback_pre_transition_to_buffered', + path, + ) + + def callback_science_starting(self, path): + """Call the method of the same name of each locker. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + """ + self._run_callback_all_lockers('callback_science_starting', path) + + def callback_science_over(self, path): + """Call the method of the same name of each locker. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + """ + self._run_callback_all_lockers('callback_science_over', path) + + def callback_analysis_cancel_send(self, path): + """Call the method of the same name of each locker. + + This method also instructs blacs NOT to send a shot's hdf5 file to lyse + if a laser was found to be out of lock. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + """ + self._run_callback_all_lockers('callback_analysis_cancel_send', path) + + # Cancel sending shot to lyse if the shot was requeued. + if self._shot_requeued: + # In this case the shot is going to be reattempted, so skip sending + # the shot file to lyse. + logger.info("Canceled sending shot to lyse.") + return True + else: + # In this case there were no issues with the lockers, so return + # False to allow blacs to send the shot file to lyse. + return False + + def callback_shot_ignore_repeat(self, path): + """Callback to avoid duplicating shots that are requeued. + + This method instructs blacs NOT to use its "repeat shot" function if + lock monitor has already requeued the shot. + + Note that if a laser were to be found to be out of lock at this point, + it would be too late to skip sending the shot to lyse. For that reason + it does not call any locker callback methods. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + """ + # Don't use blacs's "repeat shot" feature if lock monitor already + # requeued the shot. + if self._shot_requeued: + # In this case a locker failed to lock, so return True to avoid + # repeating the shot an additional time. + logger.info("Canceling repeating shot (to avoid duplicating it).") + + # If a shot is requeued during callback_science_over then the hdf5 + # file is cleaned but then dirtied again before it is rerun. This + # callback should be run when that happens though, in which case we + # can re-clean it. So ensure that the file is cleaned here. + if self.is_h5_file_dirty(path): + self.clean_h5_file(path) + return True + else: + # In this case there were no issues with the lockers, so return + # False to allow blacs to repeat the shot if it is set to do so. + return False + + def lock_locker(self, worker, path): + """Lock the laser, trying multiple times if necessary. + + This method calls the `lock()` method of the locker. If laser is + successfully locked, then the shot is aborted and requeued. That is done + so that the sot is still run with the lasers in lock even if they were + found to be out of lock during one of the later callbacks which runs + after the shot is executed. + + If the `lock()` method fails to lock the laser even after a few + attempts, then `self._handle_locking_failure()` is called which will + abort the shot, pause the queue, and requeue the shot. + + This method will connect to blacs's abort button, temporarily enabling + it if necessary. Since interrupting the user-written code in a locker's + `lock()` method is nontrivial to implement and probably a bad idea + anyway, this method actually only aborts between locking attempts. Put + another way, when the abort button is clicked this method will wait for + the current lock attempt to finish, but won't start any new ones. + + Args: + worker (LockMonitorWorker): The worker instance, typically from + `self.workers`, of the laser to lock. + path (str): The path to the hdf5 file of the currently running shot. + """ + # Prepare some stuff for checking if the abort button was clicked. When + # it is clicked, the string 'abort' will be put in the queue below, + # which will signal that no new locking attempts should be started. + abort_queue = Queue() + + def signal_abort(): + abort_queue.put('abort') + inmain( + self.BLACS['ui'].queue_abort_button.clicked.connect, + signal_abort, + ) + # Ensure that the abort button is enabled, recording its current state + # so that it can be restored later. + abort_button_was_enabled = inmain( + self.BLACS['ui'].queue_abort_button.isEnabled, + ) + inmain(self.BLACS['ui'].queue_abort_button.setEnabled, True) + + # Try to lock the laser. + max_attempts = 5 + n_attempt = 1 + is_locked = False + while n_attempt <= max_attempts and (not is_locked): + # Check if the user hit the abort button, and don't start any more + # locking attempts if they did. + if not abort_queue.empty(): + # In this case abort was clicked. + logger.info( + f"Locking {worker.import_path} was aborted by the user." + ) + self.set_status( + f"Aborted Locking {worker.display_name}." + ) + break + + # Log progress and update status indicator in blacs. + logger.info( + f"Locking {worker.import_path}, attempt #{n_attempt}..." + ) + self.set_status( + f"Locking {worker.display_name}\nAttempt #{n_attempt}..." + ) + + # Try to lock the laser. + try: + is_locked = self.run_worker_method(worker, 'lock') + except Exception: + logger.exception( + f"{worker.import_path}'s lock() raised an exception:" + ) + n_attempt += 1 + + # Set how blacs moves forward from this point. + if not is_locked: + # If the laser still isn't locked then abort, pause the queue, and + # requeue the shot. + failure_message = f"{worker.display_name} failed to lock." + self._handle_locking_failure(worker, path, failure_message) + else: + # If the laser was successfully locked, abort and requeue the shot + # but don't pause the queue. This is useful when the laser is found + # to be out of lock after a shot has run, as this re-runs the shot + # now that the laser is locked. + self.set_status(f"Successfully Locked {worker.display_name}.") + self.abort_shot() + self.requeue_shot(path) + + # Disconnect from the abort button and disable it if it was disabled + # before this method ran. + inmain( + self.BLACS['ui'].queue_abort_button.clicked.disconnect, + signal_abort, + ) + inmain( + self.BLACS['ui'].queue_abort_button.setEnabled, + abort_button_was_enabled, + ) + + def _handle_locking_failure(self, worker, path, failure_message): + """Handle when a laser is not locked and cannot be locked. + + This method will abort the shot, pause the queue, then requeue the shot. + + Args: + worker (LockMonitorWorker): The worker instance, typically from + `self.workers`, of the laser to lock. + path (str): The path to the hdf5 file of the currently running shot. + failure_message (str): The message to display as the blacs status + to indicate this locking failure. + """ + logger.info( + f"Handling locking failure of {worker.import_path} with message: " + f"'{failure_message}'." + ) + self.abort_shot() + self.pause_queue() + self.requeue_shot(path) + self._lock_failure_messages.append(failure_message) + self._failed_lockers.append(worker) + + def _update_failure_notification(self): + """Display any lock failure messages in the notification.""" + logger.debug("_update_failure_notification() called.") + if self._lock_failure_messages: + logger.info("Displaying lock failure notification...") + # Make a bulleted list of error messages. + message = '
  • ' + message = message + '
  • '.join(self._lock_failure_messages) + message = message + '
' + + # Set the notification error text and then display it. + self.notifications[LockFailureNotification].error_text = message + self.notifications[LockFailureNotification].show() + logger.info(f"Lock failure messages: {self._lock_failure_messages}") + else: + logger.debug("No failure messages to display.") + + def set_menu_instance(self, menu): + self.menu = menu + + def set_notification_instances(self, notifications): + self.notifications = notifications + + def plugin_setup_complete(self, BLACS): + """Do additional plugin setup after blacs has done more starting up. + + Plugins are initialized early on in blacs's start up. This method is + called later on during blacs's startup once more things, such as the + experiment queue, have been created. Therefore any setup that requires + access to those other parts of blacs must be done here rather than in + the plugin's `__init__()` method. + + Args: + BLACS (dict): A dictionary where the keys are strings and the values + are various parts of `blacs.__main__.BLACS`. For more details on + exactly what is included in that dictionary, examine the code in + `blacs.__main__.BLACS.__init__()` (there this dictionary, as of + this writing, is called `blacs_data`). + """ + logger.info("plugin_setup_complete() called.") + self.BLACS = BLACS + self.queue_manager = self.BLACS['experiment_queue'] + + # Extract settings. + settings = self.BLACS['settings'] + locker_import_paths = settings.get_value(Setting, 'import_paths') + + # Create the workers. + for import_path in locker_import_paths: + self.create_worker(import_path) + + # Set initial values of controls for workers that were successfully + # created, which may be overwritten with saved values later. + for import_path in self.locker_import_paths: + self.set_monitoring_enabled(import_path, True) + self.set_locking_enabled(import_path, True) + self.set_force_lock(import_path, False) + self.set_restart_worker(import_path, False) + + # Start running the init() methods of all of the lockers. + self._start_locker_inits() + + # Update the GUI. + self.tab.add_GUI_widgets() + self.tab.apply_save_data() + + def get_tab_classes(self): + return {'Lock Monitor': LockMonitorTab} + + def tabs_created(self, tabs_dict): + # There is only one tab, so extract it for more convenient access. + self.tabs = tabs_dict + self.tab = list(tabs_dict.values())[0] + + # Give the tab a way to access this Plugin. + self.tab.plugin = self + + def get_save_data(self): + return {} + + def close(self): + """Close the plugin. + + This method will also call the `close()` method of each locker. + """ + logger.info("Shutting down workers...") + with ThreadPoolExecutor() as executor: + executor.map(self._shutdown_worker, self.workers) + logger.info("Finished shutting down workers.") + + def _shutdown_worker(self, worker): + """Shutdown a worker + + This method calls the locker's `close()` method then terminates the + worker process. + + Args: + worker (LockMonitorWorker): The worker instance to close, typically + from `self.workers`. + """ + import_path = worker.import_path + logger.info(f"Shutting down worker for {import_path}...") + try: + # Instruct the worker to run its locker's close() method. Do this in + # a separate thread to allow implementation of a timeout. + logger.debug(f"Calling {import_path}.close()...") + thread = threading.Thread( + target=self._try_locker_close, + args=(worker,), + daemon=True, + ) + thread.start() + thread.join(60) # 60 second timeout. + + # Log if the close() method timed out. + if thread.is_alive(): + logger.error(f"{import_path}.close() timed out.") + + # Now terminate the worker. + logger.debug(f"Calling worker.terminate() for {import_path}...") + worker.terminate(wait_timeout=60) # 60 second timeout. + logger.debug(f"Finished worker.terminate() for {import_path}.") + logger.info(f"Finished shutting down worker for {import_path}.") + except Exception: + logger.exception(f"Failed to shutdown worker for {import_path}.") + + def _try_locker_close(self, worker): + """Run a locker's `close()` method and catch any errors. + + This method is written so that it can be run in a separate thread and + catch any errors that running the locker's `close()` method throws. + + Args: + worker (LockMonitorWorker): The worker instance to close, typically + from `self.workers`. + """ + try: + self.run_worker_method(worker, 'close') + except Exception: + logger.exception( + f"{worker.import_path}'s close() method raise an error." + ) + + +# class Menu(object): + # pass + + +class LockFailureNotification(): + name = 'Lock failure' + + def __init__(self, BLACS): + # Create the notification widget's main structure. + self._ui = QFrame() + self._layout = QVBoxLayout() + self._ui.setLayout(self._layout) + + # Create the child widgets. + self._title = QLabel( + '' + 'Lock Failure:' + ) + self._error_text = QLabel() + + # Add the child widgets to the main notification widget. + self._layout.addWidget(self._title) + self._layout.addWidget(self._error_text) + + def get_widget(self): + return self._ui + + @property + @inmain_decorator(wait_for_return=True) + def error_text(self): + """The message of the error notification.""" + return self._error_text.text() + + @error_text.setter + @inmain_decorator(wait_for_return=False) + def error_text(self, text): + self._error_text.setText(text) + + def get_properties(self): + return {'can_hide': True, 'can_close': True} + + def set_functions(self, show_func, hide_func, close_func, get_state): + self._show = show_func + self._hide = hide_func + self._close = close_func + self._get_state = get_state + + @inmain_decorator(wait_for_return=False) + def show(self): + self._show() + + @inmain_decorator(wait_for_return=False) + def hide(self): + self._hide() + + @inmain_decorator(wait_for_return=False) + def close(self): + self._close() + + @inmain_decorator(wait_for_return=True) + def get_state(self): + self._get_state() + + def get_save_data(self): + return {} + + +class Setting(object): + name = name + _NEW_ENTRY_TEXT = '' + + def __init__(self, data): + logger.info("Setting initialized.") + self.data = data + + # self.data['import_paths'] won't exist if the plugin hasn't been used + # before or if the save data was deleted. In that case set it to an + # empty list. + self.data.setdefault('import_paths', []) + + # Create the page, return the page and an icon to use on the label. + def create_dialog(self, notebook): + """Create the settings tab for this plugin. + + Note that this is the tab in the blacs preferences menu; not the blacs + tab with the controls for the lockers. + + Args: + notebook (labscript_utils.qtwidgets.fingertab.FingerTabWidget): The + notebook of settings tabs. + + Returns: + ui (qtutils.qt.QtWidgets.QWidget): The QT widget for the settings + tab. + icon (NoneType): The icon to use for the tab. As of this writing, + adding an icon is not supported by blacs and so this method + simply returns `None` for the icon. + """ + # Load the ui. + ui_path = os.path.join(PLUGINS_DIR, module, module + '_settings.ui') + self.ui = UiLoader().load(ui_path) + self.table_widget = self.ui.tableWidget + + # Fill out the table with the saved values. Do this before connecting + # the callback below to avoid it interfering. + self._pop_row(0) # Get rid of row in UI file. + for import_path in self.data['import_paths']: + self._append_row(import_path) + self._append_row(self._NEW_ENTRY_TEXT) + + # Connect the callbacks. + self.table_widget.itemChanged.connect(self.on_item_changed) + + return self.ui, None + + @property + def n_rows(self): + """The number of rows in the settings table.""" + return self.table_widget.rowCount() + + def on_item_changed(self, item): + """The callback for when an entry in the settings table is changed. + + This method will sort the entries in the table, remove duplicates, store + the values in `self.data`, and add a new row for the user to type in a + new locker import path. + + Args: + item (qtutils.qt.QtWidgets.QTableWidgetItem): The item which + changed. + """ + # Avoid infinite recursion since this method indirectly calls itself + # because it changes the items in the table. + self.table_widget.itemChanged.disconnect(self.on_item_changed) + try: + self._on_item_changed(item) + except Exception as err: + raise err + finally: + # Make sure to reconnect even if the method above errors. + self.table_widget.itemChanged.connect(self.on_item_changed) + + def _on_item_changed(self, item): + # Rebuild table from scratch rather than rearranging it because it's + # easier. + + # Extract the import paths from the table. Iterate from bottom to top + # removing rows as we go. + import_paths = [] + row_indices = list(range(self.n_rows)) + row_indices.reverse() + for index in row_indices: + # Extract the import path from the row. + import_path = self._pop_row(index) + # Skip if empty or if the default entry text. + if import_path and import_path != self._NEW_ENTRY_TEXT: + import_paths.append(import_path) + + # Remove duplicates and sort the items. + import_paths = sorted(set(import_paths)) + self.data['import_paths'] = import_paths + + # Rebuild table from scratch, including a row for the new entry. + for import_path in import_paths: + self._append_row(import_path) + self._append_row(self._NEW_ENTRY_TEXT) + + def _pop_row(self, row_index): + """Remove a row from `self.table_widget` and return its text. + + Args: + row_index (int): The index of the row of the table. + + Returns: + text (str): The text from the specified row of the table. + """ + item = self.table_widget.item(row_index, 0) + # If the entry doesn't exist, None is returned and this method will then + # just return None as well. + if item is None: + return item + + # Get the text from the row then remove it. + text = item.text() + self.table_widget.removeRow(row_index) + return text + + def _add_row(self, row_index, import_path): + """Add a row to `self.table_widget`. + + Args: + row_index (int): The index specifying where to add the row in the + table. + import_path (str): The import path to display in the row. + """ + row = QTableWidgetItem(import_path) + self.table_widget.insertRow(row_index) + self.table_widget.setItem(row_index, 0, row) + + def _append_row(self, import_path): + """Append a row to `self.table_widget`. + + Args: + import_path (str): The import path to display in the row. + """ + self._add_row(self.n_rows, import_path) + + def get_value(self, name): + if name in self.data: + return self.data[name] + + return None + + def save(self): + logger.info("lock_monitor Setting saving.") + return self.data + + def close(self): + pass + + +class LockMonitorTab(PluginTab): + # Constants for stuff displayed on the top of the tab. + _tab_icon = ':/qtutils/fugue/lock' + _tab_text_colour = 'black' + _TERMINAL_ICON = ':/qtutils/fugue/terminal' + + # Constants specifying icons to display on controls. + _ICON_MONITOR_ENABLE_TRUE = ':/qtutils/fugue/binocular--plus' + _ICON_MONITOR_ENABLE_FALSE = ':/qtutils/fugue/binocular--minus' + _ICON_LOCKING_ENABLE_TRUE = ':/qtutils/fugue/lock--plus' + _ICON_LOCKING_ENABLE_FALSE = ':/qtutils/fugue/lock--minus' + _ICON_FORCE_LOCK = ':/qtutils/fugue/lock' + _ICON_RESTART_WORKER = ':/qtutils/fugue/arrow-circle' + + # Constants specifying text to display on controls + _TEXT_MONITOR_ENABLE_TRUE = "Monitor Enabled" + _TEXT_MONITOR_ENABLE_FALSE = "Monitor Disabled" + _TEXT_LOCKING_ENABLE_TRUE = "Locking Enabled" + _TEXT_LOCKING_ENABLE_FALSE = "Locking Disabled" + _TEXT_FORCE_LOCK = "Force Lock" + _TEXT_RESTART_WORKER = "Restart Worker" + + def __init__(self, *args, **kwargs): + # Attribute to store saved settings that have been loaded from disk. + self._saved_settings = {} + + # Call parent's __init__(). + super().__init__(*args, **kwargs) + + # Attributes that will be set later by the plugin. + self.plugin = None + + @property + def locker_import_paths(self): + """The import paths of lockers for which a worker was created. + + The tab will have locker_import_paths and use them to tell the plugin + which worker to adjust settings for etc. rather than having a list of + the worker instances themselves here. This is to avoid the temptation to + call worker methods directly from the LockMonitorTab class. + """ + return self.plugin.locker_import_paths + + def initialise_GUI(self): + # All of the locker controls will be created later by add_GUI_widgets(), + # which will be run later once the plugin has figured out its + # locker_import_paths and the display names have been retrieved. This + # method prepares the GUI for the control widgets to be added later. + + # Set some attributes to access different parts of the GUI loaded from + # the .ui file. + # self.frame_layout is outermost/highest-level layout of the tab. + self.frame_layout = self._ui.verticalLayout_2 + # self.top_horizontal_layout is the layout at the top with the text that + # says "Locker Monitor [Plugin]" which will also contain the button to + # show/hide the terminal output box. + self.top_horizontal_layout = self._ui.horizontalLayout + # Scroll area will contain all of the controls. + self.scroll_area = self._ui.scrollArea + self.scroll_area_layout = self._ui.device_layout + + # Change how the GUI is laid out to make room for an output box. This + # lays out the tab in a manner that's a bit like a device tab rather + # that a plugin tab. In particular this gives the tab an output box for + # displaying text logged/printed by the workers and it adds a splitter + # that allows adjusting how space is divided between the output box and + # the controls. + + # Create the splitter which allows the user to adjust how space is split + # between the terminal output box and the controls. + self.splitter = QSplitter(Qt.Vertical) + # Avoid letting the splitter collapse things completely when it is + # dragged to the ends of its range, which is the behavior set for device + # tabs. Note that this is overwritten for the scroll area only below. + self.splitter.setChildrenCollapsible(False) + + # Put the splitter in the plugin's frame where the scrollArea is by + # default, then add that scroll area back in as a child of the splitter. + # This makes the tab laid out a bit more like device tabs where the + # terminal output box has a given size and a scroll bar pops up next to + # the the devices controls if there isn't enough room to display them + # all at once. + self.frame_layout.replaceWidget(self.scroll_area, self.splitter) + self.splitter.addWidget(self.scroll_area) + # Allow collapsing the scroll area when splitter is dragged all the way + # to the top, which is the behavior that device tabs have. + self.splitter.setCollapsible(self.splitter.count() - 1, True) + + # Add a toolpalettegroup, which will eventually contain one collapsible + # toolpalette for each locker, to the scroll area. + self.toolpalettegroup_widget = QWidget() + self.toolpalettegroup = ToolPaletteGroup(self.toolpalettegroup_widget) + self.scroll_area_layout.addWidget(self.toolpalettegroup_widget) + + # Add a spacer to fill up any extra blank vertical space in the scroll + # area, otherwise Qt stretches the toolpalettes vertically which doesn't + # look as nice. + spacer_item = QSpacerItem( + 0, # Preferred width. + 0, # Preferred height. + QSizePolicy.Minimum, # Horizontal size policy. + QSizePolicy.MinimumExpanding, # Vertical size policy. + ) + self.scroll_area_layout.addItem(spacer_item) + + # Add the output box which will display the text output from the + # lockers. + self.output_box = OutputBox(self.splitter) + + # Add a button to show/hide the output box. + self.button_show_terminal = QToolButton() + self.button_show_terminal.setIcon(QIcon(self._TERMINAL_ICON)) + self.button_show_terminal.setCheckable(True) + self.button_show_terminal.toggled.connect(self._set_terminal_visible) + self.button_show_terminal.setToolTip( + "Show terminal output from the locker(s)." + ) + self.top_horizontal_layout.addWidget(self.button_show_terminal) + + def _set_terminal_visible(self, visible): + """Set whether or not the output box of text from lockers is displayed. + + This method is based on + `blacs.tab_base_classes.Tab.set_terminal_visible`. + + Args: + visible (bool): Set to `True` to display the terminal output box, or + `False` to hide it. + """ + if visible: + self.output_box.output_textedit.show() + else: + self.output_box.output_textedit.hide() + self.button_show_terminal.setChecked(visible) + + def add_GUI_widgets(self): + """Create all of the controls for each locker.""" + # Start making the controls for all of the lockers. + self.controls = {} + for import_path in self.locker_import_paths: + # Create controls. + monitoring_enable_control = DigitalOutput( + self._TEXT_MONITOR_ENABLE_TRUE, + ) + locking_enable_control = DigitalOutput( + self._TEXT_LOCKING_ENABLE_TRUE, + ) + force_lock_control = DigitalOutput( + self._TEXT_FORCE_LOCK, + ) + restart_worker_control = DigitalOutput( + self._TEXT_RESTART_WORKER, + ) + + # Add icons for controls. + self._update_monitoring_enable_control(monitoring_enable_control) + self._update_locking_enable_control(locking_enable_control) + force_lock_control.setIcon(QIcon(self._ICON_FORCE_LOCK)) + restart_worker_control.setIcon(QIcon(self._ICON_RESTART_WORKER)) + + # Set tooltips for controls. + monitoring_enable_control.setToolTip( + "Toggle whether or not the locker callbacks (which check if " + "the laser is out of lock) are run." + ) + locking_enable_control.setToolTip( + "Toggle whether or not lock monitor will attempt to lock the " + "laser if it is found to be out of lock.\n Monitoring must be " + "enabled for this to have any effect." + ) + force_lock_control.setToolTip( + "Force lock monitor to lock the laser when the next shot is " + "run.\nNote that the locking code won't run until a shot is " + "run." + ) + restart_worker_control.setToolTip( + "Restart the worker process running the locker code when the " + "next shot is run.\nNote that the worker won't be restarted " + "until a shot is run." + ) + + # Connect controls to plugin methods. + monitoring_enable_control.clicked.connect( + self._make_set_monitoring_enabled( + import_path, + monitoring_enable_control, + ) + ) + locking_enable_control.clicked.connect( + self._make_set_locking_enabled( + import_path, + locking_enable_control, + ) + ) + force_lock_control.clicked.connect( + self._make_set_force_lock( + import_path, + force_lock_control, + ) + ) + restart_worker_control.clicked.connect( + self._make_set_restart_worker( + import_path, + restart_worker_control, + ) + ) + + # Store controls in a dictionary of dictionaries, with one + # sub-dictionary for each laser. + self.controls[import_path] = { + 'monitoring_enable': monitoring_enable_control, + 'locking_enable': locking_enable_control, + 'force_lock': force_lock_control, + 'restart_worker': restart_worker_control, + } + + # Organize the controls, with one collapsible ToolPalette for each + # locker, grouped together in one ToolPaletteGroup. + for import_path, locker_controls in self.controls.items(): + display_name = self.get_display_name_by_import_path(import_path) + toolpalette = self.toolpalettegroup.append_new_palette(display_name) + for locker_control in locker_controls.values(): + toolpalette.addWidget(locker_control, force_relayout=True) + + # Set the icon and text color used at the top of the tab. + self.set_tab_icon_and_colour() + + def get_display_name_by_import_path(self, import_path): + """Get the display name of a locker from its import path. + + Args: + import_path (str): The import path of the locker instance. + + Returns: + display_name (str): The display name of the locker/laser. + """ + return self.plugin.get_display_name_by_import_path(import_path) + + def _make_set_monitoring_enabled(self, import_path, control): + """Create a callback for when the "Monitor Enable" control is clicked. + + For technical reasons associated with namespaces, the callback method is + defined in this method instead of with a lambda function in + `self.add_GUI_widgets()`. + + Args: + import_path (str): The import path of the locker instance. + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control for which to create the callback. + """ + def _set_monitoring_enabled(): + self.plugin.set_monitoring_enabled(import_path, control.state) + self._update_monitoring_enable_control(control) + return _set_monitoring_enabled + + def _make_set_locking_enabled(self, import_path, control): + """Create a callback for when the "Locking Enable" control is clicked. + + For technical reasons associated with namespaces, the callback method is + defined in this method instead of with a lambda function in + `self.add_GUI_widgets()`. + + Args: + import_path (str): The import path of the locker instance. + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control for which to create the callback. + """ + def _set_locking_enabled(): + self.plugin.set_locking_enabled(import_path, control.state) + self._update_locking_enable_control(control) + return _set_locking_enabled + + def _make_set_force_lock(self, import_path, control): + """Create a callback for when the "Force Lock" control is clicked. + + For technical reasons associated with namespaces, the callback method is + defined in this method instead of with a lambda function in + `self.add_GUI_widgets()`. + + Args: + import_path (str): The import path of the locker instance. + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control for which to create the callback. + """ + def _set_force_lock(): + self.plugin.set_force_lock(import_path, control.state) + return _set_force_lock + + def _make_set_restart_worker(self, import_path, control): + """Create a callback for when the "Restart Worker" control is clicked. + + For technical reasons associated with namespaces, the callback method is + defined in this method instead of with a lambda function in + `self.add_GUI_widgets()`. + + Args: + import_path (str): The import path of the locker instance. + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control for which to create the callback. + """ + def _set_restart_worker(): + self.plugin.set_restart_worker(import_path, control.state) + return _set_restart_worker + + @inmain_decorator(wait_for_return=True) + def _update_monitoring_enable_control(self, control): + """Update the monitoring enable control to reflect its current state. + + This method changes the icon and text to reflect the current state of + the control. + + Args: + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control to update, which should be the monitoring enable control + for a locker. + """ + if control.state: + control.setIcon(QIcon(self._ICON_MONITOR_ENABLE_TRUE)) + control.setText(self._TEXT_MONITOR_ENABLE_TRUE) + else: + control.setIcon(QIcon(self._ICON_MONITOR_ENABLE_FALSE)) + control.setText(self._TEXT_MONITOR_ENABLE_FALSE) + + @inmain_decorator(wait_for_return=True) + def _update_locking_enable_control(self, control): + """Update the locking enable control to reflect its current state. + + This method changes the icon and text to reflect the current state of + the control. + + Args: + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control to update, which should be the locking enable control + for a locker. + """ + if control.state: + control.setIcon(QIcon(self._ICON_LOCKING_ENABLE_TRUE)) + control.setText(self._TEXT_LOCKING_ENABLE_TRUE) + else: + control.setIcon(QIcon(self._ICON_LOCKING_ENABLE_FALSE)) + control.setText(self._TEXT_LOCKING_ENABLE_FALSE) + + @inmain_decorator(wait_for_return=True) + def set_force_lock_enabled(self, import_path, enabled): + """Set the state of the force lock control for a locker. + + Args: + import_path (str): The import path of the locker instance. + enabled (bool): The desired state of the control. + """ + # Get the force lock control for the locker. + control = self.controls[import_path]['force_lock'] + # Set its state to the desired value. + control.state = enabled + + @inmain_decorator(wait_for_return=True) + def set_restart_worker_enabled(self, import_path, enabled): + """Set the state of the restart worker control for a locker. + + Args: + import_path (str): The import path of the locker instance. + enabled (bool): The desired state of the control. + """ + # Get the force lock control for the locker. + control = self.controls[import_path]['restart_worker'] + # Set its state to the desired value. + control.state = enabled + + def get_save_data(self): + # Save data for controls as a dictionary of dictionaries, with one + # sub-dictionary for each laser. Each laser's dictionary will store the + # current value of its controls. + plugin = self.plugin + save_data = {} + for import_path in self.locker_import_paths: + locker_data = {} + + # Monitoring enable control. + control = self.controls[import_path]['monitoring_enable'] + is_enabled = plugin.get_monitoring_enabled(import_path) + is_locked = not control.isEnabled() + locker_data['monitoring_enable_state'] = is_enabled + locker_data['monitoring_enable_locked'] = is_locked + + # Locking enable control. + control = self.controls[import_path]['locking_enable'] + is_enabled = plugin.get_locking_enabled(import_path) + is_locked = not control.isEnabled() + locker_data['locking_enable_state'] = is_enabled + locker_data['locking_enable_locked'] = is_locked + + # Force lock control. + control = self.controls[import_path]['force_lock'] + is_locked = not control.isEnabled() + locker_data['force_lock_locked'] = is_locked + + # Restart worker control. + control = self.controls[import_path]['restart_worker'] + is_locked = not control.isEnabled() + locker_data['restart_worker_locked'] = is_locked + + save_data[import_path] = locker_data + + # Store the visibility state of the output box and the position of the + # splitter. Based on blacs.tab_base_classes.Tab.get_builtin_save_data(). + save_data['terminal_visible'] = self.button_show_terminal.isChecked() + save_data['splitter_sizes'] = self.splitter.sizes() + return save_data + + def restore_save_data(self, data): + # Store the saved data for self.apply_save_data(). The settings for the + # controls will be restored there once the plugin has set some necessary + # tab attribute values. + self._saved_settings = data + + # Restore settings for the terminal output box and splitter. Based on + # blacs.tab_base_classes.Tab.restore_builtin_save_data(). + terminal_visible = data.get('terminal_visible', False) + self._set_terminal_visible(terminal_visible) + if 'splitter_sizes' in data: + self.splitter.setSizes(data['splitter_sizes']) + + def apply_save_data(self): + for import_path in self.locker_import_paths: + # Get this laser's saved settings, defaulting to an empty dict if + # they are not available. + locker_data = self._saved_settings.get(import_path, {}) + controls = self.controls[import_path] + + # Monitoring enable control. + control = controls['monitoring_enable'] + # Restore state for monitoring_enable, defaulting to True if its + # value wasn't loaded. + setting = 'monitoring_enable_state' + setting_value = bool(locker_data.get(setting, True)) + control.state = setting_value + self._update_monitoring_enable_control(control) + self.plugin.set_monitoring_enabled(import_path, setting_value) + # Restore whether or not the control is locked, defaulting to False + # if its value wasn't loaded. + setting = 'monitoring_enable_locked' + setting_value = bool(locker_data.get(setting, False)) + self._set_control_locked_state(control, setting_value) + + # Locking enable control. + control = controls['locking_enable'] + # Restore state for locking_enable, defaulting to True if its value + # wasn't loaded. + setting = 'locking_enable_state' + setting_value = bool(locker_data.get(setting, True)) + control.state = setting_value + self._update_locking_enable_control(control) + self.plugin.set_locking_enabled(import_path, setting_value) + # Restore whether or not the control is locked, defaulting to False + # if its value wasn't loaded. + setting = 'locking_enable_locked' + setting_value = bool(locker_data.get(setting, False)) + self._set_control_locked_state(control, setting_value) + + # Force lock control. + control = controls['force_lock'] + # Restore whether or not the control is locked, defaulting to False + # if its value wasn't loaded. + setting = 'force_lock_locked' + setting_value = bool(locker_data.get(setting, False)) + self._set_control_locked_state(control, setting_value) + + # Restart worker control. + control = controls['restart_worker'] + # Restore whether or not the control is locked, defaulting to False + # if its value wasn't loaded. + setting = 'restart_worker_locked' + setting_value = bool(locker_data.get(setting, False)) + self._set_control_locked_state(control, setting_value) + + def _set_control_locked_state(self, control, lock): + """Set whether or not a control is "locked". + + When a control is "locked" it ignores left mouse clicks. This is not the + same meaning of lock as "locking a laser". + + Args: + control (labscript_utils.qtwidgets.digitaloutput.DigitalOutput): The + control for which to set the "locked" state. + lock (bool): Whether or not the control should be locked. Set to + `True` to lock the control so that it ignores inputs that would + change its value. Set to `False` to make it respond to attempts + to change its value. + """ + if lock: + control.lock() + else: + control.unlock() + + def close_tab(self, **kwargs): + self.output_box.shutdown() + return super().close_tab(**kwargs) + + +class LockMonitorWorker(Worker): + def worker_init(self, import_path): + """Initialize the worker. + + Note that this method does NOT call the `init()` method of the locker. + If simply imports the locker instance and requests its `display_name`. + + Args: + import_path (str): The import path of the locker instance. + """ + # Don't call lockers' init() method here. We want this to return asap so + # that the value of display_name can be returned for use in the GUI. The + # plugin will call locker_init() to actually run the init() method of + # the locker later since that method may take a while. + self.import_path = import_path + # Split the import path at the last dot. Everything before that is the + # module and the part after it is the name of the locker. For example + # 'module.submodule.locker_instance' would be split into + # 'module.submodule' and 'locker_instance'. Then module.submodule would + # be imported and self.locker would be set to + # module.submodule.locker_instance. + module_name, locker_name = import_path.rsplit('.', 1) + module = importlib.import_module(module_name) + self.locker = getattr(module, locker_name) + self.display_name = self.locker.get_display_name() + + def get_display_name(self): + """Get the display name of the locker. + + Returns: + str: The display name for the locker. + """ + return self.display_name + + def locker_init(self): + """Call the locker's `init()` method.""" + return self.locker.init() + + def _run_locker_callback(self, callback_name, path): + """Run the specified callback method of the locker. + + Args: + callback_name (str): The name of the callback to run. + path (str): The path to the hdf5 file of the currently running shot. + + Returns: + bool: The value returned by the locker's callback method. + """ + try: + callback = getattr(self.locker, callback_name) + except AttributeError: + return 'callback_not_supported' + else: + return callback(path) + + def callback_pre_transition_to_buffered(self, path): + """Run the locker's `callback_pre_transition_to_buffered()` method. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + + Returns: + bool: The value returned by the locker's callback method. + """ + result = self._run_locker_callback( + 'callback_pre_transition_to_buffered', + path, + ) + return result + + def callback_science_starting(self, path): + """Run the locker's `callback_science_starting()` method. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + + Returns: + bool: The value returned by the locker's callback method. + """ + result = self._run_locker_callback( + 'callback_science_starting', + path, + ) + return result + + def callback_science_over(self, path): + """Run the locker's `callback_science_over()` method. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + + Returns: + bool: The value returned by the locker's callback method. + """ + result = self._run_locker_callback( + 'callback_science_over', + path, + ) + return result + + def callback_analysis_cancel_send(self, path): + """Run the locker's `callback_analysis_cancel_send()` method. + + Args: + path (str): The path to the hdf5 file of the currently running shot. + + Returns: + bool: The value returned by the locker's callback method. + """ + result = self._run_locker_callback( + 'callback_analysis_cancel_send', + path, + ) + return result + + def lock(self): + """Run the locker's `lock()` method. + + Returns: + bool: The value returned by the locker's `lock()` method. That + should be set to `True` if the laser was successfully locked and + `False` otherwise. + """ + return self.locker.lock() + + def close(self): + """Run the locker's `close()` method.""" + self.locker.close() + + +logger.info("Imported lock_monitor.") diff --git a/blacs/plugins/lock_monitor/dummy_lockers.py b/blacs/plugins/lock_monitor/dummy_lockers.py new file mode 100644 index 00000000..f3fac7bf --- /dev/null +++ b/blacs/plugins/lock_monitor/dummy_lockers.py @@ -0,0 +1,411 @@ +##################################################################### +# # +# /plugins/lock_monitor/dummy_lockers.py # +# # +# Copyright 2021, Monash University and contributors # +# # +# This file is part of the program BLACS, in the labscript suite # +# (see http://labscriptsuite.org), and is licensed under the # +# Simplified BSD License. See the license.txt file in the root of # +# the project for the full license. # +# # +##################################################################### +"""Example dummy lockers for testing/development purposes. + +This module contains some classes which can be used with lock monitor but which +do not actually control any hardware. They can be used with lock monitor for +testing and development purposes. In particular, the import paths +`blacs.plugins.lock_monitor.dummy_lockers.dummy_locker_instance` and +`blacs.plugins.lock_monitor.dummy_lockers.dummy_scan_zoom_locker_instance` can +be added in lock monitor's settings. +""" +import logging +import numpy as np +import os +import sys +import time + +import matplotlib.pyplot as plt + +from blacs.plugins.lock_monitor.lockers import LOG_PATH, Locker, ScanZoomLocker + + +class DummyLocker(Locker): + """An example locker class that doesn't actually control anything. + + An instance of this locker class can be added to lock monitor by opening its + settings and adding the import path + `blacs.plugins.lock_monitor.dummy_lockers.dummy_locker_instance`. + + This locker class will randomly pretend its laser is out of lock on occasion + to simulate re-locking a laser. The probability that any given callback will + report that the laser is out of lock is controlled by the + `unlocked_probability` initialization argument. + + When this locker "locks", it really just waits for a few seconds then + returns without having done anything. + """ + + def __init__(self, unlocked_probability, *args, **kwargs): + super().__init__(*args, **kwargs) + self.unlocked_probability = unlocked_probability + + def _get_random_status_ok(self): + # Randomly decide if the dummy laser is in or out of lock + status_ok = (np.random.random() > self.unlocked_probability) + if status_ok: + self.logger.info(f"Randomly chose status_ok is {status_ok}.") + else: + self.logger.warning(f"Randomly chose status_ok is {status_ok}.") + return status_ok + + def init(self): + self.logger.info("init() started...") + # Simulate initialization taking some time. + time.sleep(10) + self.logger.info("init() finished.") + + def callback_pre_transition_to_buffered(self, path): + self.logger.info("callback_pre_transition_to_buffered() called.") + status_ok = self._get_random_status_ok() + return status_ok + + def callback_science_starting(self, path): + self.logger.info("callback_science_starting() called.") + status_ok = self._get_random_status_ok() + return status_ok + + def callback_science_over(self, path): + self.logger.info("callback_science_over() called.") + status_ok = self._get_random_status_ok() + return status_ok + + def callback_analysis_cancel_send(self, path): + self.logger.info("callback_analysis_cancel_send() called.") + status_ok = self._get_random_status_ok() + return status_ok + + def lock(self): + self.logger.info("lock() starting...") + + # Simulate locking taking some time. + self.logger.debug("Simulating taking some time to lock...") + time.sleep(3) + self.logger.debug("Finished simulating taking time to lock.") + + # lock() needs to return whether or not the laser is locked, which will + # again be decided randomly for this dummy laser. + is_locked = self._get_random_status_ok() + self.logger.info("lock() finished.") + return is_locked + + +# Create a dummy locker that doesn't actually control anything, which can be +# used for testing purposes. First create a logger for it. See the readme for +# some discussion on how to set up a logger. This logger has '_dummy_locker' +# appended to its name to distinguish it from the logger for the +# DummyScanZoomLocker created below. +_dummy_logger = logging.getLogger(__name__ + '_dummy_locker') +_dummy_logger.setLevel(logging.DEBUG) +_formatter = logging.Formatter( + '%(asctime)s:%(filename)s:%(funcName)s:%(lineno)d:%(levelname)s: %(message)s' +) +# Set up a console handler. +_console_handler = logging.StreamHandler(sys.stdout) +_console_handler.setLevel(logging.INFO) +_console_handler.setFormatter(_formatter) +_dummy_logger.addHandler(_console_handler) +# Set up file handler. +_full_filename = os.path.join(LOG_PATH, 'dummy_locker.log') +_file_handler = logging.FileHandler(_full_filename, mode='w') +_file_handler.setLevel(logging.DEBUG) +_file_handler.setFormatter(_formatter) +_dummy_logger.addHandler(_file_handler) +# Create the DummyLocker instance which can be added to lock monitor. +dummy_locker_instance = DummyLocker( + unlocked_probability=0.05, + logger=_dummy_logger, + plot_root_dir_name="dummy_locker", + display_name="Dummy Locker", + auto_close_figures=True, +) + + +class DummyScanZoomLocker(ScanZoomLocker): + """An example locker class that doesn't actually control anything. + + An instance of this locker class can be added to lock monitor by opening its + settings and adding the import path + `blacs.plugins.lock_monitor.dummy_lockers.dummy_scan_zoom_locker_instance`. + + This locker class will randomly pretend its laser is out of lock on occasion + to simulate re-locking a laser. The probability that any given callback will + report that the laser is out of lock is controlled by the + `unlocked_probability` initialization argument. + + This dummy locker will simulate the approach taken by `ScanZoomLocker` by + running it on simulated data. It will produce log messages and save plots as + well, which makes it a good reference when implementing a real + `ScanZoomLocker` subclass. + """ + + def __init__(self, unlocked_probability, *args, **kwargs): + super().__init__(*args, **kwargs) + self.unlocked_probability = unlocked_probability + + def init(self): + self.logger.info("init() started...") + # Since this method is usually the best place to connect to hardware, + # we'll create the attributes used for simulating hardware here. + # Variables for storing settings of dummy controls. + self._feedback_enabled = False + self._scan_amplitude = 0 + self._feedforward = 0 + self._setpoint = 0 + + # Variables used for simulating an oscilloscope trace of a spectroscopic + # signal. + self._target_feedforward = 0 + self._target_setpoint = 0 + + # Simulate initialization taking some time. + time.sleep(10) + self.logger.info("init() finished.") + + # Take this method from DummyLocker + _get_random_status_ok = DummyLocker._get_random_status_ok + + def _randomize_target(self): + """Simulate drifts in the laser. + + In particular calling this method simulates a random change in the + free-running frequency of a laser and adding a random offset to its + spectroscopic signal. It does so by changing the + `self._target_feedforward` and `self._target_setpoint` attributes, which + represent the values that `self._feedforward` and `self._setpoint` + should ideally have after zooming in. + + The other code won't access the target values to pretend like we don't + know what the values should be; the target values are only used to + generate simulated data from the system in `self.get_scope_trace()`. + """ + self.logger.debug("Simulating system drift...") + # Pick a target feedforward value randomly between -spread/2 and + # spread/2. + spread = 5 + self._target_feedforward = spread * np.random.random() - spread / 2. + self.logger.debug( + f"Simulated feedforward target is {self._target_feedforward}" + ) + + # Pick a random target setpoint value as well. + spread = 1 + self._target_setpoint = spread * np.random.random() - spread / 2. + self.logger.debug( + f"Simulated setpoint target is {self._target_setpoint}" + ) + self.logger.debug("Finished simulating system drift.") + + def get_scope_trace(self): + """Simulate a scope trace of a spectroscopy signal. + + This scope trace produced simulates a single noisy dispersive feature. + + Returns: + signal (np.array): A simulated 1D array of voltages from an + oscilloscope. + """ + self.logger.info("Simulating a scope trace.") + n_points = 1001 + + # The scan is centered around self._feedforward with amplitude set by + # self._scan_amplitude. + x = np.linspace(-self._scan_amplitude, self._scan_amplitude, n_points) + x = x + self._feedforward + + # Dispersive signal is centered around self._target_feedforward and is + # offset by (self._target_setpoint - self._setpoint). + width = 0.1 + detuning = (x - self._target_feedforward) + normalized_detuning = detuning / width + true_signal = normalized_detuning / (1 + normalized_detuning**2) + true_signal = true_signal + self._target_setpoint - self._setpoint + + # Add some noise to the signal. + noise = 0.01 * np.random.randn(n_points) + signal = true_signal + noise + + return signal + + def callback_pre_transition_to_buffered(self, path): + self.logger.info("callback_pre_transition_to_buffered() called.") + return self.check_lock() + + def callback_science_starting(self, path): + self.logger.info("callback_science_starting() called.") + return self.check_lock() + + def callback_science_over(self, path): + self.logger.info("callback_science_over() called.") + return self.check_lock() + + def callback_analysis_cancel_send(self, path): + self.logger.info("callback_analysis_cancel_send() called.") + return self.check_lock() + + def disable_feedback(self): + self._feedback_enabled = False + + def enable_feedback(self): + self._feedback_enabled = True + + def set_scan_amplitude(self, amplitude): + self._scan_amplitude = amplitude + + def get_scan_amplitude(self): + return self._scan_amplitude + + def set_feedforward(self, feedforward): + self._feedforward = feedforward + + def get_feedforward(self): + return self._feedforward + + def set_setpoint(self, setpoint): + self._setpoint = setpoint + + def get_setpoint(self): + return self._setpoint + + def get_lockpoint_feedforward_value(self): + # Simulate getting a spectroscopic trace from an oscilloscope. + trace = self.get_scope_trace() + + # The lockpoint is halfway between the min and max of the simulated + # signal (neglecting noise) + min_index = np.argmin(trace) + max_index = np.argmax(trace) + lockpoint_index = (min_index + max_index) / 2. + + # Now figure out what feedforward voltage would center the scan around + # lockpoint_index. For real systems this typically requires knowing + # some calibration constants, but those are effectively all equal to one + # for this simulated system. + n_points = len(trace) + scan_min = self._feedforward - self._scan_amplitude + scan_max = self._feedforward + self._scan_amplitude + scan_peak_to_peak = scan_max - scan_min + scan_fraction = lockpoint_index / (n_points - 1) + lockpoint_feedforward = scan_min + scan_fraction * scan_peak_to_peak + + # Log results. + shift = lockpoint_feedforward - self._feedforward + msg = ( + "get_lockpoint_feedforward_value() info: " + f"lockpoint_index={lockpoint_index}, " + f"lockpoint feedforward value={lockpoint_feedforward}, " + f"difference from previous={shift}." + ) + self.logger.info(msg) + + # Plot results. + fig = plt.figure() + fig.suptitle( + f"Scope Traces After {self.n_zoom} Zoom(s)", + fontsize='x-large', + fontweight='bold', + ) + axes = fig.add_subplot(111) + axes.plot(trace, label='Spectroscopy') + # Mark min/max/center of fringe along x-axis. + axes.plot(min_index, 0, marker='o', color='red') + axes.plot(max_index, 0, marker='o', color='red') + axes.plot(lockpoint_index, 0, marker='x', color='red') + axes.set_xlabel("Trace index") + axes.set_ylabel("Error") + axes.legend() + + # Save the figure. + filename = os.path.join( + self.plot_dir, + f'scope_trace_{self.save_time_str}_{self.n_zoom}.png', + ) + fig.savefig( + filename, + bbox_inches='tight', + ) + + # Close the figure if configured to do so. + if self.auto_close_figures: + plt.close(fig) + + return lockpoint_feedforward + + def get_lockpoint_setpoint_value(self): + # Simulate getting a spectroscopic trace from an oscilloscope. + trace = self.get_scope_trace() + + # The lockpoint is halfway between the min and max of the simulated + # signal (neglecting noise). + error_min = np.amin(trace) + error_max = np.amax(trace) + # For real systems this step typically requires knowing some calibration + # constants, but those are effectively all equal to one for this + # simulated system. + lockpoint_setpoint = (error_min + error_max) / 2. + + # Log the results. + msg = ( + f"get_lockpoint_setpoint_value() info: " + f"error_min={error_min}, " + f"error_max={error_max}, " + f"setpoint={lockpoint_setpoint}." + ) + self.logger.info(msg) + + return lockpoint_setpoint + + def lock(self): + # Simulate random drifts in system before simulating locking. + self._randomize_target() + return super().lock() + + def check_lock(self): + # Decide randomly if the laser is in lock. + return self._get_random_status_ok() + + +# Create a dummy locker that doesn't actually control anything, which can be +# used for testing purposes. First create a logger for it. See the readme for +# some discussion on how to set up a logger. This logger has +# '_dummy_scan_zoom_locker' appended to its name to distinguish it from the +# logger for the DummyLocker created above. +_dummy_logger = logging.getLogger(__name__ + '_dummy_scan_zoom_locker') +_dummy_logger.setLevel(logging.DEBUG) +_formatter = logging.Formatter( + '%(asctime)s:%(filename)s:%(funcName)s:%(lineno)d:%(levelname)s: %(message)s' +) +# Set up a console handler. +_console_handler = logging.StreamHandler(sys.stdout) +_console_handler.setLevel(logging.INFO) +_console_handler.setFormatter(_formatter) +_dummy_logger.addHandler(_console_handler) +# Set up file handler. +_full_filename = os.path.join(LOG_PATH, 'dummy_scan_zoom_locker.log') +_file_handler = logging.FileHandler(_full_filename, mode='w') +_file_handler.setLevel(logging.DEBUG) +_file_handler.setFormatter(_formatter) +_dummy_logger.addHandler(_file_handler) +# Create the DummyLocker instance which can be added to lock monitor. +dummy_scan_zoom_locker_instance = DummyScanZoomLocker( + unlocked_probability=0.05, + logger=_dummy_logger, + plot_root_dir_name="dummy_scan_zoom_locker", + display_name="Dummy ScanZoomLocker", + auto_close_figures=True, + zoom_factor=4, + n_zooms=6, + n_zooms_before_setpoint=3, + initial_scan_amplitude=10, + initial_scan_feedforward=0, +) diff --git a/blacs/plugins/lock_monitor/images/lock_monitor_settings_screenshot.png b/blacs/plugins/lock_monitor/images/lock_monitor_settings_screenshot.png new file mode 100644 index 00000000..d758d2c7 Binary files /dev/null and b/blacs/plugins/lock_monitor/images/lock_monitor_settings_screenshot.png differ diff --git a/blacs/plugins/lock_monitor/images/lock_monitor_tab_screenshot.png b/blacs/plugins/lock_monitor/images/lock_monitor_tab_screenshot.png new file mode 100644 index 00000000..60e70877 Binary files /dev/null and b/blacs/plugins/lock_monitor/images/lock_monitor_tab_screenshot.png differ diff --git a/blacs/plugins/lock_monitor/lock_monitor_settings.ui b/blacs/plugins/lock_monitor/lock_monitor_settings.ui new file mode 100644 index 00000000..3e36d081 --- /dev/null +++ b/blacs/plugins/lock_monitor/lock_monitor_settings.ui @@ -0,0 +1,119 @@ + + + Form + + + + 0 + 0 + 844 + 528 + + + + Form + + + + + + + 12 + + + + Lock Monitor + + + + + + + true + + + + + 0 + 0 + 824 + 483 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 7 + + + 7 + + + 7 + + + 7 + + + + + false + + + true + + + 1 + + + true + + + 100 + + + false + + + true + + + false + + + + + Locker Import Path + + + + + <click here to enter> + + + + + + + + + + + + + + + diff --git a/blacs/plugins/lock_monitor/lockers.py b/blacs/plugins/lock_monitor/lockers.py new file mode 100644 index 00000000..08b627a6 --- /dev/null +++ b/blacs/plugins/lock_monitor/lockers.py @@ -0,0 +1,702 @@ +##################################################################### +# # +# /plugins/lock_monitor/lockers.py # +# # +# Copyright 2021, Monash University and contributors # +# # +# This file is part of the program BLACS, in the labscript suite # +# (see http://labscriptsuite.org), and is licensed under the # +# Simplified BSD License. See the license.txt file in the root of # +# the project for the full license. # +# # +##################################################################### +"""Base classes for laser locking classes. + +Classes used with the lock monitor blacs plugin should inherit, directly or +indirectly, from the `Locker` class defined in this module. The `ScanZoomLocker` +class, which inherits from `Locker`, is also provided here. + +Dummy example lockers that don't actually control any hardware are available in +`dummy_lockers.py`. + +For further information on how to use these classes, see the README. +""" +import os +import time + +from labscript_utils.setup_logging import LOG_PATH +# Specify root directory for logging stuff. +LOG_PATH = os.path.join(LOG_PATH, 'lock_monitor') +os.makedirs(LOG_PATH, exist_ok=True) + + +class Locker(): + """A class with minimum attributes/methods for use with lock monitor. + + All locker classes used with the lock monitor blacs plugin should inherit + from this class. Methods that aren't fleshed out here should be fleshed out + in the child classes. + + Args: + logger (:obj:`logging.Logger`): The logger to use, typically retried + using `logging.getLogger` or + `labscript_utils.setup_logging.setup_logging()`. Logging is very + useful for debugging and keeping track of when lasers are locked. It + is strongly recommended that subclasses make use of the logger, e.g. + by calling `self.logger.info("Some log message")`. + plot_root_dir_name (str): The name to use for the directory in which to + save plots. This directory will be in the directory set by + `blacs.plugins.lock_monitor.lockers.LOG_PATH`. Plots shouldn't be + saved directly in this directory; but instead should be saved in the + directory specified by `self.plot_dir`. That which will be a + subdirectory organized by year/month/day. + display_name (str): The display name to use for the laser. See the + docstring of the corresponding property for more information. + auto_close_figures (bool): Whether or not to automatically close figures + that are generated. See the docstring for the corresponding property + for more information. + + Attributes: + logger (:obj:`logging.Logger`): The logger provided during + initialization. + plot_root_dir_name (str): The name to use for the parent directory in + which to store plots, initially set to the initialization argument + of the same name. See that argument's docstring and the docstring + for `self.plot_dir` for more information. + """ + + def __init__(self, logger, plot_root_dir_name, display_name, + auto_close_figures): + # Store initialization parameters. + self.logger = logger + self.__display_name = str(display_name) + self.plot_root_dir_name = plot_root_dir_name + self.__auto_close_figures = bool(auto_close_figures) + + # Other attributes. + self.update_save_time() + + @property + def display_name(self): + """The display name for the laser passed during initialization. + + Among other things, the display name will be used to label the controls + for the locker. + """ + return self.__display_name + + @property + def auto_close_figures(self): + """Whether or not to automatically close figures after generating them. + + It is extremely helpful for debugging purposes to generate and save + plots as the `lock()` method, and possibly other methods, run. It can be + helpful to leave these figures open when testing/developing a locker + class in an interactive python prompt or in a jupyter notebook so that + they can easily be viewed right away. However, leaving figures open when + using the class with lock monitor can lead to many figures being open at + once if `lock()` is called many times, potentially consuming a lot of + memory. + + To get the best of both worlds, plots generated should be either left + open or closed based on the value of the `auto_close_figures` property. + Note that this isn't done automatically; it is up to the subclasses to + ensure that they leave open or close the figures that the generate in + accordance with the value set for `auto_close_figures`. + """ + return self.__auto_close_figures + + @auto_close_figures.setter + def auto_close_figures(self, value): + self.__auto_close_figures = bool(value) + + def update_save_time(self): + """Update `self.save_time` to the current local time.""" + self.save_time = time.localtime() + self.logger.debug("Updated self.save_time.") + + @property + def save_time_str(self): + return time.strftime('%Y%m%d_%H%M%S', self.save_time) + + @property + def plot_dir(self): + """The directory in which to store plots. + + The path will start with the directory specified by + `blacs.plugins.lock_monitor.lockers.LOG_PATH`, followed by + `self.plot_root_dir_name`, then subdirectories for the year, month, and + day, and finally one more subdirectory named after `self.save_time_str`. + To update `self.save_time_str` to the current time (so that a new + directory is used) call `self.update_save_time()`. + + Note that accessing this property will also create this directory if it + does not already exist. + """ + plot_dir = os.path.join( + LOG_PATH, + self.plot_root_dir_name, + time.strftime('%Y', self.save_time), # Year. + time.strftime('%m', self.save_time), # Month. + time.strftime('%d', self.save_time), # Day. + self.save_time_str, + ) + os.makedirs(plot_dir, exist_ok=True) + return plot_dir + + def get_display_name(self): + """Get the display name for the locker. + + Returns: + display_name (str): The value for `display_name` passed during + initialization. + """ + return self.display_name + + def init(self): + """Prepare the LaserLocker. + + This method will be called by the `lock_monitor` plugin when it is + started. Any preparation which isn't done in `__init__()` but needs to + be done before checking the lock status for the first time should be + done here. That can include things like adjusting settings on controls, + function generators, oscilloscopes, and so on. + + Configuring hardware in `init()` rather than `__init__()` can be useful + if you would like to be able to create an instance of this class without + immediately sending commands to reconfigure the hardware. + + It is often best practice to reset instruments to their default + settings, by using the SCPI `'*RST'` command for example, then adjust + the settings as needed. This ensures that all settings, including ones + that aren't explicitly changed, are always set to the same values. + Otherwise changes to parameters not explicitly set here, done manually + or by other software, can change the behavior of the system and cause + difficult-to-debug issues. Additionally, locking the front panel of the + instruments can prevent similar issues caused by changes made by users + manually after this method has run. Along the same lines it is sometimes + also possible to use a software lock to ensure that no other programs + interact with the instruments. For example, `pyvisa` resources can be + opened with `access_mode=pyvisa.constants.AccessModes.exclusive_lock` to + ensure that no other connections are made to the device. + """ + pass + + def callback_pre_transition_to_buffered(self, path): + """This method is called right before transitioning to buffered. + + The `lock_monitor` blacs plugin calls this method from blacs when a shot + is run. The call occurs right before blacs starts transitioning to + buffered, which is when it will prepare all of the hardware for the + upcoming shot. + + This method should be implemented by child classes and should return + either `True` or `False`. If `True` is returned then blacs will continue + as normal after this callback returns. If `False` is returned, then the + `lock_monitor` plugin will attempt relock the laser. Therefore this + method should return `True` if the laser is in lock, or if another + callback will determine whether or not the laser is in lock. This method + should only return `False` if it determines that the laser is indeed out + of lock. + + Args: + path (str): The path to the hdf5 file of the shot currently running. + + Returns: + status_ok (bool): The status of the laser lock. This will be set to + `False` if it is known that the laser needs to be relocked. It + will be set to `True` if the laser is in lock, or if a different + callback will determine whether or not the laser is in lock. + """ + return True + + def callback_science_starting(self, path): + """This method is called right before setting a sequence running. + + The `lock_monitor` blacs plugin calls this method from blacs when a shot + is run. The call occurs right after blacs finishes transitioning to + buffered but right before instructing the master pseudoclock to start + running the sequence. Note that this callback may not be called if a + shot is aborted, so do not rely on it running every time other callbacks + are run. + + This method should be implemented by child classes and should return + either `True` or `False`. If `True` is returned then blacs will continue + as normal after this callback returns. If `False` is returned, then the + `lock_monitor` plugin will attempt relock the laser. Therefore this + method should return `True` if the laser is in lock, or if another + callback will determine whether or not the laser is in lock. This method + should only return `False` if it determines that the laser is indeed out + of lock. + + Args: + path (str): The path to the hdf5 file of the shot currently running. + + Returns: + status_ok (bool): The status of the laser lock. This will be set to + `False` if it is known that the laser needs to be relocked. It + will be set to `True` if the laser is in lock, or if a different + callback will determine whether or not the laser is in lock. + """ + return True + + def callback_science_over(self, path): + """This method is called right after a sequence runs. + + The `lock_monitor` blacs plugin calls this method from blacs when a shot + is run. The call occurs right after the sequence finishes but before + blacs transitions back to manual mode. + + This method should be implemented by child classes and should return + either `True` or `False`. If `True` is returned then blacs will continue + as normal after this callback returns. If `False` is returned, then the + `lock_monitor` plugin will attempt relock the laser. Therefore this + method should return `True` if the laser is in lock, or if another + callback will determine whether or not the laser is in lock. This method + should only return `False` if it determines that the laser is indeed out + of lock. + + Args: + path (str): The path to the hdf5 file of the shot currently running. + + Returns: + status_ok (bool): The status of the laser lock. This will be set to + `False` if it is known that the laser needs to be relocked. It + will be set to `True` if the laser is in lock, or if a different + callback will determine whether or not the laser is in lock. + """ + return True + + def callback_analysis_cancel_send(self, path): + """This method is called right after transitioning to manual. + + The `lock_monitor` blacs plugin calls this method from blacs when a shot + is run. The call occurs right after blacs transitions back to manual + mode but before the shot is sent to lyse. Note that this callback may + not be called if a shot is aborted, so do not rely on it running every + time other callbacks are run. + + This method should be implemented by child classes and should return + either `True` or `False`. If `True` is returned then blacs will continue + as normal after this callback returns. If `False` is returned, then the + `lock_monitor` plugin will attempt relock the laser. Therefore this + method should return `True` if the laser is in lock, or if another + callback will determine whether or not the laser is in lock. This method + should only return `False` if it determines that the laser is indeed out + of lock. + + Args: + path (str): The path to the hdf5 file of the shot currently running. + + Returns: + status_ok (bool): The status of the laser lock. This will be set to + `False` if it is known that the laser needs to be relocked. It + will be set to `True` if the laser is in lock, or if a different + callback will determine whether or not the laser is in lock. + """ + return True + + def lock(self): + """Lock the laser. + + This method should attempt to lock the laser, then return `True` if the + laser was successfully locked or `False` otherwise. If automatic locking + is not supported for a laser, this method should simply return `False` + to indicate that it has not locked the laser. The parent class's version + of this method always returns `False` so it is only necessary to + override this method in child classes if they do support automatic + locking of their laser. + + It is extremely helpful for debugging purposes if this method, or other + methods that it calls, logs its progress and results using + `self.logger`. Additionally, generating and saving figures of signals + measured when locking the laser can also be extremely helpful. It is + recommended to call `self.update_save_time()` then save the figures in + `self.plot_dir` to have the figures from this call to `lock()` stored in + their own automatically-generated directory, though saving the figures + elsewhere is fine. + + If `self.init()` adjusts settings of hardware, it may be wise for + subclasses to call `self.init()` before calling `self.lock()`. Doing so + generally shouldn't be necessary since lock monitor runs `init()` + automatically when it starts. However, explicitly running `init()` again + before locking would override any changes to the settings made manually + or by other software since `init()` was last called, which would make + the code more robust against those kinds of changes. + + Returns: + is_locked (bool): Whether or not the laser was successfully locked. + """ + # Update the self.save_time so that plots from this call to lock() are + # stored in their own directory, assuming they are saved in + # self.plot_dir. + self.update_save_time() + + # Return False by default to indicate that this method has not locked + # the laser. Child classes that support locking their laser should + # override this method then return True or False as indicated in the + # docstring above. + return False + + def close(self): + """Close connections to devices used for locking the laser.""" + pass + + +class ScanZoomLocker(Locker): + """A class for locking a laser by zooming in on a spectroscopic feature. + + This class is designed to lock a laser to a spectroscopic feature in the + manner that it is usually done manually. In particular it attempts to scan + the laser, identify the desired spectroscopic feature in an oscilloscope + trace, then adjust the scan offset and reduce the scan range to zoom in on + that spectroscopic feature. That zooming step is repeated multiple times to + iteratively zoom in on the feature. After a number of zooms set by the + `n_zooms_before_setpoint` initialization argument, the setpoint (aka error + signal offset) is adjusted. Finally after a number of zooming iterations set + by the `n_zooms` initialization argument, the feedback is enabled. + + Due to the wide variety of hardware that can be used, the user must flesh + out the methods defined in this class by adding code to actually communicate + with the hardware. This class can be though of as a template for which the + user must write the code to actually adjust the signals to the requested + values and retrieve the required data back from the hardware. Additionally + the user must write some data analysis code to identify the target + spectroscopic feature and determine which feedforward control signal value + will center the scan around it. Generally methods that aren't fleshed out + here should be fleshed out in the user's classes that inherit from this + class. + + See the parent class's docstring for additional information. + + Args: + logger (:obj:`logging.Logger`): The logger to use, typically retried + using `logging.getLogger` or + `labscript_utils.setup_logging.setup_logging()`. Logging is very + useful for debugging and keeping track of when lasers are locked. It + is strongly recommended that subclasses make use of the logger, e.g. + by calling `self.logger.info("Some log message")`. + plot_root_dir_name (str): The name to use for the directory in which to + save plots. This directory will be in the directory set by + `blacs.plugins.lock_monitor.lockers.LOG_PATH`. Plots shouldn't be + saved directly in this directory; but instead should be saved in the + directory specified by `self.plot_dir`. That which will be a + subdirectory organized by year/month/day. + display_name (str): The name of the laser. Among other things, it will + be used to label the controls for the locker. + auto_close_figures (bool): Whether or not to automatically close figures + that are generated. See the docstring for the corresponding property + for more information. + zoom_factor (float): The factor by which to decrease the scan range for + each zooming iteration during locking. For example, setting it to + `10` will reduce the scan range by a factor of 10 during each + zooming iteration. Making this value very small will mean that many + zooming iterations are required before the scan range becomes small + enough to turn on the feedback and lock the laser successfully. + Making this value too large may result in the desired spectroscopic + feature ending up outside of the scan range (e.g. due to + inaccuracies in determining the feedforward signal value that + centers the scan around the target spectroscopic feature), which + will prohibit the laser from locking to the correct feature. + n_zooms (int): The number of zooming iterations to do when locking. + n_zooms_before_setpoint (int): The number of zooming iterations to + perform before adjusting the setpoint. + initial_scan_amplitude (float): The initial amplitude to use for the + scan for the first zooming iteration. + initial_scan_feedforward (float): The initial feedforward value to + use for the first zooming iteration. + + Attributes: + logger (:obj:`logging.Logger`): The logger provided during + initialization. + plot_root_dir_name (str): The name to use for the parent directory in + which to store plots, initially set to the initialization argument + of the same name. See that argument's docstring and the docstring + for `self.plot_dir` for more information. + zoom_factor (float): The factor by which to decrease the scan range for + each zoom, initially set by the initialization argument of the same + name. + n_zooms (int): The number of zooming iterations to perform before + enabling the feedback, initially set by the initialization argument + of the same name. + n_zooms_before_setpoint (int): The number of zooming iterations to + perform before adjusting the setpoint, initially set by the + initialization argument of the same name. + initial_scan_amplitude (float): The initial amplitude to use for the + scan for the first zooming iteration, initially set by the + initialization argument of the same name. + initial_scan_feedforward (float): The initial feedforward value to + use for the first zooming iteration, initially set by the + initialization argument of the same name. + n_zoom (int): A counter for keeping track of how many zooming iterations + have been done while locking. + """ + + def __init__(self, logger, plot_root_dir_name, display_name, + auto_close_figures, zoom_factor, n_zooms, + n_zooms_before_setpoint, initial_scan_amplitude, + initial_scan_feedforward): + # Call parent's __init__() method. + super().__init__( + logger=logger, + plot_root_dir_name=plot_root_dir_name, + display_name=display_name, + auto_close_figures=auto_close_figures, + ) + + # Store initialization parameters. + self.zoom_factor = zoom_factor + self.n_zooms = n_zooms + self.n_zooms_before_setpoint = n_zooms_before_setpoint + self.initial_scan_amplitude = initial_scan_amplitude + self.initial_scan_feedforward = initial_scan_feedforward + + # Variable for keeping track of how many zooming iterations have been + # done while locking. + self.n_zoom = 0 + + def disable_feedback(self): + """Turn off the lock's feedback.""" + pass + + def enable_feedback(self): + """Turn on the lock's feedback.""" + pass + + def set_scan_amplitude(self, amplitude): + """Set the amplitude of the laser's scan. + + Args: + amplitude (float): The amplitude to set the scan to. + """ + pass + + def get_scan_amplitude(self): + """Get the amplitude of the laser's scan. + + Returns: + amplitude (float): The amplitude of the laser's scan. + """ + pass + + def set_feedforward(self, feedforward): + """Set the feedforward output of the feedback loop. + + The feedforward control is the output that adjusts the laser's frequency + when it is not in lock. It's typically an offset added to the output of + a PID controller. Typically it must be set to within a range of values + in order for the laser to lock when feedback is enabled. + + Args: + feedforward (float): The value to set for the feedforward control. + """ + pass + + def get_feedforward(self): + """Get the feedforward output of the feedback loop. + + The feedforward control is the output that adjusts the laser's frequency + when it is not in lock. It's typically an offset added to the output of + a PID controller. Typically it must be set to within a range of values + in order for the laser to lock when feedback is enabled. + + Returns: + feedforward (float): The current value of the feedforward control. + """ + pass + + def set_setpoint(self, setpoint): + """Set the setpoint of the feedback loop. + + The setpoint control adjusts the offset of the error signal and so is + sometimes referred to as "error offset". It is often, but not always, + set to zero. + + Args: + setpoint (float): The value to set for the setpoint control. + """ + pass + + def get_setpoint(self): + """Get the setpoint of the feedback loop. + + The setpoint control adjusts the offset of the error signal and so is + sometimes referred to as "error offset". It is often, but not always, + set to zero. + + Returns: + setpoint (float): The current value of the setpoint control. + """ + pass + + def get_lockpoint_feedforward_value(self): + """Get the feedforward value that centers the scan around the lockpoint. + + Executing this method will typically require communicating with an + oscilloscope to get a trace of a spectroscopic signal as the laser's + frequency is scanned. This method should get that trace, identify the + target spectroscopic feature, then determine what value the feedforward + control should be set to in order to center the scan around the target + spectroscopic feature, aka lockpoint. + + It is strongly recommended that this method generate a figure showing + the oscilloscope trace with the lockpoint marked. Such figures are + instrumental in debugging when the `lock()` method fails. It is + recommended, but not required, to save the plots in the directory + `self.plot_dir`. Note that this method will generally be called multiple + times for each call to `lock()` so it may be wise to include + `self.n_zoom`, the index of the zooming iteration, in the file name to + avoid overwriting it in subsequent zooming iterations. For example, a + good file name with path would be + `os.path.join(self.plot_dir, f'scope_trace_{self.save_time_str}_{self.n_zoom}.png')`. + Make sure to then close the figure if `self.auto_close_figures` is set + to `True`. + + Additionally, it is recommended to log information using `self.logger`. + For example, a log entry could be + `self.logger.info(f"Lockpoint feedforward is {lock_point_feedforward}")`. + Logging the file name and path of the saved figure may also be helpful. + + Returns: + lockpoint_feedforward (float): The feedforward value that would + center the scan around the lockpoint. + """ + pass + + def get_lockpoint_setpoint_value(self): + """Get the desired value for the setpoint when the laser is locked. + + Executing this method will typically require communicating with an + oscilloscope to get a trace of a spectroscopic signal as the laser's + frequency is scanned. This method should get that trace then determine + what value should be used for the setpoint control. Changing the value + of the setpoint control will effectively shift the spectroscopic signal + up or down without changing its shape. + + The notes in the docstring for `self.get_lockpoint_feedforward_value()` + about the usefulness of logging and saving plots apply here as well. See + that docstring for more information. + + For some setups it may be sufficient to simply always return zero from + this method. For other setups it may be worth communicating with an + oscilloscope to adjust the setpoint to account for drifts in the offset + of the spectroscopic signal. + + Returns: + lockpoint_setpoint (float): The setpoint value to use when locking + the laser. + """ + pass + + def zoom_in_on_lockpoint(self, zoom_factor=None): + """Adjust the scan range and offset to zoom in on the lockpoint. + + Args: + zoom_factor (float, optional): The amount by which to zoom in, which + generally should be a factor larger than one. For example, + setting it to `10` would instruct this method to decrease the + ramp amplitude by a factor of 10. If set to `None`, then + `self.zoom_factor`, which is set during initialization, + will be used. + """ + # Get default value of zoom_factor if necessary. + if zoom_factor is None: + zoom_factor = self.zoom_factor + + # Find the lockpoint. + lockpoint = self.get_lockpoint_feedforward_value() + + # Decrease scan range by zoom_factor and re-center the scan around the + # lockpoint. + new_scan_amplitude = self.get_scan_amplitude() / zoom_factor + self.set_scan_amplitude(new_scan_amplitude) + self.set_feedforward(lockpoint) + + def lock(self): + """Lock the laser. + + This method works by iteratively decreasing the scan amplitude by a + factor of `self.zoom_factor`, each time re-centering the scan around the + lockpoint. After `self.n_zooms_before_setpoint`, the setpoint is + adjusted to the value returned by `self.get_lockpoint_setpoint_value`. + Finally, after a number of iterations set by `self.n_zooms`, the + feedback is enabled to lock the laser. + + If `self.init()` adjusts settings of hardware, it may be wise for + subclasses to call `self.init()` before calling this `lock()` method + (with `super().lock()`). Doing so generally shouldn't be necessary since + lock monitor runs `init()` automatically when it starts. However, + explicitly running `init()` again before locking would override any + changes to the settings made manually or by other software since + `init()` was last called, which would make the code more robust against + those kinds of changes. + + Returns: + is_locked (bool): Whether or not the laser was successfully locked. + """ + self.logger.info("lock() called.") + # Update the save time so that self.plot_dir points to a new directory + # in which to store all of the plots generated during this call to + # lock(). + self.update_save_time() + + # Ensure that the lock is turned off. + self.disable_feedback() + + # Set initial scan parameters. + self.logger.info("Setting initial scan parameters...") + self.set_scan_amplitude(self.initial_scan_amplitude) + self.set_feedforward(self.initial_scan_feedforward) + self.logger.info("Finished setting initial scan parameters.") + + # Zoom in to the lockpoint. + self.n_zoom = 0 + for j in range(self.n_zooms): + # Adjust the setpoint to the necessary value if the correct number + # of zooms have been done. + if j == self.n_zooms_before_setpoint: + self.logger.info("Adjusting setpoint...") + setpoint = self.get_lockpoint_setpoint_value() + self.set_setpoint(setpoint) + self.logger.info("Finished adjusting setpoint.") + self.logger.info(f"Starting zoom iteration #{self.n_zoom}...") + self.zoom_in_on_lockpoint() + self.logger.info(f"Finished zoom iteration #{self.n_zoom}.") + self.n_zoom += 1 + + # Ensure setpoint is adjusted if set to be done after last zoom. + if self.n_zoom == self.n_zooms_before_setpoint: + self.logger.info("Adjusting setpoint...") + setpoint = self.get_lockpoint_setpoint_value() + self.set_setpoint(setpoint) + self.logger.info("Finished adjusting setpoint.") + + # Enable the lock. + self.enable_feedback() + + # Check that locking was successful. + is_locked = self.check_lock() + self.logger.info(f"lock() finished with is_locked={is_locked}.") + + return is_locked + + def check_lock(self): + """Verify that the laser is locked. + + This method should check that the feedback is enabled and that the laser + is actually still in lock. If the laser is in lock, it should return + `True`, and it should return `False` otherwise. + + A typical way to check this is to ensure that the error signal of the + feedback loop is sufficiently small and that the output of the PID + controller is within some range (indicating that the integrator hasn't + wound up and railed due to the laser going out of lock). + + Typically the code required to verify that the laser is in lock already + exists in the `callback_*` methods. Therefore this method often can just + call the required `callback_*` methods. + + Returns: + is_locked (bool): Whether or not the laser is locked. + """ + return False