Source code for hardware_interfaces.fabry_perot

"""
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)