"""
Providing the "FT VIPER" 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). The IR pump
pulse pair is used to excite the sample into the first vibrational
state. A second, chopped UV/VIS pump pulse then excites the molecules
from that higher vibrational state to an electronically excited state
that is also vibrationally excited. A probe pulse is then used to
determine the molecular response. The IR pump pulse must temporally come
before the UV/VIS pump pulse. Therefore the stages need to be set
accordingly. To reduce IR pump light scattering on the detector, a
wobbler is used to phase cycle the pump pulse. The UV/VIS chopper, and
only the UV/VIS chopper needs to be running for this experiment. To
prevent the sample from being burnt a micrometer screw is used to
continuously move the sample up and down.
VIPER stands for Vibrationally Promoted Electronic Resonance. Time
domain VIPER experiments use 1 chopper. The IR Pump comes from the
Michelson interferometer and its molecular response is measured in the
time domain, thus no chopper is required. The UV/VIS pump pulse is
chopped. Frequency domain VIPER measurements typically contain 4
different types of signals. However, time domain VIPER measurements are
calculated differently.
All different *time domain* difference signals:
* (IR on, UV off): IR pump/IR probe
* (IR on, UV on): VIPER + IR pump/IR probe
The Background and the TRIR signal do not cause an oscillating signal in
the time domain - only an offset which is removed upon fourier
transform.
##########################################
Procedure to Start a FT VIPER Measurement:
##########################################
1. Wait until laser is warmed up and measure output
2. Optimize OPA (Optical parametric amplifier) 1 (IR probe) and OPA 2
(IR pump) on output power
3. Align beam pointing on iris for the delayline. For that, use the
"Ophir" powermeter and turn off the dry air because of the
sensitivity of the powermeter. It observes the fluctuations and
cannot return the laser power reliably
4. Optimize OPA 3 (UV pump) on output power at the sample
5. Set the overlap of the pump OPAs with the probe OPA using a pinhole
or a sample. For this, reduce the UV/VIS power (with filter) below
0.5 microJ for pinhole or GaAs (Gallium arsenide)
6. Optimize the chopper phases, delays and Wobbler (see
show_signal_wobbler for detailed procedure). This step is difficult
and crucial. FT Viper: UV/VIS 1/8, Wobbler 1/4. Note that it may be
necessary to setup both the IR and UV/VIS chopper to adjust the
choppers and observe a VIPER signal in show_viper_wobbler experiment
even though the FT-Viper only needs the UV/VIS chopper. For steps 7
and 8 show_signal_wobbler (UV and IR), show_viper_wobbler are
required
7. Check chopper phases using photo diodes (if available in setup). The
pyroelectric detector on the oscilloscope can also give insight into
the chopper phase
8. Optimize the chopper output phases by optimizing signal size
9. Measure time t zero (temporal overlap) for both OPAs with scan tzero
experiment. For this, block the movable path of the interferometer
10. Measure the temporal overlap using scan t zero experiment of the
movable path of the interferometer. For that, block the static path
11. Optimize the spatial overlap in the sample for OPA 3 and for the
movable path of the interferometer of OPA 2 using the overlap
mirrors. For the static path of the interferometer use the second
mirror of the static path
12. Optimize the chopper phases using the show_viper_wobbler experiment
(colorful experiment). Also check the background at -20,000 fs.
It should be zero
13. Optimize the delay between the pump pulses
14. **Cry** and probably repeat from step 6
15. If everything up until now works (wow you are a wizard!) turn
off the IR Chopper. If the IR chopper is still on, the interferogram
is going to be unsightly
16. Set the wavegeneration parameters appropriately for the molecule
that you want to measure (i.e. coherence time 2ps, frequency 0.4 Hz
speedupdown 150, overshoot factor 0.1). This step requires a lot
of testing, especially if this was never done before
17. Refill the detector, check your file name, check if the data
will be saved (for important measurements raw data should always be
saved)
18. Start the FT-Viper experiment
19. **Go home crying**
References:
For understanding the general procedure and setup:
* ft_2d_ir.py (for wavegeneration and alignment of the
interferometer. Additional information on the pyroelectric
detector and the photodiodes as well as the R-2R network can
be found there)
* show_signal_wobbler.py
* show_wobbler_states.py
* show_viper_wobbler.py
Note:
**Delay Files:**
If more than one delay file is required for an experiment (e.g.
VIPER) the weights of all files are multiplied to yield a
resulting weight. Generally, the delay files have to increase
monotonously. For VIPER experiments the UV/VIS delay file does
not have to increase monotonously. The IR delay file has to. For
VIPER the delays files need to have a user given offset (time
delay between the pump pulses). This is due to the fact that the
IR pump pulse should temporally "arrive" before the UV/VIS pump
pulse such that the VIPER signal is maximal. This time delay can
be found by conducting a broadband VIPER experiment where the
UV/VIS Stage is "scanned" while the IR delay remains static and
then observing for which relative delay between the stages the
VIPER signal is maximal. We observed that it can happen that the
stages have to be set in the opposite way (as if the UV/VIS pump
pulse arrives before the IR pump pulse. For examples stage
positions such as IR delay stage 20,500 fs UV/VIS delay stage
20,000 fs might yield a good VIPER signal). This is can be
attributed to the imprecision in determining the t zero position
for each of the stages separately.
#######################
Step by Step Algorithm:
#######################
**Acquisition:**
1. Preallocate dictionary (data container) which will contain data
and information about scan index, delay index etc.
2. Close the UV/VIS Shutter to avoid unnecessary exposition
to UV/VIS pump light
3. Start moving the micrometer screw continuously
4. 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)
5. Reset the interferometer counter value to zero
6. Move the interferometer stage to the starting position of
the wavegeneration
7. Start wavegeneration
8. Close UV/VIS shutter
9. Move IR and UV/VIS stage simultaneously to next respective delay
10. Open UV/VIS Shutter
11. Read the data from the ADC
12. Place data into dictionary and hand it over to
primary processing
**Primary Processing:**
1. Preallocate arrays for data, counts, weights, chopper.
Here there are 2 states for each chopper. Obtain the Chopper
voltage levels and calculate the Wobbler states from the
laser frequency and wobbler frequency.
2. Subtract background from raw data (dark noise)
3. Linearize response of pixels
4. Calculate transmission, or more precisely, relative intensity
(probe intensity / reference intensity) for each laser shot for
each pixel pair
5. Calculate interferometer positions from R-2R data for each laser
shot. Check if minimum and maximum interferometer positions were
observed.
6. Identify the chopper states for all laser shots using the
corresponding channel(s) in the ADCs' data
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 viper
spectrum, time domain 2d ir spectrum, freq domain viper
spectrum, freq domain 2d ir spectrum, zerobin, pump frequency
axis, opa range, central pump wavenumber, opa spectrum,
interpolated phase cycled viper signal, interpolated phase
cycled 2d ir signal
2. Obtain the interferogram. For this we need the phase cycled
counts because we want to average the interferograms of the
UV/VIS chopper being turned open and closed (using the phase
cycle counts as weights can fail, therefore use try except
statements to make sure that the interferogram etc. are still
displayed)
3. Calculate all the different *time domain* difference signals
Last axis UV/VIS pump on-off
Beginners notes (*IR Pump in time domain*):
* (IR on, UV off): IR pump/IR probe
* (IR on, UV on): VIPER + IR pump/IR probe
The Background and the TRIR signal do not cause an oscillating
signal in the time domain - only an offset which is removed upon
fourier transform
4. Process the single scan data:
* Calculate frequency domain VIPER absorption spectrum
* Calculate frequency domain 2D-IR absorption spectrum
* Extract the relevant information regarding the spectrum of the OPA
* Obtain pump frequency axis
5. Process averaged data:
* Calculate frequency domain VIPER absorption spectrum
* Calculate frequency domain 2D-IR absorption spectrum
* Extract the relevant information regarding the spectrum of the OPA
* Obtain pump frequency axis
6. Use try except statements to make sure that the information
about the interferometer can still be displayed even if an
error occurs (e.g. if the wavegeneration parameters are chosen
poorly)
7. Calculate VIPER time domain spectrum for each wobbler state
for central pixel to double check whether phase cycling
is working
8. Calculate the average intensity for each pixel
9. Calculate the average intensities and standard deviation of
the intensities for each pixel
10. Put this information into data container and hand it over to
Pyqtplotting thread
11. 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 ir signal heatmap with histogram
* Single viper signal heatmap with histogram
* Average viper 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_vis_chopper_states, n_wobbler_states)]
saving dimension:
[(n_probe_pixels, n_interferometer_states, n_vis_chopper_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
from newport_control import NewportControl as MuScrew
from shutter import Shutter
# Set up logger
import logging
logger = logging.getLogger(__name__)
[docs]class FtViper:
"""
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).
vis_delays (ndarray): Array containing the delays in fs for the
UV/VIS stage 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. These
delays need to be temporally offset relative to the IR
delays.
* shape: 2D
* E.g.: (number of delays, 2)
ir_delays (ndarray): Array containing the delays in fs for the
IR stage 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. These delays need
to be temporally offset relative to the IR delays.
* 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.
vis_delay_stage (PiStage): PiStage hardware control object which
provides the interface to the UV/VIS delay stage.
ir_delay_stage (PiStage): PiStage hardware control object which
provides the interface to the IR delay stage.
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).
vis_chopper_info (dict): Contains the information that is
necessary to identify the different chopper states of the
chopper that chops the UV/VIS pump pulse. It contains the
keys "high voltage level" and "name". "high voltage level"
is the voltage read by the ADC when the chopper reference
output is high. It is needed as a reference for the
digitization function that is used. The "name" key is
required to determine to which channel of the adc the
chopper is connected and its value needs to match the
corresponding key in index_dict.
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.
mu_screw (MuScrew): NewportControl object that can be used to
move the micrometer screw.
vis_shutter (Shutter): Shutter object that can be used to open
and close the UV/VIS (pump) shutter.
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,
vis_delays: ndarray,
ir_delays: ndarray,
adc: ADC,
vis_delay_stage: PiStage,
ir_delay_stage: PiStage,
interferometer_stage: PiStage,
interferometer_counter: InterferometerCounter,
prl: PRL,
vis_chopper_info: dict,
he_ne_wl: float,
wobbler_freq: float,
background_handler: Background,
spectrometer: Spectrometer,
mu_screw: MuScrew,
vis_shutter: Shutter,
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(vis_delays, "vis_delay_file")
saver.save_other(ir_delays, "ir_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(
vis_delays,
ir_delays,
adc,
vis_delay_stage,
ir_delay_stage,
interferometer_stage,
interferometer_counter,
spectrometer,
mu_screw,
vis_shutter,
self.acq_queue,
)
self.primary_processing = PrimaryProcessing(
self.acq_queue,
self.processing_queue,
vis_delays,
ir_delays,
adc.pixel_idx,
adc.probe_pixel_idx,
adc.reference_pixel_idx,
adc.index_dict,
interferometer_counter.r2r_indices,
prl,
vis_chopper_info,
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,
vis_delays,
ir_delays,
adc.probe_pixel_idx,
interferometer_states,
self.central_pixel,
saver,
)
self.plotting = PyqtPlotting(
widget_pyqtgraph,
adc,
self.plot_queue,
vis_delays,
ir_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:
vis_delays (ndarray): Array containing the delays in fs for the
UV/VIS stage 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. These
delays need to be temporally offset relative to the IR
delays.
* shape: 2D
* E.g.: (number of delays, 2)
ir_delays (ndarray): Array containing the delays in fs for the
IR stage 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. These delays need
to be temporally offset relative to the IR delays.
* 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.
vis_delay_stage (PiStage): PiStage hardware control object which
provides the interface to the UV/VIS delay stage.
ir_delay_stage (PiStage): PiStage hardware control object which
provides the interface to the IR delay stage.
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.
mu_screw (MuScrew): NewportControl object that can be used to
move the micrometer screw.
vis_shutter (Shutter): Shutter object that can be used to open
and close the UV/VIS (pump) shutter.
acq_queue (Queue): Multiprocessing queue object that the
acquisition thread uses to pass data to the primary
processing process.
"""
def __init__(
self,
vis_delays: ndarray,
ir_delays: ndarray,
adc: ADC,
vis_delay_stage: PiStage,
ir_delay_stage: PiStage,
interferometer_stage: PiStage,
interferometer_counter: InterferometerCounter,
spectrometer: Spectrometer,
mu_screw: MuScrew,
vis_shutter: Shutter,
acq_queue: Queue,
):
threading.Thread.__init__(self)
# Assign attributes
self.vis_delays = vis_delays
self.ir_delays = ir_delays
self.adc = adc
# Hardware devices
self.vis_delay_stage = vis_delay_stage
self.ir_delay_stage = ir_delay_stage
self.interferometer_stage = interferometer_stage
self.interferometer_counter = interferometer_counter
self.spectrometer = spectrometer
self.mu_screw = mu_screw
self.vis_shutter = vis_shutter
# 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()
# ----------- Prepare Acquisition -------
# ------- Close UV/VIS Shutter
# to avoid unnecessary exposition to
# UV/VIS pump light. The next time it is
# needed is during the actual measurement
# process. (It is not needed for the
# setup process.)
self.vis_shutter.close()
# ------- Start moving the mircometer screw
# back and forth continuously
# if it was not doing that already
if not self.mu_screw.continuously_moving:
self.mu_screw.toggle_continuous_movement()
# -------- 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 UV/VIS and IR
# delays at the same time
for d_idx, delays in enumerate(zip(self.vis_delays, self.ir_delays)):
# First close UV/VIS shutter before moving stages
# to avoid unnecessary exposition to
# UV/VIS pump light
self.vis_shutter.close()
# Unpack delays for better clarity
vis_delay, ir_delay = delays
# At this point the way we are using delays
# files has become ambiguous because we
# can specify weights in both the UV/Vis
# and the IR delay file
# Here we decide to multiply the weights
# to get a resulting total weight
weight = vis_delay[1] * ir_delay[1]
# Set samples to acquire according to weight
# for these delay
samples_to_acquire = int(round(self.base_samples * weight))
self.adc.set_samples_to_acquire(samples_to_acquire)
# Move both stages simultaneously
# (thats why wait = False)
self.vis_delay_stage.move(vis_delay[0], wait=False)
self.ir_delay_stage.move(ir_delay[0], wait=False)
#! If micrometer screw is supposed to move step wise
#! insert here
# Now wait for both stages to reach their positions
self.vis_delay_stage.wait()
self.ir_delay_stage.wait()
# To measure data open shutter
self.vis_shutter.open()
# 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()
# Close UV/Vis shutter so sample
# does not get burnt more than necessary
self.vis_shutter.close()
# 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.
vis_delays (ndarray): Array containing the delays in fs for the
UV/VIS stage 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. These
delays need to be temporally offset relative to the IR
delays.
* shape: 2D
* E.g.: (number of delays, 2)
ir_delays (ndarray): Array containing the delays in fs for the
IR stage 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. These delays need
to be temporally offset relative to the IR delays.
* 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).
vis_chopper_info (dict): Contains the information that is
necessary to identify the different chopper states of the
chopper that chops the UV/VIS pump pulse. It contains the
keys "high voltage level" and "name". "high voltage level"
is the voltage read by the ADC when the chopper reference
output is high. It is needed as a reference for the
digitization function that is used. The "name" key is
required to determine to which channel of the adc the
chopper is connected and its value needs to match the
corresponding key in index_dict.
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,
vis_delays: ndarray,
ir_delays: ndarray,
pixel_idx: ndarray,
probe_pixel_idx: ndarray,
reference_pixel_idx: ndarray,
index_dict: dict,
r2r_indices: ndarray,
prl: PRL,
vis_chopper_info: dict,
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
# This is required for the sort_data function
# The number of interferometer states we expect to
# observe have already been calculated
self.interferometer_states = interferometer_states
# In this experiment we have two different (UV/VIS) chopper states
self.n_chopper_states = 2
# 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.n_chopper_states, self.wobbler_states]
)
# ---- Save chopper high voltage level divided by 2 as list
# We need this for the digitize function that identifies
# the High and Low Chopper
# We select half of the high voltage as the limit
# at which the distinction between states is done
# - UV/VIS chopper -
self.vis_chopper_voltage_level = [vis_chopper_info["high voltage level"][0] / 2]
# The chopper name is needed to retrieve the index (row)
# within the adc raw data that corresponds to this
# chopper
self.vis_chopper_name = vis_chopper_info["name"][0]
# --- 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.n_chopper_states, # UV/VIS chopper open/closed
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, n_chopper_states)
# Phase cycled = scatter free
self.phase_cycled_data = np.zeros(
(
2, # averaged and non averaged (scan)
ir_delays.shape[
0
], # We only need one delay dimension because we always move both stages simultaneously
self.probe_pixel_idx.size
+ 1, # +1 because of interferogram / pyro detector channel
self.interferometer_states, # all interferometer positions
self.n_chopper_states,
) # UV/VIS chopper open/closed
)
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 the corresponding chopper state for each shot
# -UV/VIS Chopper-
vis_chopper_states = np.digitize(
raw_data[self.index_dict[self.vis_chopper_name]],
self.vis_chopper_voltage_level,
)
print(vis_chopper_states[:30])
# 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, chopper_states
# 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, vis_chopper_states, 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["phase cycled counts"] = self.phase_cycled_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.
vis_delays (ndarray): Array containing the delays in fs for the
UV/VIS stage 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. These
delays need to be temporally offset relative to the IR
delays.
* shape: 2D
* E.g.: (number of delays, 2)
ir_delays (ndarray): Array containing the delays in fs for the
IR stage 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. These delays need
to be temporally offset relative to the IR delays.
* 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,
vis_delays: ndarray,
ir_delays: ndarray,
probe_pixel_idx: ndarray,
interferometer_states: int,
central_pixel,
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
# Delays
self.ir_delays = ir_delays
self.vis_delays = vis_delays
# 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_viper_spectrum = np.zeros(
(
2,
ir_delays.shape[0],
self.probe_pixel_idx.size,
self.interferometer_states,
)
)
self.time_domain_2d_ir_spectrum = np.zeros(
(
2,
ir_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_viper_spectrum = [None, None]
self.freq_domain_2d_ir_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_viper_signal = [None, None]
self.intp_2d_ir_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"]
# Get interferogram
# For this we need the phase cycled counts because
# we want to average interferograms for vis chopper
# on and off because they should not be different.
# (The UV/VIS chopper does not influence the
# IR interferometer)
phase_cycled_counts = data_container["phase cycled data"][:, -1, delay_idx]
# Using the phase_cycled_counts as weights
# can fail if the averaging in primary processing failed
# for this reason we put it into a try statement
try:
# The last entry on the 2nd axis of sorted_data holds the
# interferograms (averaged and single scan) for both chopper
# states
interferogram = np.average(
sorted_data[:, delay_idx, -1],
axis=2, # This is the axis of the chopper states
weights=phase_cycled_counts,
)
except ZeroDivisionError:
interferogram = np.average(
sorted_data[:, delay_idx, -1],
axis=2, # This is the axis of the chopper states
# Do not use any weights if weighted average failed.
)
# Calculate time domain absorption for vis chopper on and off
self.time_domain_absorption = -np.log10(
sorted_data[:, delay_idx, :-1]
) # The last entry on the 2nd axis is the interferogram
# Now calculate all the different *time domain* difference signals
# Last axis UV/VIS pump on-off
# Beginners notes (*IR Pump in time domain*):
# (IR on, UV off): IR pump/IR probe
# (IR on, UV on): VIPER + IR pump/IR probe
# The Background and the TRIR signal do not cause
# an oscillating signal in the time domain - only
# an offset which is removed upon fourier transform
self.time_domain_2d_ir_spectrum[:, delay_idx] = self.time_domain_absorption[
:, :, :, 0
]
self.time_domain_viper_spectrum[:, delay_idx] = (
self.time_domain_absorption[:, :, :, 1]
- self.time_domain_absorption[:, :, :, 0]
)
# 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 VIPER absorption spectrum
# * At the moment we use no window function for fft
self.freq_domain_viper_spectrum[1], ifgr_info = dp.process_ft2dir_data(
self.time_domain_viper_spectrum[1, delay_idx],
interferogram[1], # this entry holds the interferogram
)
# Calculate frequency domain 2D-IR absorption spectrum
# * At the moment we use no window function for fft
self.freq_domain_2d_ir_spectrum[1], _ = dp.process_ft2dir_data(
self.time_domain_2d_ir_spectrum[1, delay_idx],
interferogram[1], # this entry holds the interferogram
)
# The ifgr_info (interferogram info) is the same in both cases
# thats why we disregard the second one.
# 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]
] # Pump axis is the same for both VIPER and 2D-IR
# VIPER
self.intp_viper_signal[1] = dp.generate_img_data(
data_container["probe axis"],
pump_axis,
self.freq_domain_viper_spectrum[1]
.take(self.opa_range[1], axis=-1)
.T,
)
# 2D-IR
self.intp_2d_ir_signal[1] = dp.generate_img_data(
data_container["probe axis"],
pump_axis,
self.freq_domain_2d_ir_spectrum[1]
.take(self.opa_range[1], axis=-1)
.T,
)
# --- Process averaged data ---
# Calculate frequency domain VIPER absorption spectrum
# * At the moment we use no window function for fft
self.freq_domain_viper_spectrum[0], ifgr_info = dp.process_ft2dir_data(
self.time_domain_viper_spectrum[0, delay_idx],
interferogram[0], # this entry holds the interferogram
)
# Calculate frequency domain 2D-IR absorption spectrum
# * At the moment we use no window function for fft
self.freq_domain_2d_ir_spectrum[0], _ = dp.process_ft2dir_data(
self.time_domain_2d_ir_spectrum[0, delay_idx],
interferogram[0], # this entry holds the interferogram
)
# The ifgr_info (interferogram info) is the same in both cases
# thats why we disregard the second one.
# 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]
] # Pump axis is the same for both VIPER and 2D-IR
# VIPER
self.intp_viper_signal[0] = dp.generate_img_data(
data_container["probe axis"],
pump_axis,
self.freq_domain_viper_spectrum[0]
.take(self.opa_range[0], axis=-1)
.T,
)
# 2D-IR
self.intp_2d_ir_signal[0] = dp.generate_img_data(
data_container["probe axis"],
pump_axis,
self.freq_domain_2d_ir_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_viper_spectrum[0] = np.zeros(
self.time_domain_viper_spectrum[0, delay_idx].shape
)
self.freq_domain_2d_ir_spectrum[0] = np.zeros(
self.time_domain_2d_ir_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_viper_signal[0] = np.zeros(
self.time_domain_viper_spectrum[0, delay_idx].shape
)
self.intp_2d_ir_signal[0] = np.zeros(
self.time_domain_2d_ir_spectrum[0, delay_idx].shape
)
self.freq_domain_viper_spectrum[1] = np.zeros(
self.time_domain_viper_spectrum[1, delay_idx].shape
)
self.freq_domain_2d_ir_spectrum[1] = np.zeros(
self.time_domain_2d_ir_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_viper_signal[1] = np.zeros(
self.time_domain_viper_spectrum[1, delay_idx].shape
)
self.intp_2d_ir_signal[1] = np.zeros(
self.time_domain_2d_ir_spectrum[1, delay_idx].shape
)
# Calculate VIPER 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
# abs = absorption
npc_time_domain_abs_central_pixel = -np.log10(
data_container["sorted data"][self.central_pixel]
)
npc_time_domain_viper_central_pixel = (
npc_time_domain_abs_central_pixel[:, 1]
- npc_time_domain_abs_central_pixel[:, 0]
)
# 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
# VIPER
data_container[
"time domain viper spectrum"
] = self.time_domain_viper_spectrum[:, delay_idx]
data_container[
"non phase cycled time domain viper central pixel"
] = npc_time_domain_viper_central_pixel
data_container[
"frequency domain viper spectrum"
] = self.freq_domain_viper_spectrum
data_container[
"interpolated frequency domain viper spectrum"
] = self.intp_viper_signal
# 2D IR
data_container[
"time domain 2d-ir spectrum"
] = self.time_domain_2d_ir_spectrum[:, delay_idx]
data_container[
"frequency domain 2d-ir spectrum"
] = self.freq_domain_2d_ir_spectrum
data_container[
"interpolated frequency domain 2d-ir spectrum"
] = self.intp_2d_ir_signal
# Interferometer
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
# MCT
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.
vis_delays (ndarray): Array containing the delays in fs for the
UV/VIS stage 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. These
delays need to be temporally offset relative to the IR
delays.
* shape: 2D
* E.g.: (number of delays, 2)
ir_delays (ndarray): Array containing the delays in fs for the
IR stage 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. These delays need
to be temporally offset relative to the IR delays.
* 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,
vis_delays: ndarray,
ir_delays: ndarray,
central_pixel: int,
):
# Assign attributes
self.adc = adc
self.vis_delays = vis_delays
self.ir_delays = ir_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=6
)
# Setup plots that hold heatmap 2D plot y-axis: pump axis, x-axis: probe axis
self.widget_pyqtgraph.plots[
"single 2d-ir-signal heatmap"
] = self.widget_pyqtgraph.plots["upper layout"].addPlot()
self.widget_pyqtgraph.plots["single 2d-ir-signal heatmap"].setTitle(
"2D-IR-signal - IR delay {} fs"
)
self.widget_pyqtgraph.plots["single 2d-ir-signal heatmap"].setLabel(
"bottom", "probe frequency ω<sub>1</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.plots["single 2d-ir-signal heatmap"].setLabel(
"left", "pump frequency ω<sub>3</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.set_style(
self.widget_pyqtgraph.plots["single 2d-ir-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-ir-signal heatmap histogram"] = pg.HistogramLUTItem()
self.widget_pyqtgraph.plots["upper layout"].addItem(
self.plot_ref["single 2d-ir-signal heatmap histogram"]
)
self.widget_pyqtgraph.plots[
"single viper-signal heatmap"
] = self.widget_pyqtgraph.plots["upper layout"].addPlot()
self.widget_pyqtgraph.plots["single viper-signal heatmap"].setTitle(
"VIPER-signal - UV/VIS delay {} fs"
)
self.widget_pyqtgraph.plots["single viper-signal heatmap"].setLabel(
"bottom", "probe frequency ω<sub>1</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.plots["single viper-signal heatmap"].setLabel(
"left", "pump frequency ω<sub>3</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.set_style(
self.widget_pyqtgraph.plots["single viper-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 viper-signal heatmap histogram"] = pg.HistogramLUTItem()
self.widget_pyqtgraph.plots["upper layout"].addItem(
self.plot_ref["single viper-signal heatmap histogram"]
)
self.widget_pyqtgraph.plots[
"avg viper-signal heatmap"
] = self.widget_pyqtgraph.plots["upper layout"].addPlot()
self.widget_pyqtgraph.plots["avg viper-signal heatmap"].setTitle(
"Scan averaged VIPER"
)
self.widget_pyqtgraph.plots["avg viper-signal heatmap"].setLabel(
"bottom", "probe frequency ω<sub>1</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.plots["avg viper-signal heatmap"].setLabel(
"left", "pump frequency ω<sub>3</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.set_style(
self.widget_pyqtgraph.plots["avg viper-signal heatmap"]
)
self.plot_ref["avg viper-signal heatmap histogram"] = pg.HistogramLUTItem()
self.widget_pyqtgraph.plots["upper layout"].addItem(
self.plot_ref["avg viper-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 ω<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 ω<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"]
intp_viper_signal = data_container[
"interpolated frequency domain viper spectrum"
]
intp_2d_ir_signal = data_container[
"interpolated frequency domain 2d-ir 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 viper spectrum"][
1, self.central_pixel
]
npc_time_domain_central_pixel = data_container[
"non phase cycled time domain viper 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-ir-signal heatmap"].setTitle(
"2D-IR-signal - IR delay {} fs".format(self.ir_delays[delay_idx][0])
)
self.widget_pyqtgraph.plots["single viper-signal heatmap"].setTitle(
"VIPER-signal - UV/VIS delay {} fs".format(self.vis_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 three because
# we define three colorbar/histogram items
# in init which are already in plot ref
if len(self.plot_ref) > 3:
# -----------Start 2D Plots---------------
# Update 2D-IR 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-ir-signal heatmap histogram"].getLevels()
self.plot_ref["single 2d-ir-signal heatmap"].setImage(intp_2d_ir_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-ir-signal heatmap histogram"].setLevels(
*levels
)
# Update contour lines
dp.update_contour_lines(
intp_2d_ir_signal[1],
self.plot_ref["single 2d-ir-signal heatmap contours"],
)
# 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 viper-signal heatmap histogram"].getLevels()
self.plot_ref["single viper-signal heatmap"].setImage(intp_viper_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 viper-signal heatmap histogram"].setLevels(
*levels
)
# Update contour lines
dp.update_contour_lines(
intp_viper_signal[1],
self.plot_ref["single viper-signal heatmap contours"],
)
# Scan averaged
# Get old levels so it does not rescale the colors everytime (see below)
levels = self.plot_ref["avg viper-signal heatmap histogram"].getLevels()
self.plot_ref["avg viper-signal heatmap"].setImage(intp_viper_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 viper-signal heatmap histogram"].setLevels(*levels)
# Update contour lines
dp.update_contour_lines(
intp_viper_signal[0], self.plot_ref["avg viper-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 stage position - (every wobbler state and every chopper one line)
for chopper_state in range(data_container["counts"].shape[-2]):
for wobbler_state in range(data_container["counts"].shape[-1]):
self.plot_ref[
"stage counts c{} w{}".format(chopper_state, wobbler_state)
].setData(
data_container["counts"][
0, :, chopper_state, 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),
pg.mkPen(color="#17becf", 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-IR image: probe vs pump frequency (wavenumber)
# Single scan
self.plot_ref["single 2d-ir-signal heatmap"] = pg.ImageItem(
intp_2d_ir_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-ir-signal heatmap histogram"].setImageItem(
self.plot_ref["single 2d-ir-signal heatmap"]
)
# Set the color levels of the histogram
self.plot_ref[
"single 2d-ir-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_2d_ir_signal[1],
self.plot_ref["single 2d-ir-signal heatmap"],
)
self.widget_pyqtgraph.plots["single 2d-ir-signal heatmap"].addItem(
self.plot_ref["single 2d-ir-signal heatmap"]
)
self.plot_ref["single 2d-ir-signal heatmap"].setLookupTable(self.lut)
# Generate contour lines
self.plot_ref[
"single 2d-ir-signal heatmap contours"
] = dp.generate_contour_lines(
intp_2d_ir_signal[1], self.plot_ref["single 2d-ir-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-ir-signal heatmap"].addItem(bisector)
# Plot VIPER image: probe vs pump frequency (wavenumber)
# Single scan
self.plot_ref["single viper-signal heatmap"] = pg.ImageItem(
intp_viper_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 viper-signal heatmap histogram"].setImageItem(
self.plot_ref["single viper-signal heatmap"]
)
# Set the color levels of the histogram
self.plot_ref[
"single viper-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_viper_signal[1],
self.plot_ref["single viper-signal heatmap"],
)
self.widget_pyqtgraph.plots["single viper-signal heatmap"].addItem(
self.plot_ref["single viper-signal heatmap"]
)
self.plot_ref["single viper-signal heatmap"].setLookupTable(self.lut)
# Generate contour lines
self.plot_ref[
"single viper-signal heatmap contours"
] = dp.generate_contour_lines(
intp_viper_signal[1], self.plot_ref["single viper-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 viper-signal heatmap"].addItem(bisector)
# Plot 2d image: probe vs pump frequency (wavenumber)
# Scan averaged
self.plot_ref["avg viper-signal heatmap"] = pg.ImageItem(
intp_viper_signal[0]
)
self.plot_ref["avg viper-signal heatmap histogram"].setImageItem(
self.plot_ref["avg viper-signal heatmap"]
)
# Set the color levels of the histogram
self.plot_ref["avg viper-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_viper_signal[0],
self.plot_ref["avg viper-signal heatmap"],
)
self.widget_pyqtgraph.plots["avg viper-signal heatmap"].addItem(
self.plot_ref["avg viper-signal heatmap"]
)
self.plot_ref["avg viper-signal heatmap"].setLookupTable(self.lut)
# Generate contour lines
self.plot_ref[
"avg viper-signal heatmap contours"
] = dp.generate_contour_lines(
intp_viper_signal[1], self.plot_ref["avg viper-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 viper-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 and every chopper one line)
for chopper_state in range(data_container["counts"].shape[-2]):
for wobbler_state in range(data_container["counts"].shape[-1]):
self.plot_ref[
"stage counts c{} w{}".format(chopper_state, wobbler_state)
] = self.widget_pyqtgraph.plots["counts"].plot(
y=data_container["counts"][
0, :, chopper_state, wobbler_state
], # Choose pixel 0 - all pixels have the same counts
name="stage counts for chopper state {} wobbler state {}".format(
chopper_state, wobbler_state
),
pen=count_pen[chopper_state],
)
# 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, 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,
)