"""
This class is used to communicate with a Fabry Perot device which is
tuned via Analog Voltage Output. The Analog Digital Converter is
therefore needed.
"""
# Helper modules
from typing import List, Tuple
import logging
# Import relevant modules
import nidaqmx as ni
from nidaqmx import task as ni_task
from nidaqmx.constants import VoltageUnits
import numpy as np
from queue import Queue
from analog_digital_converter import AnalogDigitalConverter
from shutter import Shutter
from triax import Triax
from hardware_properties import HardwareProperties
from data_processing import PixelResponseLinearization
# Set up logger
logger = logging.getLogger(__name__)
[docs]class FabryPerotVoltage:
"""
This class can be used to set the analog voltage of the Fabry-Perot
etalon.
It is assumed that a National Instruments nidaqmx capable device is
used to provide the analog output voltage.
Args:
device_name (str): Name of device on which the appropriate
analog-out port is located. The name can be found and configured
in the National Instruments Software: 'Measurement and
Automation Explorer' (MAX). The device name should also be
displayed in a pop-up when plugging in the device.
* E.g.: "Dev0", "Dev1" etc.
channel (str): Name of analog output channel, that is used.
* E.g.: "ao0", "ao1" etc.
voltage_range (Tuple[float, float]): [minimum, maximum] voltage
(in volts) that the etalon supports. Make sure to use correct/safe
values as this also prevents setting the voltage to "unhealthy" levels!
name (str, optional): Name / Identifier to give to this etalon. This is
relevant for log statements, especially when there is more than one
etalon in the setup. Defaults to "Fabry-Perot voltage controller".
Attributes:
voltage (float): Voltage that is currently held at specified
output
task (object): Represents a DAQmx Task object, through which all
communication with device is managed.
channel (object): DAQmx channel object representing the output
channel.
References:
| https://nidaqmx-python.readthedocs.io/en/latest/index.html
| https://nidaqmx-python.readthedocs.io/en/latest/task.html
"""
def __init__(
self,
device_name: str,
channel: str,
voltage_range: Tuple[float, float],
name: str = "Fabry-Perot voltage controller",
):
# Set attributes
self.name = name
# Initialise nidaqmx task object
self.task = ni_task.Task(self.name)
logger.info("Initialized nidaqmx task for {}.".format(self.name))
# Connect to appropriate analog out channel with corresponding
# settings See nidaqmx.task.ao_channel documentation
self.channel = self.task.ao_channels.add_ao_voltage_chan(
r"/{}/{}".format(device_name, channel),
min_val=voltage_range[0],
max_val=voltage_range[1],
units=VoltageUnits.VOLTS,
)
logger.info(
"Set {} as analog output voltage channel for {}.".format(
self.channel, self.name
)
)
# Set voltage to zero
self.set_voltage(0)
def __str__(self):
info_str = (
"Object that controls voltage of {}. \nCurrent voltage: {} V.".format(
self.name, self.voltage
)
)
return info_str
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.end()
[docs] def set_voltage(self, voltage: float):
"""
Sets voltage of Fabry-Perot etalon.
Args:
voltage (float): Voltage in V
"""
# Change voltage level
self.task.write(
voltage
) # Writing holds/sets the voltage to the wanted level! (Even after task is deleted.)
self.voltage = voltage # Update attribute
logger.info("{} voltage is: {} V.".format(self.name, voltage))
[docs] def end(self):
"""
Sets voltage to 0 V and ends nidaqmx task.
"""
self.task.write(0) # Set voltage to zero
self.task.close() # Close nidaqmx task
logger.info("Connection to {} terminated.".format(self.name))
[docs]class FabryPerot:
"""
Class that connects to analog output to Fabry Perot and provides
functionality and algorithms to tune the wavelength.
It is assumed that a National Instruments nidaqmx capable device is
used to provide the analog output voltage. This could be easily
changed by writing a new FabryPerotVoltage class and instanciating
it here.
Args:
device_name (str): Name of device on which the appropriate
analog-out port is located. The name can be found and configured
in the National Instruments Software: 'Measurement and
Automation Explorer' (MAX). The device name should also be
displayed in a pop-up when plugging in the device.
* E.g.: "Dev0", "Dev1" etc.
channel (str): Name of analog output channel, that is used.
* E.g.: "ao0", "ao1" etc.
voltage_range (Tuple[float, float]): [minimum, maximum] voltage
(in volts) that the etalon supports. Make sure to use
correct/safe values as this also prevents setting the voltage to
"unhealthy" levels!
init_voltage (float): Voltage in Volts to which analog output is
set upon init.
adc (AnalogDigitalConverter): Object that represents the ADC
hardware connected to MCT detector that is used to tune Fabry
Perot.
shutter (Shutter): Object that controls IR-pump shutter.
spectrometer (Triax): Spectrometer which is used to tune the
Fabry Perot.
hw_properties (HardwareProperties): Object that holds all the
hardware properties of the setup
pixel_linearisation (PixelResponseLinearization): Object that
can linearise the pixel response of the MCT detector that is
used to tune the Fabry Perot.
name (str, optional): Name / Identifier to give to this etalon.
This is relevant for log statements, especially when there is
more than one etalon in the setup. Defaults to "Fabry-Perot
etalon".
Attributes:
analog_voltage_controller (FabryPerotVoltage): Used to control
voltage applied to Fabry Perot
slope (float): Mysterious slope that is used tune Fabry Perot.
Nobody knows where it was born.
current_pixel (int): Pixel to which Fabry Perot is currently
tuned (assuming that the spectrometer settings were not
changed.) This attribute is initialized after the first time the
Fabry Perot was tuned to a wavenumber.
current_wavenumber (float): Wavenumber in cm-1 to which Fabry
Perot is currently tuned. This attribute is initialized after
the first time the Fabry Perot was tuned to a wavenumber/
wavelength.
current_wavelength (float): Wavelength in nanometers to which
Fabry Perot is currently tuned. This attribute is initialized
after the first time the Fabry Perot was tuned to a wavenumber/
wavelength.
"""
def __init__(
self,
device_name: str,
channel: str,
init_voltage: float,
voltage_range: Tuple[float, float],
adc: AnalogDigitalConverter,
shutter: Shutter,
spectrometer: Triax,
hw_properties: HardwareProperties,
pixel_linearisation: PixelResponseLinearization,
name: str = "Fabry-Perot etalon",
):
# Set attributes:
self.device_name = device_name
self.channel = channel
self.init_voltage = init_voltage
self.voltage_range = voltage_range
self.adc = adc
self.shutter = shutter
self.spectrometer = spectrometer
self.hw_properties = hw_properties
self.pixel_linearisation = pixel_linearisation
self.name = name
# Instantiate voltage controller object
self.analog_voltage_controller = FabryPerotVoltage(
device_name, channel, voltage_range, name=name + "voltage controller"
)
logger.info("Voltage controller for {} initialised".format(self.name))
self.slope = self.__calculate_slope()
logger.info("Calculated slope for {}".format(self.name))
# Create queue to pass data to potential plot partner
self.queue = Queue()
# Set voltage to init_voltage
self.analog_voltage_controller.set_voltage(self.init_voltage)
# Set attributes to -1 because they are not known yet
self.current_pixel = -1
self.current_wavenumber = -1 # wavenumber in cm-1
self.current_wavelength = -1 # wavelength in nanometers
[docs] def set_wavelength(self, wavelength: float):
"""
Tune Fabry Perot to closest possible wavelength that is
available with the current spectrometer settings.
This algorithm can only tune Fabry Perot to have maximum
intensity on a certain pixel. So we find the pixel whose
wavelength is closest to the one specified.
Args:
wavelength (float): wavelength in nanometers
See Also:
set_wavenumber
"""
# Convert wavelength to wavenumber in cm-1
wavenumber = 1e7 / wavelength
# Call set_wavenumber
self.set_wavenumber(wavenumber)
[docs] def set_wavenumber(self, wavenumber: float):
"""
Tune Fabry Perot to closest possible wavenumber that is
available with the current spectrometer settings.
This algorithm can only tune Fabry Perot to have maximum
intensity on a certain pixel. So we find the pixel whose
wavenumber is closest to the one specified.
Args:
wavenumber (float): wavenumber in cm-1
"""
# If the wavenumber that we want to tune the Fabry Perot to is
# not within the range of the current spectrometer setting we
# need to change the central wavelength of the spectrometer.
if (
wavenumber <= self.spectrometer.wn_axis[0]
or self.spectrometer.wn_axis[-1] <= wavenumber
):
logger.warning(
"""The requested wavenumber ({} cm-1) is not within the range of the
spectrometer. Changing the central wavenumber of the spectrometer to the requested wavenumber.
Make sure that a change of the central wavenumber is intentional (e.g. you really want to pump at a
frequency that is significantly different from the probe frequencies).
The unnecessary turning of the grating can lead to artifacts. ({})""".format(
wavenumber, self.name
)
)
prior_central_wn = (
self.spectrometer.wavenumber
) # Save old central wavenumber to reset later
self.spectrometer.set_wavenumber(wavenumber)
# Find pixel that is closed to required wavenumber by finding
# the index of the spectrometer wavenumber array that is closet
# to target wavenumber
difference = np.abs(self.spectrometer.wn_axis - wavenumber)
# Get index of smallest difference - this is our target pixel
target_pixel = difference.argmin()
self.set_pixel(target_pixel)
# Resetting central wavenumber to the prior value if it was
# changed
if "prior_central_wn" in locals():
logging.info(
"Resetting central wavenumber to {} cm-1 after tuning of {}".format(
prior_central_wn, self.name
)
)
self.spectrometer.set_wavenumber(prior_central_wn)
[docs] def set_pixel(self, target_pixel: int):
"""
Tune Fabry Perot to have maximum intensity on provided target
pixel.
Args:
target_pixel (int): Pixel onto which Fabry Perot
is supposed to be tuned.
"""
prior_samples_to_acquire_value = (
self.adc.samples_to_acquire
) # Save old samples to acquire value to reset it later.
# Check whether target pixel is on detector..
if target_pixel >= self.spectrometer.wn_axis.size:
# Set to maximum possible pixel
target_pixel = self.spectrometer.wn_axis.size - 1
target_wavenumber = self.spectrometer.wn_axis[target_pixel]
logging.info(
"Tuning {} to Pixel {}. This corresponds to a wavenumber of {} cm-1.".format(
self.name, target_pixel, target_wavenumber
)
)
logger.info(
"Opening Pump-IR-shutter by calling shutter.open() method for {}".format(
self.name
)
)
self.shutter.open()
# Coarse tune wavelength then fine tune
self.__coarse_tuning(target_pixel)
self.__fine_tuning(target_pixel)
# Update attributes
self.current_pixel = target_pixel
self.current_wavenumber = target_wavenumber # wavenumber in cm-1
self.current_wavelength = 1e7 / target_wavenumber # wavelength in nanometers
logger.info(
"Closing Pump-IR-shutter by calling shutter.open() method for {}".format(
self.name
)
)
self.shutter.close()
# Reset samples to acquire
self.adc.set_samples_to_acquire(prior_samples_to_acquire_value)
def __calculate_slope(self):
"""
Calculates a slope (change in wavelength per change in voltage)
to use for the coarse tuning of the fabry perot wavelength.
This method uses a lot of different hardware parameters to
determine this slope.
Note:
Nobody knows how to derive this algorithm or who wrote it.
Apparently it was developed in Zuerich by someone other than
Jens.
References:
| https://youtu.be/Buc2QtA8-VE
| https://www.horiba.com/uk/scientific/products/optics-tutorial/
Returns:
float: slope (units are probably volts per pixel (??))
"""
# Tilt angle of spectral plane relative to normal of wavevector
# (direction of beam). [Gamma in VB6] See also:
# https://www.horiba.com/us/en/scientific/products/optics-tutorial/diffraction-gratings/"
gamma = (
self.hw_properties.spectrometer.gamma * np.pi / 180
) # (convert to radians)
# ? Distance of two adjacent groves on the grating in
# ????(nm)???? (lattice constant) [D in VB6]
groove_distance = 1e6 / self.spectrometer.gr_line_density
# focal length of the focusing elements (czerny-turner(?)) in mm
# [f in VB6]
focal_length = self.hw_properties.spectrometer.focal_length
# Center to center distance of two adjacent pixels in mm
# [spacing in VB6]
spacing = self.hw_properties.mct_array.pixel_pitch
# for lack of a better name
a = (groove_distance * np.cos(gamma)) ** 2 - (
self.spectrometer.wavelength / 2
) ** 2 # This strongly look like pythagorean theorem
if a <= 0:
return 0.01 # Return a slope of 0.01
else:
# ----- Magic formula incoming ------
dispersion = (spacing / focal_length) * (
np.sqrt(a) - np.tan(gamma) * self.spectrometer.wavelength
)
dispersion = (
1e7 * dispersion
) / self.spectrometer.wavelength ** 2 # VB6: in cm-1
# We suspect: 10 corresponds to the maximum of 10 V that the
# piezo Controller gets from DAC (The piezo itself is
# regulated with a voltage up to 150 V, the piezo controller
# amplifies the voltage from the DAC) The 8000 corresponds
# to the 8 micrometer maximum travel range (width
# modulation) of the piezo in nm The factor of 2 probably
# has to do with the effective change in path difference of
# the light (see common formulas for fabry perot)
gain = (2 * 8000 / 10) / self.spectrometer.wavelength # Unit: probably 1/V
# The slope formula contains a lot of factors that cancel
# each other out It reduces to: dispersion * 7 * (10 / (2 *
# 8000)) where dispersion refers to the first dispersion
# that was calculated (the one with sqrt and tan etc.)
slope = (dispersion * 7) / ((1e7 / self.spectrometer.wavelength) * gain)
# Divide by 2, 'to be on the save' (aka safe) side
return slope / 2
def __coarse_tuning(self, target_pixel):
"""
Coarse tunes the Fabry Perot to a target pixel.
The target pixel is counted relative to the 0th pixel in the
probe detector line/array.
#######################
Step by Step Algorithm:
#######################
1. We set the number of iteration to 20 since this is more than
enough to complete the coarse adjustment (typically 2 or 3
steps are already enough).
2. Set samples that should be aquired to 20
3. Get the indices (rows) of the probe pixel information in the
data array of the ADC.
4. Read data from ADC and linearize the pixel response.
5. Average this data for each pixel. We do not account for the
choppers chopping the pump signal. This implies, that the
pumped and unpumped data is averaged together. How would tha
look like. Imagine averaging Gauss peaks with a noise line
around zero (because every second column is unpumped). This
results in a lower intensity overall but should not change
the position of the maximum intensity.
6. Calculate the distance in pixels between current maximum and
target.
7. Calculate the voltage necessary to "go" target pixel using
the slope that was calculated earlier.
8. Apply this voltage.
Args:
target_pixel (int): Index/ position of probe pixel that the
Fabry Perot should be tuned to. Note again, that this
position is counted relative to the 0th probe pixel on the
detector array (#! This only applies if the pixel
#! are in the correct order in the json file otherwise
#! this algorithm will behave strangely).
Note:
It is assumed, that the IR pump pulse from the Fabry Perot
is projected on to the probe pixel row of the MCT array.
"""
# Preparations for coarse tuning
max_iteration = 20
self.adc.set_samples_to_acquire(
20
) # Set samples to be acquired to a small number i.e. 20
probe_pixel = (
self.adc.probe_pixel_idx
) # Write indices of probe pixels into shorter variable
# Coarse tuning of wavelength
for i in range(max_iteration):
self.adc.read()
# Put data in queue for plot partner
self.queue.put(self.adc.data.copy())
# Linearize pixel response
linearised_pixel_response = self.pixel_linearisation.linearize(
self.adc.data[probe_pixel]
)
# Calculate the mean probe intensity for each probe pixel.
# Probably different from old algorithm. We do correct with
# background (no laser light on detector). In LabView the
# spectra VI is called to get the mean_probe_abs
# (abs=absolute)
#! Needs to be tested.
mean_probe_intensity = np.average(linearised_pixel_response, axis=-1)
# Look for the pixel with the highest intensity.
#! Note that it needs to be clarified what is meant by
#! target pixel and current pixel in terms of indices. It
#! might be that the line below needs to be changed to:
#! current_pixel = probe_pixel[mean_probe_intensity.argmax()]
current_pixel = mean_probe_intensity.argmax()
if current_pixel == target_pixel:
logger.info(
"Target pixel reached (pixel: {}). Coarse tuning complete for {}.".format(
current_pixel, self.name
)
)
break
else:
# Calculate the number of pixels that light intensity
# maximum is distanced from the target pixel
delta_pixel = target_pixel - current_pixel
# Save the voltage that is currently applied to the
# Fabry Perot in a shorter variable name
present_voltage = self.analog_voltage_controller.voltage
# Calculate the necessary voltage to reach target pixel
target_voltage = present_voltage + (self.slope * delta_pixel)
logger.info(
"Coarse Tuning: Setting the target voltage of {} to {} V. i = {}".format(
self.name, target_voltage, i
)
)
self.analog_voltage_controller.set_voltage(target_voltage)
logging.info("Coarse Tuning: Completed. ({})".format(self.name))
def __fine_tuning(self, target_pixel):
"""
Fine tunes the Fabry Perot to the target pixel.
Note:
We do not know how this algorithm works or who wrote it. It
is legacy VB6 code. But it seems to be an implementation of
a Dithering algorithm.
Args:
target_pixel (int): Index/ position of probe pixel that the
Fabry Perot should be tuned to. Note, that this position is
counted relative to the 0th probe pixel on the detector
array (#! This only applies if the pixel
#! are in the correct order in the json file otherwise
#! this algorithm will behave strangely).
Note:
It is assumed, that the IR pump pulse from the Fabry Perot
is projected on to the probe pixel row of the MCT array.
"""
# Preparations for fine tuning
self.adc.set_samples_to_acquire(
20
) # Set samples to be acquired to a small number i.e. 20
probe_pixel = (
self.adc.probe_pixel_idx
) # Write indices of probe pixels into shorter variable
# Actual fine tuning algorithm - nobody know when and where it
# was born.
average_correction = 0
for i in range(2000):
# Probably empirically determined threshold.
if i > 11 and average_correction < 0.01:
logger.info(
"Completed fine tuning to target pixel {} for {}. i = {}".format(
target_pixel, self.name, i
)
)
break
# ----------- Measure m1 (dithering method)
# Nobody can explain where the 0.02 come from. We
# essentially change the tuned voltage a little bit. Too bad
target_voltage = (
self.analog_voltage_controller.voltage + average_correction + 0.02
)
logger.info(
"Fine Tuning: Setting the target voltage of {} to {} V".format(
self.name, target_voltage
)
)
self.analog_voltage_controller.set_voltage(target_voltage)
# Get new intensity at target pixel
self.adc.read()
# Put data in queue for plot partner
self.queue.put(self.adc.data.copy())
# Linearize pixel response
linearised_pixel_response = self.pixel_linearisation.linearize(
self.adc.data[probe_pixel]
)
# Calculate the mean intensity at target pixel. Probably
# different from old algorithm. We do correct with
# background (no laser light on detector). In LabView the
# spectra VI is called to get the mean_probe_abs
# (abs=absolute)
#! Needs to be tested.
m1 = np.average(
linearised_pixel_response[target_pixel]
) # mean intensity at target pixel
logger.info("Fine Tuning: m1 = {} for {}. i = {}".format(m1, self.name, i))
# ----------- Measure m2 (dithering method)
# We essentially change tuned voltage a little bit in the
# other direction. Same 0.02 as above.
target_voltage = self.analog_voltage_controller.voltage - 0.02
logger.info(
"Fine Tuning: Setting the target voltage of {} to {} V".format(
self.name, target_voltage
)
)
self.analog_voltage_controller.set_voltage(target_voltage)
# Get new intensity at target pixel
self.adc.read()
# Put data in queue for plot partner
self.queue.put(self.adc.data.copy())
# Linearize pixel response
linearised_pixel_response = self.pixel_linearisation.linearize(
self.adc.data[probe_pixel]
)
# Calculate the mean intensity at target pixel. Probably
# different from old algorithm. We do correct with
# background (no laser light on detector). In LabView the
# spectra VI is called to get the mean_probe_abs
# (abs=absolute)
#! Needs to be tested.
m2 = np.average(
linearised_pixel_response[target_pixel]
) # mean intensity at target pixel
logger.info("Fine Tuning: m2 = {} for {}. i = {}".format(m2, self.name, i))
# -------- Calculating some dithering stuff (?)?
if i < 9:
# In the VB6 code "i_average" = "i_avg" We do not know
# if its actually "average"
i_average = i + 1
else:
i_average = 9
# We guess this is where the magic happens.
correction = 12 * self.slope * ((m1 - m2) / (m1 + m2))
average_correction = ((i_average - 1) * average_correction / i_average) + (
correction / i_average
)
logger.info(
"Fine Tuning: average_correction = {} for {}. i = {}".format(
average_correction, self.name, i
)
)
logging.info("Fine Tuning: Completed. ({})".format(self.name))
# Tell plot partner to stop
self.queue.put("stop")
[docs] def end(self):
self.analog_voltage_controller.end()
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.end()
if __name__ == "__main__":
logger.setLevel(logging.DEBUG)
# fp = FabryPerot(device_name="Dev1", channel="ao0", voltage_range=[0,10])
# fp.set_voltage(5)
# test = FabryPerot
# Load hardware config file
hw_path = "hardware_config_files/i-lab_hardware_properties.json"
hw_config = HardwareProperties(hw_path)
# Initialize pixel response linearisation
from data_processing import PixelResponseLinearization as PRL # import redundant
prl = PRL(hw_config.paths.pixel_linearization)
with FabryPerotVoltage(
hw_config.fabry_perot.device_name,
hw_config.fabry_perot.channel,
hw_config.fabry_perot.voltage_range,
) as fp_vltg_ctrl:
while True:
voltage = float(input())
print(voltage)
fp_vltg_ctrl.set_voltage(voltage)
# # Initialize plotting
# from matplotlib import pyplot as plt
# fig, ax = plt.subplots()
# with AnalogDigitalConverter(
# hw_config.adc.device_name,
# hw_config.adc.input_configuration_path,
# hw_config.adc.samples_to_acquire,
# hw_config.adc.trigger_source,
# hw_config.adc.sampling_rate,
# hw_config.adc.laser_frequency
# ) as adc, \
# \
# Shutter(
# hw_config.shutter.ir_shutter.device_name,
# hw_config.shutter.ir_shutter.channel,
# waiting_time = hw_config.shutter.ir_shutter.waiting_time,
# default_position = hw_config.shutter.ir_shutter.default_position,
# open_electrical_state = hw_config.shutter.ir_shutter.open_electrical_state,
# name = hw_config.shutter.ir_shutter.name,
# ) as shutter, \
# \
# Triax(
# hwconfig=hw_config,
# init=True
# ) as spectrometer, \
# \
# FabryPerot(
# hw_config.fabry_perot.device_name,
# hw_config.fabry_perot.channel,
# hw_config.fabry_perot.voltage_range,
# adc,
# shutter,
# spectrometer,
# hw_config,
# prl,
# name = hw_config.fabry_perot.name
# ) as fp:
# shutter.open()
# adc.read()
# raw_data = prl.linearize(adc.data)
# avg_intensity = np.average(raw_data, axis=1)
# ax.plot(avg_intensity[adc.probe_pixel_idx], label="Probe")
# ax.plot(avg_intensity[adc.probe_pixel_idx], label="Reference")
# fig.show()
# plt.pause(3)
# shutter.close()
# print(fp.analog_voltage_controller.voltage)
# fp.set_pixel(30)
# shutter.open()
# adc.read()
# raw_data = prl.linearize(adc.data)
# avg_intensity = np.average(raw_data, axis=1)
# ax.plot(avg_intensity[adc.probe_pixel_idx], label="Probe")
# ax.plot(avg_intensity[adc.probe_pixel_idx], label="Reference")
# fig.show()
# plt.pause(3)
# shutter.close()
# print(fp.analog_voltage_controller.voltage)
# print(fp.analog_voltage_controller.voltage)