Source code for experiments.ft_2d_ir

"""
Providing the "FT-2D-IR" experiment. This experiment is an IR pump time
domain experiment. The pump pulse is split into two with a Michelson
interferometer. These pulses spatially overlap while the temporal
overlap is "scanned" with the movable path of the interferometer. During
the measurement, a motorized stage "scans" the temporal overlap with a
user defined frequency and amplitude (see pi_control.py). A probe pulse
is then used to determine the molecular response. To reduce IR pump
light scattering on the detector, a wobbler is used to phase cycle the
pump pulse.

The interferometer has two levels. The upper path is used to trace the
position of the stage. For this, a special counter electronics is used.
The two photodiodes on the upper path of the interferometer detect the
interference of coherent monochromatic light (generally a Helium-Neon
laser at approx. 632 nm). The signal (light) on one photodiode is phase
shifted 90° relative to the other photodiode. The counter registers each
time the sine wave-shaped interference resulting from the stage motion
passes a threshold. Depending on which of the two photodiodes passed
their threshold first, the counter counts up or down. By measuring the
stage positions this way it is possible to obtain a resolution of the
wavelength of the light source which is used (here He-Ne = 632.8 nm).
The bottom path is used for the IR pump light. Here a pyroelectric
detector is used for the detection of the interferogram that results
from the two broadband IR pump pulses. This interferogram is needed to
cut off the time domain data at the point where the movable pulse
arrives after the static pulse and for the phase correction of the
Fourier Transformed (frequency domain) data.


Note:

    **Adjusting/Alignment of interferometer:**

        *The basic advice for the overall alignment is: Everything
        should* *go into and out of the interferometer in a
        straight/parallel way.* *Make everything right angled.*

        The method of alignment as described in the report *"A
        straightforward approach to FT2D-IT spectroscopy in the
        pump-probe geometry" by J.S. Engler* does not yield a good
        result. 

        For this reason it is recommended to use the following method of
        alignment:
            
        1.  It is necessary to have a coherent light source (such as a
            He-Ne laser) which has a high and stable intensity. Due to
            the special beamsplitter used in the bottom interferometer
            path (IR path), it can be difficult to observe good
            interference in a subsequent step of the alignment. A good
            laser diode which costs a couple of euros can work too.

        2.  Use (only!) three poles of same, and preferably setup
            adequate, height and screw them to the interferometer
            baseplate. The more identical the height of the poles are,
            the better. Do *not* use four poles. If even one of the
            poles has a slightly different height, this can warp the
            baseplate of the interferometer. This torsion will be
            dependent on the torque applied to the screws that clamp the
            interferometer to the table. (In 3D space three points will
            always form a plane, ergo use only 3 posts.) To dampen
            vibrations (e.g. door slamming, heavy footsteps, etc.) it is
            advisable to use rubber material at the foot of the poles.
            Use a ring puncher to create rings of appropriate size (for
            underneath the feet of the poles *and* for underneath the
            clamps which are used to fix the poles to the table). When
            tightening down the clamps (try to position them under the
            interferometer to save space) less force is required than
            expected. We observed that screwing until the rubber bends
            upwards (due to clamp pressure) is already too much. If the
            interferometer is screwed on too tightly the rubber deforms
            changing the effective height of a pole, this can cause the
            alignment to change. Additionally, it is advisable to
            decouple the table from the floor to dampen lower
            frequencies. To position the interferometer parallel to the
            holes in the table, aluminum rails (extrusions) can be used.
            These are typically standardized and have precise dimensions
            (precise enough). Put a few screws (their heads need to have
            the same diameter!) into holes the in direction or line
            along which you want to position the interferometer. Now use
            clamps to fix the aluminum extrusion against these screws.
            Now you have a straight reference line parallel to the
            screws and holes of the table. Push the interferometer
            against the extrusion while fastening it to the table.

        3.  Place two irises in a straight line and the same height on
            the table. Make sure the height is at the center of the
            bottom beam splitter. The irises should have a distance of
            at least 1.5 m. Make sure there is enough space in between
            or behind for the interferometer. The irises are going to be
            used to align the alignment beam parallel to the table. Now
            align the beam on to the irises using two mirrors. When
            starting to walk the beam start by overshooting for faster
            convergence. Only for the fine-tuning align onto the
            apertures. Another important factor for convergence is the
            distance of the mirrors relative to each other and to the
            irises. The distance between the second mirror and the first
            iris has to be smaller than the distance between the first
            and the second mirror. Additional information and a good
            guide on walking the beam can be found in *"Understanding
            Walking the Beam" by S. Agha and D. Minkin Stony Brook Laser
            Teaching Center (July 2007)*. To see whether the beam
            pointing is correct, slowly close the iris and observe if
            the beam illuminates one side of the iris earlier at one
            side than the other. If the illumination is symmetric, the
            beam is properly aligned. Furthermore, it is possible to use
            a piece of paper behind the iris. It might help if the beam
            is being cutoff asymmetrically. Repeat these steps until the
            beam moves through the center of the irises. It is also
            advisable to repeat this adjustment every day.
        
        4.  The first component to place onto the empty interferometer
            base plate is the first beamsplitter. The angle between the
            incident and the reflected beam should be 90°. Make sure the
            beam is parallel to the table and does not change its
            height. (Even over a distance of several meters.)

        5.  Place the movable path of the interferometer onto the
            baseplate. The movable path consists of the PI stage, a
            stage baseplate, and a mirror mount. First, screw the stage
            to the interferometer baseplate. Now the stage base plate
            needs to be placed on top of the stage. This step is
            important because it can determine the maximal coherence
            time which can be scanned with the interferometer stage. If
            the movable mirrors cannot move far enough in the right
            direction, the coherence time is too small for practical
            use. We noticed that the temporal overlap is relatively far
            back on the stage if the stage base plate is placed flush
            with the stage. For this, it is again advisable to use an
            aluminum rail (get help from a second person since this step
            is not easy to perform alone). When screwing down the stage
            baseplate, check the maximum torque which is allowed for the
            specific stage that you are using in the datasheet. (It is
            basically no force at all.) To prevent unnecessary point
            load, it is advisable to use washers. Be careful when moving
            the stage around. It is advisable to use some tape to secure
            it when does not need to be moved (e.g.: for most of the
            alignment operations on the interferometer).
        
        6.  Screw the mirror mount onto the stage base plate. Use screws
            that have proper length. A screw that is too long may result
            in damage to the stage itself. Once the mirror mount is
            attached, make sure the mirrors are positioned low enough
            within the mount. If the mirrors are placed too high, the
            lower half of the beam that is reflected off the first beam
            splitter might be cut off.
        
        7.  Align the second mirror of the movable path (the first
            mirror is not adjustable) such that the reflected beam is
            parallel to the incident beam. This implies that the angle
            between the two mirrors is perfect 90°. Technically when the
            stage moves back and forth (use PIMikroMove software to move
            the stage) the beam pointing should not change if it was
            aligned correctly. However, we noticed that the beam moved
            up and down when moving the stage back and forth. In our
            case, the beam moved up and down the same extent at a short
            distance (e.g. 3 cm) and a long distance (e.g. 5m). This
            indicates that the movement of the stage itself is not
            perfectly level and that, nevertheless, the alignment is
            correct. This effect is negligible but can confuse.
            We suspect that the "bend" might come from a bad
            interferometer baseplate or too much weight on the stage
            (this should be checked). For further adjustment the
            interferometer stage does not have to be moved back and
            forth anymore.

        8.  Place the second beamsplitter into the interferometer.
            Again, make sure the incident and reflected beam are at a
            right angle. For this, the interferometer either needs to be
            rotated or a second alignment path has to be constructed
            (i.e. via a folding mirror). The beam again has to go out of
            the beamsplitter in a 90° angle and the beam pointing still
            has to stay the same at different distances
        
        9.  Place the mirror tower (four mirrors) which is the static
            path of the interferometer onto the interferometer
            baseplate. Now the interference has to be aligned. First,
            try to position the mirror tower approximately such that the
            two beams overlap. Place alignment screws into the four
            screw holes of the tower. This ensures that the interference
            does not get destroyed when pulling out a screwdriver etc.
            (since these screws stay inside). If you don't have the
            screws, buy them and postpone the further alignment until
            they have arrived. From this point onwards it is advisable
            to use a quadrant diode (A beam block can also be used.
            Nevertheless, the interference is significantly better with
            the quadrant diode). Check the specification of your diode
            and what inputs do what (electronics datasheet). In /utils a
            program "quadrant_diode.py" is provided to use the diode.
            However, an analog to digital converter is required.
            Alternatively, an oscilloscope displaying in xy-mode can be
            used. But this is only going to work if the light intensity
            is high enough, as otherwise averaging needs to be done.
            Place the quadrant diode directly behind the second
            beamsplitter, block only the static path, and adjust the
            diode itself such that the beam is close to (0,0) (zoom in
            for higher precision). Reset the position with the provided
            button. Unblock the static path and block the movable path.
            Use the first mirror of the static path to align the beam to
            (0,0). Place the quadrant diode further away. Now repeat the
            procedure, but use the second mirror of the static arm to
            align onto the (0,0). During the first few iterations, it is
            advisable to overshoot when the quadrant diode is in the
            front position. After some iterations, the second position
            will converge automatically, if everything is done
            correctly. In the last iterations, the interference (at the
            distant position) needs to be optimized by eye. Two people
            may be needed. Place the dots next to each other with slight
            overlap, using the second mirror of the static path. Slowly
            move the dot down until they are at the same height. A good
            indicator is the interference. The interference lines will
            be more straight and vertical (not bend anymore) if the dots
            have the same height. When the same height is set, slowly
            move one dot over the other. There is a point at which the
            interference lines change their orientation. This point
            needs to be reached. Then, interference is maximal. We
            observed that the dot basically has two states, bright and
            dark, if the maximal interference is reached. By carefully
            tapping on the stage the switch between dark and bright can
            be observed. Note that maximal interference is not reached
            with just a thick black line in the dot. Adjust the
            interference for both levels.
        
        10. Place the tower with the detectors (photodiodes,
            pyroelectric detector) onto the interferometer baseplate.
            Try to clamp down the cables in such a way the no force is
            transferred to the interferometer when someone pulls on
            them. It is not necessary to glue the detectors in place.
            
        11. Place the spherical mirrors onto the interferometer
            baseplate. Align the respective beam onto the detectors
            using the spherical mirrors. The beam should fall on the
            center of the detectors. If the interferogram "saturates" a
            beamsplitter can be placed in front of the pyroelectric
            detector.
        
        12. Place the lambda quarter plate between the second mirror of
            the static path (top level) and the second beamsplitter. The
            beam should pass through the middle. Now the He-Ne (or
            different light source) intensity needs to be optimized. For
            that, filters can be used. Turn on the X-Y display setting
            on the oscilloscope and move the stage via PIMikroMove. The
            amplitude of the photodiodes should be 4 V. At the same
            time, make sure the photodiodes don't saturate. If you see
            an oval form it is necessary to turn the lambda quarter
            plate such that a circle is observed. The circle should also
            touch the X- and Y-axis. This indicates that the
            interference is optimal because the destructive interference
            is at 100%. If the intensity and the polarization are not
            adjusted correctly, the counter will fail. This step should
            be repeated and checked every once in a while (or when the
            counter fails). The counter can also fail because the light
            source which is sent into the interferometer is misaligned
            (i.e. due to pulling the cables). Align the light source
            properly back into the interferometer. The circle can be
            used to optimize this.

        13. If the spatial overlap of the IR path (bottom level) has to
            be readjusted, the screw of the second mirror of the static
            path can be used. One should be careful not to destroy the
            interference.

    **Pyroelectric Detector and Counter Electronics:**

        The counter electronics caused problems at the in and outputs of
        the pyroelectric detector. We advise shielding the cables going
        in and out of the **pins** for the pyroelectric detector. The
        shielding should be connected to ground. Note that the
        pyroelectric detector itself should be connected with two
        separate BNC cables (or something equivalent). Else the
        pyroelectric detector is not properly shielded. This is done
        incorrectly in the H-Lab. Additionally, it is recommended to
        block ambient light (from halogen lamps, etc.) to reduce noise on
        the photodiodes.

        The photodiodes need to have enough intensity and the correct
        polarization (see step 12 of interferometer alignment). If the
        counter fails it can also be that the light source is not
        aligned properly into the interferometer. 

        For a more explicit explanation of the counter algorithms see
        *interferometer_counter.py*

    **R-2R (Resistor-2xResistor Network):**

        The R-2R is used to "compress" the 16 (parallel) outputs of the
        counter electronics to only four outputs, mainly to save analog
        inputs on the ADC. There are a few characteristics that are
        necessary for a well designed R-2R network. The R-2R used in the
        previous implementation was not correctly dimensioned and prone
        to error (the curve was not monotonically increasing). The
        resistances in the old version allowed more current to flow than
        the counter electronics could provide resulting in the voltage
        dropping. The key is to use resistors in the mega Ohm range. It
        is also advisable to use precise resistors. The high resistance
        makes current flow impossible. We observed that the output of
        the interferometer counter would not set the voltages correctly
        if current could flow. Behind the R-2R, voltage followers need
        to be used because the ADC needs a certain amount of current to
        be able to measure voltages in the first place. To check if the
        R-2R is working properly see */utils testing_r2r_with_adc.py*
        and *testing_r2r_measure_voltages.py*. The resulting output of
        the R-2R should always yield a straight line, reproducible for
        any R-2R build with the same circuit plan.  

    **Interferogram:**

        The interferogram is an essential part of the time domain
        experiments. It has to be set up precisely (see *step 9* of
        interferometer alignment). It is also necessary to shield the
        pyroelectric detector and the cables within the box of the
        counter electronics to reduce noise. We suspect that the noise
        comes from the laser trigger not being galvanically isolated
        from the electronics. The noise may also result from inductance
        caused by the switch within the PCB which is used to trigger the
        pyroelectric detector. It may be advisable to redesign the
        pyroelectrical amplifier for a better result. Another noise
        source is the power supply. Make sure to use a good and stable
        power supply. To get a clean interferogram approximately 120s
        acquisition time was set with a 1kHz laser system. If the
        interferogram saturates it is necessary to attenuate some IR
        light e.g. with a beamsplitter or filter in front of the
        pyroelectric detector.

    **Wavegeneration parameters:**

        Setting up and optimizing the wavegeneration can be tricky. The
        wavegeneration is used to move the stage back and forth
        continuously. If this movement is set up incorrectly the
        algorithms will not be able to compute the frequency domain
        spectrum. However, the experiment is designed in such a way that
        it will always display the interferogram, the OPA spectrum, how
        many counts were counted for each position of the
        interferometer. To start the wavegeneration, 4 parameters need
        to be specified: frequency, amplitude, speedupdown, and
        overshoot factor. What these parameters are needed for looked up
        within the GUI (tooltips) and also in pi_control.py. The
        wavegeneration should be set up in a way that every
        interferometer position/bin is observed several times. Else the
        algorithm will not average properly and raise a "weights sum to
        zero" error. If a position was not filled/hit, it can be
        observed in the bottom middle graph. There would be spikes going
        down to zero. If set correctly no zero would be observed. When
        running properly, there might be an underlying frequency
        (looking like a wave) which can be seen in the graph. Using a
        laser that pulses with a certain frequency and a stage that is
        moving at a certain frequency results in a beat. The experiment
        still works as long as the positions are "roughly" equally
        filled/hit. The solution to a proper wavegeneration lies in a
        combination of the parameters. For instance, the overshoot
        should be chosen in such a way, that every bin is recorded but
        is also as small as possible (to not waste laser shots). We
        observed that a loss of approx. 3% of laser shots can be
        achieved (for certain less is possible). A well-working setting
        was: coherence time = 2ps, frequency = 0.4 Hz, speedupdown =
        150, overshoot factor = 0.1. The rightmost plot in the bottom
        displays the interferometer stage position for a given laser
        shot. One would assume that a sinewave/ramp etc. should be
        observed. There are "spikes" that go up to 32767 which can be
        observed when everything is running as intended. They are a
        result of an underflow (For more explanation see *ft_2d_ir.py*
        code comments).

    **Counts:**

        When the interferometer stage moves beyond the
        calculated/predicted coherence time limits (due to imprecision
        and overshoot) the sort_data function (data_processing.py) sorts
        these states into the last available state. See comments for
        further explanation.

#######################
Step by Step Algorithm:
#######################

**Acquisition:**

    1.  Preallocate dictionary (data container) which will contain data
        and information about scan index, delay index etc.
    2.  Move the interferometer stage to the position where the 
        interferometer counter needs to be reset
        (this is a position a few hundred femto seconds after the 
        t zero, but not as far as the starting position of the 
        wavegeneration)
    3.  Reset the interferometer counter value to zero
    4.  Move the interferometer stage to the starting position of
        the wavegeneration
    5.  Start wavegeneration
    6.  Move IR delay stage to IR delay(s)
    7.  Read the data from the ADC
    8.  Place data into dictionary and hand it over to
        primary processing

**Primary Processing:**

    1.  Preallocate arrays for data, counts, weights. 
    2.  Calculate the number of wobbler states: there are generally 4
        different wobbler states (left, center, right, center position 
        of wobbler)
    3.  Subtract background from raw data (dark noise)
    4.  Linearize response of pixels
    5.  Calculate transmission, or more precisely, relative intensity
        (probe intensity / reference intensity) for each laser shot for
        each pixel pair
    6.  Calculate interferometer positions from R-2R data for each laser
        shot. Check if minimum and maximum interferometer positions were
        observed.
    7.  Identify the different wobbler states for all laser shots
    8.  Sort the data (transmissions) for each state and calculate 
        statistics
    9.  Phase cycle by averaging the wobbler states in the transmission 
        space. Calculate the resulting phase cycled counts and weights
    10. Average the phase cycled data by weighting equally. If averaging
        fails because not all states were observed ignore exception so
        that the graphs still will be displayed later
    11. Put this information into data container and hand it over to
        secondary processing
    12. Save data (and raw data) including counts and weights
        if respective checkboxes on GUI were checked.

**Secondary Processing:**

    1.  Preallocate arrays and variables to hold time domain 2d 
        spectrum, frequency domain 2d spectrum, zerobin, pump frequency
        axis, opa range, central pump wavenumber, opa spectrum and
        interpolated phase cycled signal
    2.  Obtain the interferogram from the sorted data
    3.  Calculate time domain 2D absorption spectrum
    4.  Process single scan data. Calculate frequency 
        domain 2D absorption spectrum
    5.  Extract the relevant information regarding the spectrum 
        (from single scan data) of the OPA
    6.  Obtain pump axis (single scan data)
    7.  Process averaged data to obtain average frequency domain data
    8.  Extract the relevant information regarding the spectrum of the 
        OPA (from averaged data)
    9.  Obtain pump axis (averaged data)
    10. Calculate 2D time domain spectrum for each wobbler state 
    11. Calculate the average intensities and standard deviation of 
        the intensities for each pixel
    12. Put this information into data container and hand it over to
        Pyqtplotting thread
    13. Calculate the numbers which are displayed in the 
        "statistics box" on the GUI

**Pyqt Plotting:**

    1.  Remove old plots
    2.  Setup the plot that displays:
            * Single 2d signal heatmap with histogram
            * Average 2d signal heatmap with histogram
            * Interferogram
            * Opa spectrum
            * Time domain spectrum of central pixel for all wobbler
              states
            * Intensities and their standard deviation (multiplied by 5)
            * Counts (How often a position was measured)
            * Stage position
    3.  Plot the plots for the first time
    4.  Update plots

**Saving:**

Note that the phase cycled transmission is saved. Therefore there exists
no dimension for the Wobbler states.

    .. code-block::

        programming data dimension:
        [(2 ([0] is current average scan, [1] is last single scan), n_delays, n_probe_pixels, n_interferometer_states, n_wobbler_states)]
        
        saving dimension: 
        [(n_probe_pixels, n_interferometer_states, n_wobbler_states)]
        
        raw data dimension: 
        [(n_channels, samples_to_acquire)]

################
Folder Structure
################

In **scans/** the first file of 3 contains the data in the saving
dimensions. The counts file contains the number of times a given state
was observed. This should be used as weights when averaging equally
weighted. The weights file contains the inverse variances of a given
state for all pixels, etc. The dimensions of these files are as the
saving dimension suggests. These files can be used to average in
different ways in order to obtain the complete resulting spectrum. Check
the actual code of the corresponding experiment to understand how to
calculate the desired end result (difference spectrum).

The data in **/averaged_data** is averaged equally weighted (using
counts). Likewise to the scan data the difference spectrum still needs
to be calculated from the transmissions for every state. Because the
array contains the transmissions averaged with counts it is only
intended to be used as a first indicator for the measurement. Here time
domain data was averaged. Please note that better quality can be
achieved when Fourier transforming first and averaging in the frequency
domain. This has to with the phasing that varies from scan to scan.

The **/figure** folder is currently empty. It is possible to implement
plotting of figures which are saved here while the experiment is
running. This is however not implemented yet.

The **/hardware config** folder holds every file which contains hardware
configuration parameters. This folder is a compressed copy of the
hardware configuration folder in the software's directory. Here it is
possible to look up the ADC configuration to obtain what channel was
connected to which hardware element (e.g. chopper - ai78). It is also
possible to obtain the R-2R values, the FPAS configuration, the
linearization parameters, etc.

The **/raw data** folder is in the experiments directory only if the
"save raw data" checkbox on the GUI was checked. It contains the raw
(unedited... as raw as it can get) data. Basically, the voltages which
the ADC measured for each channel. The dimensions are as "raw data
dimensions" suggests.

The **background.npy** file is a copy of the most recently collected
dark noise background of the MCT detector array. It corrects for dark
noise. If no new background was collected before the experiment was
started the code will use the latest available background and display a
warning in the log.

**setupinfo.txt** is a ReadMe file that contains the most relevant
experimental parameters at first glance. Additionally, the user can
decide to write a comment in the readme editor of the GUI. The content
of this is written to the **notes.txt** file.

**probe_wn_axis.npy** contains the wavenumber axis which is generated by
the spectrometer triax.py class.

**delays.npy** contains the delays including weights.

.. code-block::

    username/
    ├── date1_experimentname1_000/
    │   ├── averaged_data
    │   │   ├──  d000_date1_experimentname1_000.npy
    │   │   ├──  ...
    │   │   └──  d999_date1_experimentname1_000.npy
    │   ├── figures
    │   ├── hardware config
    │   ├── raw data
    │   │   ├──  delay000
    │   │   │   ├──  s000000_d000_date1_experimentname1_000_raw.npy
    │   │   │   ├──  ...
    │   │   │   └──  s000099_d000_date1_experimentname1_000_raw.npy
    │   │   ├──  ...
    │   │   └──  delay999
    │   ├── scans
    │   │   ├──  delay000
    │   │   │   ├──  s000000_d000_date1_experimentname1_000.npy
    │   │   │   ├──  s000000_d000_counts_date1_experimentname1_000.npy
    │   │   │   ├──  s000000_d000_weights_date1_experimentname1_000.npy
    │   │   │   ├──  ...
    │   │   │   └──  s000099_d000_date1_experimentname1_000.npy
    │   │   ├──  ...
    │   │   └──  delay999
    │   ├── probe_wn_axis_date1_experimentname1_000.npy
    │   ├── delays_date1_experimentname1_000.npy
    │   ├── setupinfo_date1_experimentname1_000.txt
    │   ├── notes_date1_experimentname1_000.txt
    │   └── background_date1_experimentname1_000.npy
    ├── date1_experimentname1_001/
    ├── date2_experimentname1_000/
    └── date2_experimentname2_000/
"""
if __name__ == "__main__":
    # Add directories to path for imports
    import os, sys, inspect

    currentdir = os.path.dirname(
        os.path.abspath(inspect.getfile(inspect.currentframe()))
    )
    parentdir = os.path.dirname(currentdir)

    sys.path.insert(0, parentdir)
    sys.path.insert(0, os.path.join(parentdir, "hardware_interfaces"))
    sys.path.insert(0, os.path.join(parentdir, "gui"))

import multiprocessing
from multiprocessing import Process, Queue
import sys

import threading

import numpy as np
from numpy import ndarray

# Matplotlib
import matplotlib
from matplotlib import pyplot as plt
from matplotlib.backends.backend_qt5agg import (
    FigureCanvasQTAgg,
    NavigationToolbar2QT as NavigationToolbar,
)
from matplotlib.figure import Figure
from matplotlib import cm

# PyQTGraph
from pyqtgraph import PlotWidget, plot
import pyqtgraph as pg

from PyQt5 import QtWidgets, QtCore  # , QtGui
from PyQt5.QtCore import QRunnable, QThreadPool, pyqtSignal, QObject
from qt_multithreading_wrapper import Worker

# Data processing
import data_processing as dp
from data_processing import PixelResponseLinearization as PRL

from save_data import SaveData, Background

# Hardware modules
from analog_digital_converter import AnalogDigitalConverter as ADC
from triax import Triax as Spectrometer
from pi_control import PiStage
from interferometer_counter import InterferometerCounter

# Set up logger
import logging

logger = logging.getLogger(__name__)


[docs]class Ft2dIr: """ Args: widget_pyqtgraph (QWidget): WidgetPyqtgraph object on which the plots are going to be displayed. Has methods for plot manipulation (i.e. removal of plots, autoscale). delays (ndarray): Array containing the delays in fs that are supposed to be measured in the 0th column and their corresponding weights in the 1st column. I.e.: loaded from a delay file which can be generated via the delay file editor. * shape: 2D * E.g.: (number of delays, 2) adc (ADC): Analog to digital converter hardware object which is used to communicate with and read data from the ADC. delay_stage (PiStage): PiStage hardware control object which provides the interface to the delay stage needed for this experiment. interferometer_stage (PiStage): PiStage hardware control object which provides the interface to the interferometer stage. interferometer_counter (InterferometerCounter): InterferometerCounter object which provides the functionalities necessary to use the interferometer counter electronics. prl (PRL): Pixel response linearization object which grants the functionality to linearize raw data according to the linearization parameters specified in the corresponding *pixel_linearization_fit_parameters.json* file (for each lab). he_ne_wl (float): Wavelength of the light source (generally a Helium-Neon laser) which is used for determining the position of the interferometer stage via the photodiodes and the counter electronics. wobbler_freq (float): Frequency in Hz with which the wobbler oscillates. background_handler (Background): Instance of Background class which can access the most recently collected background. This background is later subtracted from the raw data as dark noise correction. spectrometer (Spectrometer): Spectrometer/Triax hardware class which grants functionality to control the triax spectrometer. Needed to obtain e.g. wavenumber axis etc. info_queue (Queue): Multiprocessing queue object which is used to transfer/hand over information to lineEdits on GUI. Contains (if applicable) scan index, delay index, interleave index, values for statistics groupBox etc. saver (SaveData): Object that manages saving of data (including counts, weights, probe wavenumber axis etc.) into their respective directories. If None is passed, no data is going to be saved. If the raw data checkbox was checked on the GUI the savers raw data attribute is set to True and the raw data is saved automatically. Defaults to None. """ def __init__( self, widget_pyqtgraph, delays: ndarray, adc: ADC, delay_stage: PiStage, interferometer_stage: PiStage, interferometer_counter: InterferometerCounter, prl: PRL, he_ne_wl: float, wobbler_freq: float, background_handler: Background, spectrometer: Spectrometer, info_queue: Queue, saver: SaveData = None, ): # Initialise Queues self.acq_queue = Queue() self.processing_queue = Queue() self.plot_queue = Queue() # Save wavenumber axis, delay files etc. to file system if saver: saver.save_other(spectrometer.wn_axis, "probe_wn_axis") saver.save_other(delays, "delay_file") saver.save_other(background_handler.load_background(), "background") # Calculate number of interferometer states # we expect to observe because both processing # processes needs this information # Calculate the number of bins within this interferometer # amplitude removing the overshooting distance at both ends. # This automatically corresponds to the (interferometer) # number_of_states used in dp.sort_data(). We also use this to # preallocate our arrays. # We need to multiply by two because of the mirrors the light travels # twice the distance (or for other path factors an even greater multiple) interferometer_states = ( int( np.ceil( ( interferometer_stage.amplitude - 2 * interferometer_stage.overshoot_mm ) // he_ne_wl ) ) * interferometer_stage.path_factor ) # Get index of central pixel in MCT array. # This is necessary to display the time domain # spectrum on the GUI. # ? Not sure if this works for all analog input # ? configurations self.central_pixel = adc.probe_pixel_idx.size // 2 self.acquisition = Acquisition( delays, adc, delay_stage, interferometer_stage, interferometer_counter, spectrometer, self.acq_queue, ) self.primary_processing = PrimaryProcessing( self.acq_queue, self.processing_queue, delays, adc.pixel_idx, adc.probe_pixel_idx, adc.reference_pixel_idx, adc.index_dict, interferometer_counter.r2r_indices, prl, wobbler_freq, adc.laser_frequency, interferometer_states, interferometer_counter.bin_reference_values, background_handler, saver, ) self.secondary_processing = SecondaryProcessing( self.processing_queue, self.plot_queue, info_queue, delays, adc.probe_pixel_idx, interferometer_states, self.central_pixel, saver, ) self.plotting = PyqtPlotting( widget_pyqtgraph, adc, self.plot_queue, delays, self.central_pixel )
[docs] def start(self): self.plotting.threadpool.start(self.plotting.work) self.secondary_processing.start() self.primary_processing.start() self.acquisition.start()
[docs]class Acquisition(threading.Thread): """ Args: delays (ndarray): Array containing the delays in fs that are supposed to be measured in the 0th column and their corresponding weights in the 1st column. I.e.: loaded from a delay file which can be generated via the delay file editor. * shape: 2D * E.g.: (number of delays, 2) adc (ADC): Analog to digital converter hardware object which is used to communicate with and read data from the ADC. delay_stage (PiStage): PiStage hardware control object which provides the interface to the delay stage needed for this experiment. interferometer_stage (PiStage): PiStage hardware control object which provides the interface to the interferometer stage. interferometer_counter (InterferometerCounter): InterferometerCounter object which provides the functionalities necessary to use the interferometer counter electronics. spectrometer (Spectrometer): Spectrometer/Triax hardware class which grants functionality to control the triax spectrometer. Needed to obtain e.g. wavenumber axis etc. acq_queue (Queue): Multiprocessing queue object that the acquisition thread uses to pass data to the primary processing process. """ def __init__( self, delays: ndarray, adc: ADC, delay_stage: PiStage, interferometer_stage: PiStage, interferometer_counter: InterferometerCounter, spectrometer: Spectrometer, acq_queue: Queue, ): threading.Thread.__init__(self) # Assign attributes self.delays = delays self.adc = adc # Hardware devices self.delay_stage = delay_stage self.interferometer_stage = interferometer_stage self.interferometer_counter = interferometer_counter self.spectrometer = spectrometer # Multiprocessing Queue self.acq_queue = acq_queue # Save the base amount of samples # to acquire # This is needed to reset the weighting # for each delay self.base_samples = self.adc.samples_to_acquire # Initialize scan index self.scan_idx = 0 # Create dictionary that will hold data that is passed # to other queues self.data_container = {} # Multithreading event to stop experiment self.exit = threading.Event() # -------- Start moving interferometer and counting # Move interferometer to position where counter is supposed # to be reset. This is the actual minimum position of the # wavegeneration if overshooting would not be necessary. # ---Note: We actually want the stage to pass the reset position # during wavegeneration. If this happens, an underflow occurs # and the counter starts counting backwards starting at 0xFFFF/2 # This is wanted behavior! For optimal data processing we need # to fill every interferometer position (and also every other state) # with data (laser shots). So reaching beyond the 0 ensures # measuring the 0th bin. Please see the comments for the # sort_data function in Primary Processing for # further information. --- self.interferometer_stage.move_mm(self.interferometer_stage.reset_position) # Reset interferometer counter to value 0 self.interferometer_counter.reset_counter() # Move to starting position of wavegeneration. self.interferometer_stage.move_mm(self.interferometer_stage.start_pos) # Start moving interferometer stage back and forth # Note: For this to be possible a wavetable # already needs to be setup beforehand self.interferometer_stage.start_wavegen()
[docs] def run(self): # * You might wonder why this acquisition # * is structured differently from the acquisition # * of the other (older/simpler) experiments # * [Or you might not wonder - # * Lets hope that someone fixed this discrepancy # * already] # * The way it is implemented here (the straight # * forward way) was chosen because it simplifies # * the indices counting a lot, and within multiprocessing # * should not lead to any loss of laser time. # * Before the 0th data set was acquired outside of # * the loop and was processed while the second # * acquisition was running while not self.exit.is_set(): # Iterate over all delays for d_idx, delay in enumerate(self.delays): # Set samples to acquire according to weight # for this delay samples_to_acquire = int(round(self.base_samples * delay[1])) self.adc.set_samples_to_acquire(samples_to_acquire) # Move to next delay / move delay stage self.delay_stage.move(delay[0]) # Read data with given parameters self.adc.read() # Put data in acquisition queue self.data_container["data"] = self.adc.data.copy() self.data_container["scan index"] = self.scan_idx self.data_container["delay index"] = d_idx self.data_container["probe axis"] = self.spectrometer.wn_axis.copy() # Give data to queue self.acq_queue.put(self.data_container.copy()) # Update scan idx self.scan_idx += 1 # Stop wavegeneration for interferometer self.interferometer_stage.stop_wavegen() # Tell processes to stop after last data self.acq_queue.put("stop")
[docs] def shutdown(self): # Setting exit will stop the loop # within run self.exit.set()
[docs]class PrimaryProcessing(multiprocessing.Process): """ Args: acq_queue (Queue): Multiprocessing queue object that the acquisition thread uses to pass data to the primary processing process. processing_queue (Queue): Multiprocessing queue object that the primary processing process uses to pass data to the secondary processing process. delays (ndarray): Array containing the delays in fs that are supposed to be measured in the 0th column and their corresponding weights in the 1st column. I.e.: loaded from a delay file which can be generated via the delay file editor. * shape: 2D * E.g.: (number of delays, 2) pixel_idx (ndarray): Array that contains the indices of the rows in the ADCs' data that correspond to pixel input channels. These are specified in the "analog input configuration.json" for each laboratory and can be easily accessed with the attribute "pixel_idx" of the ADC. * shape: 1D * E.g.: (64) or (128) probe_pixel_idx (ndarray): Array that contains the indices of the rows in the ADCs' data that correspond to *probe* pixel input channels. These are specified in the "analog input configuration.json" for each laboratory and can be easily accessed with the attribute "probe_pixel_idx" of the ADC. It is highly relevant that the order the pixels are listed in this array match the order of the array in the reference_pixel_idx argument. This means that if reference pixel 3 is listed first in the other array here probe pixel 3 needs to be listed first as well and so on. Otherwise the intensities on the probe array are not going to be normalized correctly. For the plotting to work correctly the pixels also need to be listed in the order of the wavenumber axis array of the spectrometer. * shape: 1D * E.g.: (32) or (64) reference_pixel_idx (ndarray): Array that contains the indices of the rows in the ADCs' data that correspond to *reference* pixel input channels. These are specified in the "analog input configuration.json" for each laboratory and can be easily accessed with the attribute "reference_pixel_idx" of the ADC. It is highly relevant that the order the pixels are listed in this array match the order of the array in the probe_pixel_idx argument. This means that if probe pixel 10 is listed first in the other array here reference pixel 10 needs to be listed first as well and so on. Otherwise the intensities on the probe array are not going to be normalized correctly. For the plotting to work correctly the pixels also need to be listed in the order of the wavenumber axis array of the spectrometer. * shape: 1D * E.g.: (32) or (64) index_dict (dict): Dictionary that maps the names of the input channels to their corresponding row in the ADCs' data as they are specified in the "analog input configuration.json" for each laboratory. I.e.: It contains the information which entries of the ADCs' data array belong choppers, wobblers etc. This dictionary can be easily accessed with the attribute "index_dict" of the ADC. r2r_indices (ndarray): Array that contains the indices of the rows in the ADCs' data that correspond to R-2R input channels in ascending order of significance (LSB channel first, etc.) * shape: 1D * E.g.: (4) prl (PRL): Pixel response linearization object which grants the functionality to linearize raw data according to the linearization parameters specified in the corresponding *pixel_linearization_fit_parameters.json* file (for each lab). interferometer_states (int): Number of interferometer states that are expected to be observed given a coherence time. wobbler_freq (float): Frequency in Hz with which the wobbler oscillates. laser_freq (float): Frequency (repetition rate) of the laser in Hz. bin_reference_values (ndarray): Reference values for each of the R-2R networks. * shape: 2D (4, 15) 4 rows for each of the R-2R Networks, 15 values for the 16 levels of the R-2R. background_handler (Background): Instance of Background class which can access the most recently collected background. This background is later subtracted from the raw data as dark noise correction. saver (SaveData): Object that manages saving of data (including counts, weights, probe wavenumber axis etc.) into their respective directories. If None is passed, no data is going to be saved. If the raw data checkbox was checked on the GUI the savers raw data attribute is set to True and the raw data is saved automatically. Defaults to None. """ def __init__( self, acq_queue: Queue, processing_queue: Queue, delays: ndarray, pixel_idx: ndarray, probe_pixel_idx: ndarray, reference_pixel_idx: ndarray, index_dict: dict, r2r_indices: ndarray, prl: PRL, wobbler_freq: float, laser_freq: float, interferometer_states: int, bin_reference_values: ndarray, background_handler: Background, saver: SaveData = None, ): super(multiprocessing.Process, self).__init__() # Multiprocessing Queue # Gets data from acquisition class self.acq_queue = acq_queue # Gives data to secondary processing class self.processing_queue = processing_queue # Pixel response linearisation self.prl = prl # Laser frequency in Hz self.laser_freq = laser_freq # Wobbler frequency in Hz self.wobbler_freq = wobbler_freq # Data saving instance self.saver = saver # Pixel index is an array which tells us which # entries in our adc data are pixels # Append probe and reference pixel indices self.pixel_idx = pixel_idx self.probe_pixel_idx = probe_pixel_idx self.ref_pixel_idx = reference_pixel_idx self.index_dict = index_dict self.r2r_indices = r2r_indices # Bin reference values for R-2R Network self.bin_reference_values = bin_reference_values # Load background data from file self.background = background_handler.load_background() # Calculate the number of states that # are going to be observed for each device self.interferometer_states = interferometer_states # Calculate the number of wobbler states we are going to observe self.wobbler_states = int(laser_freq // wobbler_freq) # Stack the different number of states into one array. self.number_of_possible_states = np.array( [self.interferometer_states, self.wobbler_states] ) # Initialize array that # sorted data is written into. # This is different from other # experiments (without wobbler) # because the delay axis as well # as the average and single scan # dimension have been removed. # Here we need to phase cycle directly: # 1. The get_wobbler_states algorithm # is not "safe" in fringe cases. # This could lead to averaging # different wobbler states together # when averaging different scans. # If we directly average wobbler states # for each scan this problem does not # arise. # 2. It makes the most sense to directly # phase cycle the data of one scan: # this has to do with the # imprecision of the delay stage # when measuring two time the same delay # it will move to slightly different # locations yielding slightly different # phases. Averaging these my yield # artifacts. # 3. This reduces the size of the data # set that is going to be saved by # a factor of 4. self.data = np.zeros( ( self.probe_pixel_idx.size + 1, # +1 because of interferogram / pyro detector channel self.interferometer_states, # all interferometer positions self.wobbler_states, ) # all wobbler states ) self.counts = np.zeros(self.data.shape) self.weights = np.zeros(self.data.shape) # Because we use a Wobbler in this experiment # we need a second set of arrays where # the phase cycled/ scatter free transmissions # are written into. # In this case the data is average # relative intensity # (transmission (probe/ref)) # for each pixel. # By convention the averaged is the # 0 th entry of the 0th axis of the array # and the temp data is the 1st entry of the 0th # axis of the array. # dimensions: 2 = (current average scan, last single scan) # dimensions: (2, n_delays, n_probe_pixels, n_interferometer_states) # Phase cycled = scatter free self.phase_cycled_data = np.zeros( ( 2, # averaged and non averaged (scan) delays.shape[0], # all delays self.probe_pixel_idx.size + 1, # +1 because of interferogram / pyro detector channel self.interferometer_states, ) # all interferometer positions ) self.phase_cycled_counts = np.zeros(self.phase_cycled_data.shape) self.phase_cycled_weights = np.zeros(self.phase_cycled_data.shape)
[docs] def run(self): while True: # Get data / information from acquisition process data_container = self.acq_queue.get() if type(data_container) == str: if data_container == "stop": break # Write data in dictionary into variable raw_data = data_container["data"] scan_idx = data_container["scan index"] delay_idx = data_container["delay index"] # Subtract background from raw data # of pixels and pixels only background_corrected_data = ( raw_data[self.pixel_idx] - self.background[self.pixel_idx, np.newaxis] ) # Linearize response of Pixels (and pixels only) intensities = self.prl.linearize(background_corrected_data) # Calculate transmission/ relative intensity # (probe intensity / reference intensity) # In LabView the transmission was not calculated # shot-to-shot. We believe it makes more sense # to do calculate shot to shot normalized data: # When comparing the sorting of non-normalized intensities # to sorting normalized intensities/transmission for time-domain # pump and frequency-domain probe experiments (namely FT-2D-IR) # for the same raw data set there was no significant difference. # The difference between the two was at least two orders of magnitude # smaller than the resulting frequency-domain difference absorption # spectrum. There seems to be no reason against sorting # shot-to-shot normalized intensities (transmission). We believe # this should also generally lead to better data quality. # Caveat: The raw data set this was tested on only contained # negative delays. We recommend investigating this in # more depth. transmission = ( intensities[self.probe_pixel_idx] / intensities[self.ref_pixel_idx] ) # Calculate/ generate interferometer counter positions counter_data = dp.calculate_counter_values( raw_data, self.r2r_indices, self.bin_reference_values ) # Check if lower and upper interferometer positions were observed if not (counter_data == 0).any(): logger.fatal("------THE 0TH BIN WAS NOT OBSERVED----------") if not (counter_data == self.interferometer_states).any(): logger.fatal( "------THE LAST ({}) BIN WAS NOT OBSERVED----------".format( self.interferometer_states ) ) # Get Wobbler States wobbler_states = dp.get_wobbler_states( raw_data[self.index_dict["wobbler"]], self.laser_freq, wobbler_freq=self.wobbler_freq, ) # Generate states array by stacking counter information and wobbler states # ? There is proably a faster way to do this than stacking by preallocating arrays? Nvm for now states = np.vstack((counter_data, wobbler_states)) # We need to sort our interferogram too. # Thats why "append" it to the unsorted transmissions. unsorted_data = np.vstack( (transmission, raw_data[self.index_dict["pyro detector"]]) ) # Sort and average data # --- Explanation for sorting interferometer states: # The sort_data function sorts samples (laser shots) # into an array, according to the counter position # for each shot, and then averages them. # Each entry corresponds to one interferometer # position, starting at the 0th bin and ending # with the self.interferometer_states bin. # What happens if the counter position is outside this interval? # The information is then added # to the bin at the closer end of the array. For counter data # greater than self.interferometer_states # the information is added to the last entry. # For counter_data < 0 it would be added to the # 0th entry of the array (but this cannot occur # in the case of counter data). # This "clipping" is not an issue. # At the end of the coherence time there should # be no signal, or else other procedures like zeropadding # would not work either. And thus clipping data to # the end of the time domain data is no problem. # (Technically we are ignoring underflowed counter # values here - but even in this case the discrepancy # should average out.) self.data[:], self.weights[:], self.counts[:], statistics = dp.sort_data( unsorted_data, states, self.number_of_possible_states ) # --------- Phase cycling ---------- # phase cycled = scatter free # -- Phase cycle transmissions -- # Remove scattering by averaging the different wobbler states # * In VB6 the interleaves are averaged # * once the difference spectra are calculated # * this (below) first phase cycles and then # * offers data for signal calculating # ** Update: We tested/compared the difference # ** for one data set and the absolute difference # ** was consistently smaller 10e-3 smaller # ** than the signal (this was done with interleaves # ** but the same logic applies to the wobbler) # ** We (Jens and us) agree that it conceptually # ** makes more sense to average the relative # ** intensities. #! It should be tested whether it makes sense to first average #! scans and then phase cycle (we are first phase cycling then averaging here) # * In this scenario we don't actually need to compute the wobbler states # * which wobbler position came first etc. # * It would suffice to separate the data in 4 different states like # * this: 0,1,2,3,0,1,2,3,0,1.... # * When averaging the way it is done here the actual position of the # * does not matter because they are averaged out before we average # * two different scans. idx = (1, delay_idx) self.phase_cycled_data[idx] = np.average( self.data, # Do not use weights to phase cycle because each wobbler position needs to be weighted equally axis=-1, # The last axis of the array is the wobbler axis ) # Now we need to calculate the total counts # that were observed in each interferometer position # for all wobbler states self.phase_cycled_counts[idx] = self.counts.sum(axis=-1) # We also need to update the weights (inverse variance of each state) self.phase_cycled_weights[idx] = self.weights.sum(axis=-1) # ----- Average data ---- # Create / Average "averaged" phase cycled data by using # weighted average between the phase cycled temp data # (weight = counts for each state in "temp" data set) # and the already existing average data # (weight = counts for each state in average data set) # --- Note: In LabView it was observed that averaging # the data in the frequency domain instead of the # time domain is beneficial. This was attributed # to the phase drifting between scans. # This is not done here because this would imply # computing the frequency domain only for display purposes. # If this would crash, the whole measurement would be stopped. # It is always the time domain data that should be saved, # because of postprocessing reasons(different windows, more # zeropadding etc.). --- # There are several scenarios in which the following # weighted average can fail (most likely in the 0th scan): # 1. The interferometer counter is not counting (correctly) # 2. The acquisition time is too small - thus not all states # have been observed within one scan. # 3. Everything should be working as expected but # some states have not been observed. This might # be the stage that did not completely move to its # corresponding defined end. # 4. The wavegeneration does not reach all interferometer # positions. # 5. ... # In all these cases we do not want the whole measurement to # crash. Thats why we put the averaging into a try statement: try: self.phase_cycled_data[0, delay_idx] = np.average( self.phase_cycled_data[:, delay_idx], axis=0, weights=self.phase_cycled_counts[:, delay_idx], ) except ZeroDivisionError: logger.fatal( """Averaging failed. Setting phase cycled counts and weights to 0. Not all states were observed.""" ) # Set phase cycled counts to 0 self.phase_cycled_counts[idx] = 0 # Set phase cycled weights to 0 self.phase_cycled_weights[idx] = 0 # Add everything to data container # and give it to processing queue data_container["sorted data"] = self.data.copy() data_container["phase cycled data"] = self.phase_cycled_data.copy() data_container["counts"] = self.counts.copy() data_container["intensities"] = intensities.copy() data_container["transmission"] = transmission.copy() data_container["interferometer positions"] = counter_data data_container["statistics"] = statistics self.processing_queue.put(data_container.copy()) # ---------------------------------------- # Save data (if specified) # In this case save phase cycled data to save space if self.saver: # Use saver class to save data self.saver.save_scan( self.phase_cycled_data[1, delay_idx], scan_idx, delay_idx=delay_idx ) self.saver.save_avg( self.phase_cycled_data[0, delay_idx], delay_idx=delay_idx ) self.saver.save_counts( self.phase_cycled_counts[1, delay_idx], scan_idx, delay_idx=delay_idx, ) self.saver.save_weights( self.phase_cycled_weights[1, delay_idx], scan_idx, delay_idx=delay_idx, ) if self.saver.raw_data: self.saver.save_raw_data(raw_data, scan_idx, delay_idx=delay_idx) # Update counts self.phase_cycled_counts[0, delay_idx] += self.phase_cycled_counts[ 1, delay_idx ] # Once stop signal was received the function breaks out of the loop. # Now we need to tell the secondary processing to stop. self.processing_queue.put("stop")
[docs]class SecondaryProcessing(multiprocessing.Process): """ Args: processing_queue (Queue): Multiprocessing queue object that the primary processing process uses to pass data to the secondary processing process. plot_queue (Queue): Multiprocessing queue object that the secondary processing process uses to pass data to the plot thread. info_queue (Queue): Multiprocessing queue object which is used to transfer/hand over information to lineEdits on GUI. Contains (if applicable) scan index, delay index, interleave index, values for statistics groupBox etc. delays (ndarray): Array containing the delays in fs that are supposed to be measured in the 0th column and their corresponding weights in the 1st column. I.e.: loaded from a delay file which can be generated via the delay file editor. * shape: 2D * E.g.: (number of delays, 2) probe_pixel_idx (ndarray): Array that contains the indices of the rows in the ADCs' data that correspond to *probe* pixel input channels. These are specified in the "analog input configuration.json" for each laboratory and can be easily accessed with the attribute "probe_pixel_idx" of the ADC. It is highly relevant that the order the pixels are listed in this array match the order of the array in the reference_pixel_idx argument. This means that if reference pixel 3 is listed first in the other array here probe pixel 3 needs to be listed first as well and so on. Otherwise the intensities on the probe array are not going to be normalized correctly. For the plotting to work correctly the pixels also need to be listed in the order of the wavenumber axis array of the spectrometer. * shape: 1D * E.g.: (32) or (64) interferometer_states (int): Number of interferometer states that are expected to be observed given a coherence time. central_pixel (int): Index of the central pixel of the detector. Used to display the signal on the central pixel. saver (SaveData): Object that manages saving of data (including counts, weights, probe wavenumber axis etc.) into their respective directories. If None is passed, no data is going to be saved. If the raw data checkbox was checked on the GUI the savers raw data attribute is set to True and the raw data is saved automatically. Defaults to None. """ def __init__( self, processing_queue: Queue, plot_queue: Queue, info_queue: Queue, delays: ndarray, probe_pixel_idx: ndarray, interferometer_states: int, central_pixel: int, saver: SaveData = None, ): super(multiprocessing.Process, self).__init__() # Multiprocessing Queue # Gets data from acquisition class self.processing_queue = processing_queue # Gives data to secondary processing class self.plot_queue = plot_queue # Give experimental status and statistics to # GUI self.info_queue = info_queue # The file saver is required the save the resulting plot self.saver = saver # Pixel index is an array which tells us which # entries in our adc data are pixels # In this case we only need this to # preallocate our signal array self.probe_pixel_idx = probe_pixel_idx # Number of interferometer states we expect to observe self.interferometer_states = interferometer_states # Central pixel of MCT array self.central_pixel = central_pixel # Preallocate array that will hold signal information # By convention the averaged is the # 0 th entry of the 0th axis of the array # and the temp data is the 1st entry of the 0th # axis of the array. self.time_domain_2d_spectrum = np.zeros( (2, delays.shape[0], self.probe_pixel_idx.size, self.interferometer_states) ) # We cannot preallocate an array that holds the combined # frequency data set because we cannot exactly determine # its size. The reason for this is, that the time domain # data is cut off at the zerobin and then zeropadded. # Instead we use a list to hold the single scan # and averaged 2D frequency domain spectrum self.freq_domain_2d_spectrum = [None, None] # Preallocate some more lists holding relevant information self.zerobin = [None, None] self.pump_frequency_axis = [None, None] self.opa_range = [None, None] self.central_pump_wn = [None, None] self.opa_spectrum = [None, None] # Needed for 2D heatmap /contour plot # intp: interpolated self.intp_signal = [None, None]
[docs] def run(self): while True: # Get data / information from acquisition process data_container = self.processing_queue.get() if type(data_container) == str: if data_container == "stop": break # ---------------------------------------- # Calculate information for plotting and GUI # We first calculate everything that is required # for plotting and pass it to the plot queue because # plotting can also cost time. Only then we # calculate the values that we want to display on # line edits on the GUI # Get delay index delay_idx = data_container["delay index"] # Get the sorted and phase cycled data sorted_data = data_container["phase cycled data"] # The last entry on the 2nd axis holds the interferograms (averaged and single scan) interferogram = sorted_data[:, delay_idx, -1] # Calculate time domain 2D absorption spectrum self.time_domain_2d_spectrum[:, delay_idx] = -np.log10( sorted_data[:, delay_idx, :-1] ) # The last entry on the 2nd axis is the interferogram # In some cases the processing of the time domain spectra might not work # thats why we put it in a try statement try: # --- Process single scan data --- # Calculate frequency domain 2D absorption spectrum # * At the moment we use no window function for fft self.freq_domain_2d_spectrum[1], ifgr_info = dp.process_ft2dir_data( self.time_domain_2d_spectrum[1, delay_idx], interferogram[1], # this entry holds the interferogram ) # Extract the relevant information regarding the spectrum of the OPA self.zerobin[1] = ifgr_info[0] self.pump_frequency_axis[1] = ifgr_info[3] self.opa_range[1] = ifgr_info[4][1] self.central_pump_wn[1] = ifgr_info[3][ifgr_info[4][0]] self.opa_spectrum[1] = np.abs( ifgr_info[2] ) # The fft of the interferogram yields complex values - the OPA spectrum is the amplitude # Interpolate frequency domain 2d spectrum so that it can be # displayed using pyqtgraph # while doing this also transpose array s.t. pump axis # is the 0th axis and probe axis is the 1st axis. # Get the range of the pump axis in which the OPA # emits light pump_axis = self.pump_frequency_axis[1][self.opa_range[1]] self.intp_signal[1] = dp.generate_img_data( data_container["probe axis"], pump_axis, self.freq_domain_2d_spectrum[1].take(self.opa_range[1], axis=-1).T, ) # --- Process averaged data --- # * At the moment we use no window function for fft self.freq_domain_2d_spectrum[0], ifgr_info = dp.process_ft2dir_data( self.time_domain_2d_spectrum[0, delay_idx], interferogram[0] ) # Extract the relevant information regarding the spectrum of the OPA self.zerobin[0] = ifgr_info[0] self.pump_frequency_axis[0] = ifgr_info[3] self.opa_range[0] = ifgr_info[4][1] self.central_pump_wn[0] = ifgr_info[3][ifgr_info[4][0]] self.opa_spectrum[0] = np.abs( ifgr_info[2] ) # The fft of the interferogram yields complex values - the OPA spectrum is the amplitude # Interpolate frequency domain 2d spectrum so that it can be # displayed using pyqtgraph # while doing this also transpose array s.t. pump axis # is the 0th axis and probe axis is the 1st axis. # Get the range of the pump axis in which the OPA # emits light pump_axis = self.pump_frequency_axis[0][self.opa_range[0]] self.intp_signal[0] = dp.generate_img_data( data_container["probe axis"], pump_axis, self.freq_domain_2d_spectrum[0].take(self.opa_range[0], axis=-1).T, ) except: logger.fatal( "Processing of time domain data to frequency domain failed. Setting all affected variables to 0." ) # Write 0 in all variables # This is done so that the plotting does not crash self.freq_domain_2d_spectrum[0] = np.zeros( self.time_domain_2d_spectrum[0, delay_idx].shape ) self.zerobin[0] = 0 self.pump_frequency_axis[0] = np.arange(interferogram[0].size) self.opa_range[0] = np.arange(interferogram[0].size) self.central_pump_wn[0] = 0 self.opa_spectrum[0] = np.zeros(interferogram[0].size) self.intp_signal[0] = np.zeros( self.time_domain_2d_spectrum[0, delay_idx].shape ) self.freq_domain_2d_spectrum[1] = np.zeros( self.time_domain_2d_spectrum[1, delay_idx].shape ) self.zerobin[1] = 0 self.pump_frequency_axis[1] = np.arange(interferogram[1].size) self.opa_range[1] = np.arange(interferogram[1].size) self.central_pump_wn[1] = 0 self.opa_spectrum[1] = np.zeros(interferogram[1].size) self.intp_signal[1] = np.zeros( self.time_domain_2d_spectrum[1, delay_idx].shape ) # Calculate 2D time domain spectrum for each wobbler state # for central pixel to double check whether phase cycling # is working. # --- Note considering naming: # For experiments with interleaves we choose # to call the non phase cycled signals="signal" # and the phase cycled signal="phase_cycled_signal" # here we reverse this order. So non phase cycled # signals = "npc_signal" and phase cycled signal # = "signal". # We do this because with interleaves it takes # time to measure all delay stage positions. # Here all different phase states are collected # within one acquisition. --- # npc = non phase cycled npc_time_domain_central_pixel = -np.log10( data_container["sorted data"][self.central_pixel] ) # Calculate the average intensity for each pixel avg_intensities = np.average(data_container["intensities"], axis=1) # Calculate the standard deviation of the # intensities std_intensities = np.std(data_container["intensities"], axis=1, ddof=1) # Add everything to data container # and give to plot queue data_container["time domain 2d spectrum"] = self.time_domain_2d_spectrum[ :, delay_idx ] data_container[ "non phase cycled time domain central pixel" ] = npc_time_domain_central_pixel data_container[ "frequency domain 2d spectrum" ] = self.freq_domain_2d_spectrum data_container[ "interpolated frequency domain 2d spectrum" ] = self.intp_signal data_container["zerobin"] = self.zerobin data_container["interferogram"] = interferogram data_container["pump frequency axis"] = self.pump_frequency_axis data_container["opa range"] = self.opa_range data_container["central pump wn"] = self.central_pump_wn data_container["opa spectrum"] = self.opa_spectrum data_container["average intensity"] = avg_intensities data_container["std intensity"] = std_intensities self.plot_queue.put(data_container.copy()) # ---------------------------------------- # Calculate everything that is needed for statistical information # on GUI and add it to data container and give it to info queue # Calculate the average intensities over all pixels data_container["mean state intensity"] = np.average( data_container["transmission"], axis=1 ) data_container["mean state std"] = data_container["statistics"][1] self.info_queue.put(data_container.copy()) # Once stop signal was received the function breaks out of the loop. # Now we need to tell the plotting and updating of info on GUI to stop. self.plot_queue.put("stop") self.info_queue.put("stop")
[docs]class PyqtPlotting: """ Pyqt Plotting class (Qt multithreaded). This class is necessary for displaying plots on the GUI. PyQtGraph is used as the plotting engine. Generally the plots are set up first (type of plot, layout, title etc.). On the first run, the plots are drawn for the first time. Then the plots are updated every iteration. We update the same plot references every time to make it more efficient. Args: widget_pyqtgraph (QWidget): WidgetPyqtgraph object on which the plots are going to be displayed. Has methods for plot manipulation (i.e. removal of plots, autoscale). adc (ADC): Analog to digital converter hardware object which is used to communicate with and read data from the ADC. plot_queue (Queue): Multiprocessing queue object that the secondary processing process uses to pass data to the plot thread. delays (ndarray): Array containing the delays in fs that are supposed to be measured in the 0th column and their corresponding weights in the 1st column. I.e.: loaded from a delay file which can be generated via the delay file editor. * shape: 2D * E.g.: (number of delays, 2) central_pixel (int): Index of the central pixel of the detector. Used to display the signal on the central pixel. """
[docs] class Signals(QObject): new_data = pyqtSignal(dict)
def __init__( self, widget_pyqtgraph, adc: ADC, plot_queue: Queue, delays: ndarray, central_pixel: int, ): # Assign attributes self.adc = adc self.delays = delays self.central_pixel = central_pixel self.widget_pyqtgraph = widget_pyqtgraph self.graphics_layout = widget_pyqtgraph.graphics_layout # Multiprocessing Queue self.plot_queue = plot_queue # Setup signals and threadpool self.threadpool = QThreadPool() self.signals = self.Signals() # # Clear old plots self.widget_pyqtgraph.remove_plots() # Create dictionary that holds reference to lines etc. self.plot_ref = {} # Add a sub-layout to hold the 2D plots and the histograms in the first row self.widget_pyqtgraph.plots["upper layout"] = self.graphics_layout.addLayout( colspan=4 ) # Setup plots that hold heatmap 2D plot y-axis: pump axis, x-axis: probe axis self.widget_pyqtgraph.plots[ "single 2d-signal heatmap" ] = self.widget_pyqtgraph.plots["upper layout"].addPlot() self.widget_pyqtgraph.plots["single 2d-signal heatmap"].setTitle( "2D-signal for delay time {} fs" ) self.widget_pyqtgraph.plots["single 2d-signal heatmap"].setLabel( "bottom", "probe frequency &omega;<sub>1</sub> [cm<sup>-1</sup>]" ) self.widget_pyqtgraph.plots["single 2d-signal heatmap"].setLabel( "left", "pump frequency &omega;<sub>3</sub> [cm<sup>-1</sup>]" ) self.widget_pyqtgraph.set_style( self.widget_pyqtgraph.plots["single 2d-signal heatmap"] ) # Add the HistogramLUTItem to the plotlayout directly after 2D plot # Also add the item. With that it will be directly drawn at the correct position self.plot_ref["single 2d-signal heatmap histogram"] = pg.HistogramLUTItem() self.widget_pyqtgraph.plots["upper layout"].addItem( self.plot_ref["single 2d-signal heatmap histogram"] ) self.widget_pyqtgraph.plots[ "avg 2d-signal heatmap" ] = self.widget_pyqtgraph.plots["upper layout"].addPlot() self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].setTitle( "Scan averaged 2D-signal for delay time {} fs" ) self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].setLabel( "bottom", "probe frequency &omega;<sub>1</sub> [cm<sup>-1</sup>]" ) self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].setLabel( "left", "pump frequency &omega;<sub>3</sub> [cm<sup>-1</sup>]" ) self.widget_pyqtgraph.set_style( self.widget_pyqtgraph.plots["avg 2d-signal heatmap"] ) self.plot_ref["avg 2d-signal heatmap histogram"] = pg.HistogramLUTItem() self.widget_pyqtgraph.plots["upper layout"].addItem( self.plot_ref["avg 2d-signal heatmap histogram"] ) # Setup colormap for heatmaps # Credit: https://github.com/pyqtgraph/pyqtgraph/issues/561 colormap = cm.get_cmap("seismic") # cm.get_cmap("CMRmap") colormap._init() # [:-3,:] because the last values of the colormap are fringe # cases which are matplotlib specific and do not define our # colormap self.lut = (colormap._lut * 255).view(np.ndarray)[ :-3, : ] # Convert matplotlib colormap from 0-1 to 0 -255 for Qt # Skip to next row self.graphics_layout.nextRow() # Add a sub-layout to hold the 2 plots in the second row self.widget_pyqtgraph.plots["middle layout"] = self.graphics_layout.addLayout( colspan=6 ) # Setup plot that holds plot for interferogram self.widget_pyqtgraph.plots["interferogram"] = self.widget_pyqtgraph.plots[ "middle layout" ].addPlot(colspan=2) self.widget_pyqtgraph.plots["interferogram"].setTitle( "Interferogram (zerobin: {})" ) self.widget_pyqtgraph.plots["interferogram"].setLabel("bottom", "position") self.widget_pyqtgraph.plots["interferogram"].setLabel( "left", "intensity [a.u.]" ) self.widget_pyqtgraph.set_style(self.widget_pyqtgraph.plots["interferogram"]) # Setup plot that holds plot for OPA pump spectrum self.widget_pyqtgraph.plots["opa spectrum"] = self.widget_pyqtgraph.plots[ "middle layout" ].addPlot(colspan=2) self.widget_pyqtgraph.plots["opa spectrum"].setTitle("OPA pump spectrum") self.widget_pyqtgraph.plots["opa spectrum"].setLabel( "bottom", "pump frequency &omega;<sub>3</sub> [cm<sup>-1</sup>]" ) self.widget_pyqtgraph.plots["opa spectrum"].setLabel("left", "intensity [a.u.]") self.widget_pyqtgraph.set_style(self.widget_pyqtgraph.plots["opa spectrum"]) # Setup plot that holds time domain data for central pixel for each wobbler state self.widget_pyqtgraph.plots[ "time-domain central pixel" ] = self.widget_pyqtgraph.plots["middle layout"].addPlot(colspan=2) self.widget_pyqtgraph.plots["time-domain central pixel"].setTitle( "Time domain absorption for central pixel" ) self.widget_pyqtgraph.plots["time-domain central pixel"].setLabel( "bottom", "interferometer position" ) self.widget_pyqtgraph.plots["time-domain central pixel"].setLabel( "left", "intensity [a.u.]" ) self.widget_pyqtgraph.set_style( self.widget_pyqtgraph.plots["time-domain central pixel"] ) # Add next row for the last row of plots. self.graphics_layout.nextRow() # Setup plot that displays intensities self.widget_pyqtgraph.plots["intensities"] = self.graphics_layout.addPlot( row=2, col=0 ) self.widget_pyqtgraph.plots["intensities"].setTitle("Average intensities") self.widget_pyqtgraph.plots["intensities"].setLabel( "bottom", "probe frequency &omega;<sub>1</sub> [cm<sup>-1</sup>]" ) self.widget_pyqtgraph.plots["intensities"].setLabel("left", "intensity [a.u.]") self.widget_pyqtgraph.set_style(self.widget_pyqtgraph.plots["intensities"]) # Setup plot that displays counts for different wobbler states self.widget_pyqtgraph.plots["counts"] = self.graphics_layout.addPlot( row=2, col=1 ) self.widget_pyqtgraph.plots["counts"].setTitle( "How often a position was measured" ) self.widget_pyqtgraph.plots["counts"].setLabel( "bottom", "interferometer position" ) self.widget_pyqtgraph.plots["counts"].setLabel("left", "counts") self.widget_pyqtgraph.set_style(self.widget_pyqtgraph.plots["counts"]) # Setup plot that displays interferometer positions over time self.widget_pyqtgraph.plots["stage position"] = self.graphics_layout.addPlot( row=2, col=2 ) self.widget_pyqtgraph.plots["stage position"].setTitle( "Position of interferometer stage" ) self.widget_pyqtgraph.plots["stage position"].setLabel("bottom", "time") self.widget_pyqtgraph.plots["stage position"].setLabel( "left", "interferometer counter value" ) self.widget_pyqtgraph.set_style(self.widget_pyqtgraph.plots["stage position"]) # Connect signal that data has arrived to update the plot self.signals.new_data.connect( lambda data_container: self.update_plot(data_container) ) # Start loop that gets data from queue in Qt Thread self.work = Worker(self.run)
[docs] def run(self): while True: data_container = self.plot_queue.get() if type(data_container) == str: if data_container == "stop": break self.signals.new_data.emit(data_container)
[docs] def update_plot(self, data_container): delay_idx = data_container["delay index"] spectrum_2d = data_container["frequency domain 2d spectrum"] intp_signal = data_container["interpolated frequency domain 2d spectrum"] interferogram = data_container["interferogram"] pump_axes = data_container["pump frequency axis"] opa_range = data_container["opa range"] opa_spectrum = data_container["opa spectrum"] zerobin = data_container["zerobin"] time_domain_central_pixel = data_container["time domain 2d spectrum"][ 1, self.central_pixel ] npc_time_domain_central_pixel = data_container[ "non phase cycled time domain central pixel" ] probe_axis = data_container["probe axis"] avg_intensities = data_container["average intensity"] std_intensities = data_container["std intensity"] # Update titles with current delay self.widget_pyqtgraph.plots["single 2d-signal heatmap"].setTitle( "2D-signal for delay time {} fs".format(self.delays[delay_idx][0]) ) self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].setTitle( "Scan averaged 2D-signal for delay time {} fs".format( self.delays[delay_idx][0] ) ) # Update interferogram title with zerobin that the algorithm found self.widget_pyqtgraph.plots["interferogram"].setTitle( "Interferogram (zerobin: {})".format(zerobin[1]) ) #! Add corresponding phase to zerobin too. # Needs to be bigger than two because # we define two colorbar/histogram items # in init which are already in plot ref if len(self.plot_ref) > 2: # -----------Start 2D Plots--------------- # Update 2d image: probe frequency vs. pump frequency # Single Scan # Get old levels so it does not rescale the colors everytime (see below) levels = self.plot_ref["single 2d-signal heatmap histogram"].getLevels() self.plot_ref["single 2d-signal heatmap"].setImage(intp_signal[1]) # "Disable" autoscale after 0th scan has run. if data_container["scan index"] > 0: # Set to old levels of the histogram self.plot_ref["single 2d-signal heatmap histogram"].setLevels(*levels) # Update contour lines dp.update_contour_lines( intp_signal[1], self.plot_ref["single 2d-signal heatmap contours"] ) # Scan averaged # Get old levels so it does not rescale the colors everytime (see below) levels = self.plot_ref["avg 2d-signal heatmap histogram"].getLevels() self.plot_ref["avg 2d-signal heatmap"].setImage(intp_signal[0]) # "Disable" autoscale after 0th scan has run. if data_container["scan index"] > 0: # Set to old levels of the histogram self.plot_ref["avg 2d-signal heatmap histogram"].setLevels(*levels) # Update contour lines dp.update_contour_lines( intp_signal[0], self.plot_ref["avg 2d-signal heatmap contours"] ) # ----------End 2D Plots---------------- # Update interferogram # Scan averaged self.plot_ref["single interferogram"].setData( # x = probe_axis, #! Add mm or fs scale y=interferogram[1] ) # Avg scan self.plot_ref["avg interferogram"].setData( # x = probe_axis, #! Add mm or fs scale y=interferogram[0] ) # Update OPA spectrum # Single scan self.plot_ref["single opa spectrum"].setData( x=pump_axes[1][: opa_spectrum[1].size], y=opa_spectrum[1] ) # Scan averaged self.plot_ref["avg opa spectrum"].setData( x=pump_axes[0][: opa_spectrum[0].size], y=opa_spectrum[0] ) # Update time domain spectrum for central pixel for all wobbler states for i in range(npc_time_domain_central_pixel.shape[-1]): self.plot_ref["time-domain signal wobbler state {}".format(i)].setData( y=npc_time_domain_central_pixel[:, i] ) # Update phase cycled time domain spectrum in same plot self.plot_ref["time-domain signal"].setData(y=time_domain_central_pixel) # Update intensity error bars for probe array self.plot_ref["probe error bars"].setData( x=probe_axis, y=avg_intensities[self.adc.probe_pixel_idx], height=5 * std_intensities[self.adc.probe_pixel_idx], ) # Update intensity error bars for reference array self.plot_ref["ref error bars"].setData( x=probe_axis, y=avg_intensities[self.adc.reference_pixel_idx], height=5 * std_intensities[self.adc.reference_pixel_idx], ) # Update intensities self.plot_ref["probe intensities"].setData( x=probe_axis, y=avg_intensities[self.adc.probe_pixel_idx] ) self.plot_ref["ref intensities"].setData( x=probe_axis, y=avg_intensities[self.adc.reference_pixel_idx] ) # Update counts for a given interferometer position for wobbler_state in range(data_container["counts"].shape[-1]): self.plot_ref["stage counts {}".format(wobbler_state)].setData( y=data_container["counts"][ 0, :, wobbler_state ] # Choose pixel 0 - all pixels have the same counts ) # Plot interferometer position self.plot_ref["stage position"].setData( # x = pump_axes[1][opa_range[1]], #! add proper time scale y=data_container["interferometer positions"], #! rescale to fs or mm ) else: # First time plotting signal_pen = pg.mkPen(color="#d62728", width=2.5, style=QtCore.Qt.SolidLine) npc_signal_pen = pg.mkPen( color="#A9A9A9", width=1.2, style=QtCore.Qt.SolidLine ) avg_signal_pen = pg.mkPen( color="#17becf", width=2.5, style=QtCore.Qt.SolidLine ) position_pen = pg.mkPen( color="#9467bd", width=1.5, style=QtCore.Qt.SolidLine ) count_pen = pg.mkPen(color="#2ca02c", width=1, style=QtCore.Qt.SolidLine) probe_pen = pg.mkPen(color="#1f77b4", width=2.5, style=QtCore.Qt.SolidLine) reference_pen = pg.mkPen( color="#ff7f0e", width=2.5, style=QtCore.Qt.SolidLine ) std_intensity_pen = pg.mkPen( color="#7f7f7f", width=1.5, style=QtCore.Qt.SolidLine ) # ? dashed lines? bisector_pen = pg.mkPen( color="#7f7f7f", width=1.5, style=QtCore.Qt.SolidLine ) # Histogram colormap seismic = pg.graphicsItems.GradientEditorItem.Gradients["seismic"] # Plot 2d image: probe vs pump frequency (wavenumber) # Single scan self.plot_ref["single 2d-signal heatmap"] = pg.ImageItem(intp_signal[1]) # Generate a scrollable colorbar # This generates a histogram with # which it is possible to scale the # Data which will be displayed in # the heatmaps # First create a reference for the histogram # which containes the image item "single time-signal heatmap" # This basically means, that HistogramLUTItem contains the data # from the heatmap self.plot_ref["single 2d-signal heatmap histogram"].setImageItem( self.plot_ref["single 2d-signal heatmap"] ) # Set the color levels of the histogram self.plot_ref["single 2d-signal heatmap histogram"].gradient.restoreState( seismic ) # Get the range of the pump axis in which the OPA # emits light pump_axis = pump_axes[1][opa_range[1]] # Scale image to match axes dp.scale_img( probe_axis, pump_axis, intp_signal[1], self.plot_ref["single 2d-signal heatmap"], ) self.widget_pyqtgraph.plots["single 2d-signal heatmap"].addItem( self.plot_ref["single 2d-signal heatmap"] ) self.plot_ref["single 2d-signal heatmap"].setLookupTable(self.lut) # Generate contour lines self.plot_ref[ "single 2d-signal heatmap contours" ] = dp.generate_contour_lines( intp_signal[1], self.plot_ref["single 2d-signal heatmap"] ) # Add bisector to plot (we do not add it to plot_ref because we are not going to change it, ever) bisector = pg.InfiniteLine(angle=45, pen=bisector_pen) bisector.setZValue(10) self.widget_pyqtgraph.plots["single 2d-signal heatmap"].addItem(bisector) # Plot 2d image: probe vs pump frequency (wavenumber) # Scan averaged self.plot_ref["avg 2d-signal heatmap"] = pg.ImageItem(intp_signal[0]) self.plot_ref["avg 2d-signal heatmap histogram"].setImageItem( self.plot_ref["avg 2d-signal heatmap"] ) # Set the color levels of the histogram self.plot_ref["avg 2d-signal heatmap histogram"].gradient.restoreState( seismic ) # Get the range of the pump axis in which the OPA # emits light pump_axis = pump_axes[1][opa_range[1]] # Scale image to match axes dp.scale_img( probe_axis, pump_axis, intp_signal[0], self.plot_ref["avg 2d-signal heatmap"], ) self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].addItem( self.plot_ref["avg 2d-signal heatmap"] ) self.plot_ref["avg 2d-signal heatmap"].setLookupTable(self.lut) # Generate contour lines self.plot_ref["avg 2d-signal heatmap contours"] = dp.generate_contour_lines( intp_signal[1], self.plot_ref["avg 2d-signal heatmap"] ) # Add bisector to plot (we do not add it to plot_ref because we are not going to change it, ever) bisector = pg.InfiniteLine(angle=45, pen=bisector_pen) bisector.setZValue(10) self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].addItem(bisector) # Plot interferogram # Single scan self.plot_ref["single interferogram"] = self.widget_pyqtgraph.plots[ "interferogram" ].plot( # x = probe_axis, #! Add mm or fs scale y=interferogram[1], name="interferogram of this scan for current delay", pen=signal_pen, ) # Scan averaged self.plot_ref["avg interferogram"] = self.widget_pyqtgraph.plots[ "interferogram" ].plot( # x = probe_axis, #! Add mm or fs scale y=interferogram[0], name="average interferogram for current delay", pen=avg_signal_pen, ) # Plot OPA spectrum # Single scan self.plot_ref["single opa spectrum"] = self.widget_pyqtgraph.plots[ "opa spectrum" ].plot( x=pump_axes[1][: opa_spectrum[1].size], y=opa_spectrum[1], name="Pump OPA spectrum of this scan for current delay", pen=signal_pen, ) # Scan averaged self.plot_ref["avg opa spectrum"] = self.widget_pyqtgraph.plots[ "opa spectrum" ].plot( x=pump_axes[0][: opa_spectrum[0].size], y=opa_spectrum[0], name="average pump OPA spectrum for current delay", pen=avg_signal_pen, ) # Set x-axis range on pump OPA peak self.widget_pyqtgraph.plots["opa spectrum"].setRange( xRange=pump_axes[0][opa_range[0][[0, -1]]], padding=0 ) # Plot time domain spectrum for central pixel for all wobbler states for i in range(npc_time_domain_central_pixel.shape[-1]): self.plot_ref[ "time-domain signal wobbler state {}".format(i) ] = self.widget_pyqtgraph.plots["time-domain central pixel"].plot( y=npc_time_domain_central_pixel[:, i], name="wobbler state {}".format(i), pen=npc_signal_pen, ) # Plot phase cycled time domain spectrum in same plot self.plot_ref["time-domain signal"] = self.widget_pyqtgraph.plots[ "time-domain central pixel" ].plot( y=time_domain_central_pixel, name="phase cycled time domain signal", pen=signal_pen, ) # Create intensity error bars for probe array self.plot_ref["probe error bars"] = pg.ErrorBarItem( x=probe_axis, y=avg_intensities[self.adc.probe_pixel_idx], height=5 * std_intensities[self.adc.probe_pixel_idx], beam=0.3, pen=std_intensity_pen, ) self.widget_pyqtgraph.plots["intensities"].addItem( self.plot_ref["probe error bars"] ) # Create intensity error bars for reference array self.plot_ref["ref error bars"] = pg.ErrorBarItem( x=probe_axis, y=avg_intensities[self.adc.reference_pixel_idx], height=5 * std_intensities[self.adc.reference_pixel_idx], beam=0.3, pen=std_intensity_pen, ) self.widget_pyqtgraph.plots["intensities"].addItem( self.plot_ref["ref error bars"] ) # Plot intensities self.plot_ref["probe intensities"] = self.widget_pyqtgraph.plots[ "intensities" ].plot( x=probe_axis, y=avg_intensities[self.adc.probe_pixel_idx], name="Average intensities on probe array", pen=probe_pen, ) self.plot_ref["ref intensities"] = self.widget_pyqtgraph.plots[ "intensities" ].plot( x=probe_axis, y=avg_intensities[self.adc.reference_pixel_idx], name="Average intensities on reference array", pen=reference_pen, ) # Plot counts for a given stage position - (every wobbler state one line) for wobbler_state in range(data_container["counts"].shape[-1]): self.plot_ref[ "stage counts {}".format(wobbler_state) ] = self.widget_pyqtgraph.plots["counts"].plot( y=data_container["counts"][ 0, :, wobbler_state ], # Choose pixel 0 - all pixels have the same counts name="stage counts for wobbler state {}".format(wobbler_state), pen=count_pen, ) # Set y-axis range of count plots to "ignore"/overlook border cases self.widget_pyqtgraph.plots["counts"].setRange( yRange=[0, data_container["counts"][0, 1, 0] + 5], padding=0 ) # +5 to not cutoff the line # Plot interferometer position self.plot_ref["stage position"] = self.widget_pyqtgraph.plots[ "stage position" ].plot( # x = pump_axes[1][opa_range[1]], #! add proper time scale y=data_container["interferometer positions"], #! rescale to fs or mm pen=position_pen, )
[docs]class MplPlotting: #! Deprecated - technically works!
[docs] class Signals(QObject): new_data = pyqtSignal(dict)
def __init__(self, mpl_widget, adc: ADC, plot_queue, delays: ndarray): # Assign attributes self.adc = adc self.delays = delays # Multiprocessing Queue self.plot_queue = plot_queue self.mpl_widget = mpl_widget self.canvas = self.mpl_widget.canvas self.fig = self.canvas.figure # Setup signals and threadpool self.threadpool = QThreadPool() self.signals = self.Signals() # Clear old plots (clear figure) self.fig.clf() # Create dict that will hold references to axes self.axes = {} # Create dictionary that holds references # to all plotted lines self.plot_ref = {} # Create dictionary that holds references # to all colorbars self.cb_ref = {} # Select colormap for 2d plots self.cmap = cm.get_cmap("seismic") self.axes["single 2d spectrum"] = self.fig.add_subplot( 321, xlabel=r"probe frequency $\omega_1$ [cm$^{-1}$]", ylabel=r"pump frequency $\omega_3$ [cm$^{-1}$]", # title="current 2d spectrum - delay {} fs" ) self.axes["avg 2d spectrum"] = self.fig.add_subplot( 322, xlabel=r"probe frequency $\omega_1$ [cm$^{-1}$]", ylabel=r"pump frequency $\omega_3$ [cm$^{-1}$]", # title="average 2d spectrum - delay {} fs" ) self.axes["interferogram"] = self.fig.add_subplot( 323, xlabel=r"position", ylabel=r"intensity [a.u.]", # title="interferogram" ) self.axes["opa spectrum"] = self.fig.add_subplot( 324, xlabel=r"pump frequency $\omega_3$ [cm$^{-1}$]", ylabel=r"intensity [a.u.]", # title="OPA pump spectrum" ) self.axes["stage position"] = self.fig.add_subplot( 337, xlabel=r"time [s]", ylabel=r"interferometer counter value", # title="Position of interferometer stage" ) self.axes["stage counts"] = self.fig.add_subplot( 338, xlabel=r"interferometer position", ylabel=r"counts", # title="how often a position was measured" ) self.axes["intensities"] = self.fig.add_subplot( 339, xlabel=r"probe frequency $\omega_1$ [cm$^{-1}$]", ylabel=r"intensity [a.u.]", # title="Average intensities" ) # Connect signal that data has arrived to update the plot self.signals.new_data.connect( lambda data_container: self.update_plot(data_container) ) # Start loop that gets data from queue in Qt Thread self.work = Worker(self.run)
[docs] def run(self): while True: data_container = self.plot_queue.get() if type(data_container) == str: if data_container == "stop": break self.signals.new_data.emit(data_container)
[docs] def update_plot(self, data_container): # Plot for the first time to get line references delay_idx = data_container["delay index"] spectrum_2d = data_container["frequency domain 2d spectrum"] interferogram = data_container["interferogram"] pump_axes = data_container["pump frequency axis"] opa_range = data_container["opa range"] opa_spectrum = data_container["opa spectrum"] probe_axis = data_container["probe axis"] avg_intensities = data_container["average intensity"] std_intensities = data_container["std intensity"] # Update title with current delay # !!! # Clear old plots for ax in self.axes.items(): ax.cla() # Clear old colorbars for cb in self.cb_ref: cb.remove() # Current scan # Meshgrid for contour plots X, Y = np.meshgrid(probe_axis[::-1], pump_axes[1][opa_range[1]][::-1]) # Single scan self.plot_ref["single 2d spectrum"] = self.axes[ "single 2d spectrum" ].pcolormesh( X, Y, np.flip(spectrum_2d[1, delay_idx, :, opa_range[1]]).T, cmap=self.cmap ) # Add colorbar self.cb_ref["single 2d spectrum"] = self.fig.colorbar( self.plot_ref["single 2d spectrum"], self.axes["single 2d spectrum"] ) # Add countour lines self.plot_ref["single 2d spectrum contour"] = self.axes[ "single 2d spectrum" ].contour( X, Y, np.flip(spectrum_2d[1, delay_idx, :, opa_range[1]]).T, levels=25, colors="k", linewidths=1, ) # Scan averaged # Meshgrid for contour plots X, Y = np.meshgrid(probe_axis[::-1], pump_axes[0][opa_range[0]][::-1]) # Avg scan self.plot_ref["avg 2d spectrum"] = self.axes["avg 2d spectrum"].pcolormesh( X, Y, np.flip(spectrum_2d[0, delay_idx, :, opa_range[0]]).T, cmap=self.cmap ) # Add colorbar self.cb_ref["avg 2d spectrum"] = self.fig.colorbar( self.plot_ref["avg 2d spectrum"], self.axes["avg 2d spectrum"] ) # Add countour lines self.plot_ref["avg 2d spectrum contour"] = self.axes["avg 2d spectrum"].contour( X, Y, np.flip(spectrum_2d[0, delay_idx, :, opa_range[0]]).T, levels=25, colors="k", linewidths=1, ) # Plot interferogram self.plot_ref["single interferogram"] = self.axes["interferogram"].plot( interferogram[1][opa_range[1]], label="interferogram of this scan" ) self.plot_ref["avg interferogram"] = self.axes["interferogram"].plot( interferogram[0][opa_range[0]], label="average interferogram" ) # Plot OPA spectrum self.plot_ref["single opa spectrum"] = self.axes["opa spectum"].plot( pump_axes[1][opa_range[1]], opa_spectrum[1][opa_range[1]], label="OPA spectrum of this scan", ) self.plot_ref["avg opa spectrum"] = self.axes["opa spectum"].plot( pump_axes[0][opa_range[0]], opa_spectrum[0][opa_range[0]], label="average OPA spectrum", ) # Plot interferometer position self.plot_ref["stage position"] = self.axes["stage position"].plot( data_container["interferometer positions"] ) # Plot interferometer counts self.plot_ref["stage counts"] = self.axes["stage counts"].plot( data_container["counts"][0, delay_idx, 0] ) # Plot intensities self.plot_ref["avg probe intensity"] = self.axes["intensities"].errorbar( probe_axis, avg_intensities[self.adc.probe_pixel_idx], yerr=5 * std_intensities[self.adc.probe_pixel_idx], label="Average intensities on probe array", ) self.plot_ref["avg ref intensity"] = self.axes["intensities"].errorbar( probe_axis, avg_intensities[self.adc.reference_pixel_idx], yerr=5 * std_intensities[self.adc.reference_pixel_idx], label="Average intensities on reference array", ) # Activate legends self.axes["intensities"].legend() # self.fig.tight_layout() #??????? #! which one? idle or not # Trigger the canvas to update and redraw. # self.canvas.draw_idle() self.canvas.draw() # ???????