"""
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 ω<sub>1</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.plots["single 2d-signal heatmap"].setLabel(
"left", "pump frequency ω<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 ω<sub>1</sub> [cm<sup>-1</sup>]"
)
self.widget_pyqtgraph.plots["avg 2d-signal heatmap"].setLabel(
"left", "pump frequency ω<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 ω<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"]
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() # ???????