Lock-in


SPI Rack lock-in setup

Example to create a lock-in setup using the SPI Rack modules: D5b and B2b/D4b. One D5b (source module) is required, and any number of measurement modules (B2b/D4b) can be used. Here we will use two B2b modules interfacing to IVVI rack measurement modules.

First we will use these units to get the step response of the system. This allows us to characterize the system and to determine the highest possible lock-in frequency that we can use.

For this test we have the following measurement setup:

Measurement Setup

Initialisation

To use the SPI Rack as a lock-in, we need to import the SPI_rack, D5b_module and the B2b_module\ D4b_module from the spirack library. All the communication with the SPI Rack runs through the SPI_rack object which communicates through a virtual COM port. This COM port can only be open on one instance on the PC. Make sure you close the connection here before you can use it somewhere else.

We also import the logging library to be able to display the logging messages; numpy for data manipulation; scipy for the FFT analysis and plotly for visualistation.

from spirack import SPI_rack, D5b_module, B2b_module

import logging

from time import sleep, time
from tqdm import tqdm_notebook

import numpy as np
from scipy import signal

from plotly.offline import init_notebook_mode, iplot, plot
import plotly.graph_objs as go

init_notebook_mode(connected=True)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

Open the SPI rack connection and unlock the controller. This is necessary after bootup of the controller module. If not unlocked, no communication with the modules can take place. The virtual COM port baud rate is irrelevant as it doesn’t change the actual speed. Timeout can be changed, but 1 second is a good value.

COM_port = 'COM4' # COM port of the SPI rack
COM_speed = 1e6   # Baud rate, not of much importance
timeout = 1       # Timeout value in seconds

spi_rack = SPI_rack(COM_port, COM_speed, timeout)
spi_rack.unlock() # Unlock the controller to be able to send data to the rack

Read back the version of the microcontroller software. This should return 1.6 or higher to be able to use the D5b properly. Als read the temperature and the battery voltages through the C1b, this way we verify that the connection with the SPI Rack is working.

print('Version: ' + spi_rack.get_firmware_version())
print('Temperature: {:.2f} C'.format(spi_rack.get_temperature()))
battery_v = spi_rack.get_battery()
print('Battery: {:.3f}V, {:.3f}V'.format(battery_v[0], battery_v[1]))

Create a new D5b module object at the correct module address using the SPI object. By default the module resets the output voltages to 0 Volt. Before it does this, it will read back the current value. If this value is non-zero it will slowly ramp it to zero. If reset_voltages = False then the output will not be changed.

To see that the we have a connection, we read back the firmware version.

D5b = D5b_module(spi_rack, module=2, reset_voltages=False)
print("Firmware version: {}".format(D5b.get_firmware_version()))

Now we create two B2b module objects at the correct module address using the SPI object. If we set calibrate=True, the module will run a calibration routine at initialisation. This takes about 4 seconds per module, the python code will stall operation during this process.

To see that the we have a connection, we read back the firmware versions.

B2b_1 = B2b_module(spi_rack, module=3, calibrate=False)
print("Firmware version B2b_1: {}".format(B2b_1.get_firmware_version()))

B2b_2 = B2b_module(spi_rack, module=4, calibrate=False)
print("Firmware version B2b_2: {}".format(B2b_2.get_firmware_version()))

System Analysis

To characterize the system we will use the D5b_module to create a step function and use the B2b modules to measure the response. We do this by setting the D5b_module to a very long toggling time and a toggle amount of only 2. The B2b modules we set to trigger on the D5b with a large amount of samples and a high sample rate.

D5b.set_toggle_time(0.2)
toggle_value = D5b.get_toggle_time()
print('Toggle time: {} s'.format(toggle_value))

D5b.set_toggle_amount(2)
print('Toggle amount: {}'.format(D5b.get_toggle_amount()))

The holdoff time of the source module we set as short as possible: 30 μs.

D5b.set_trigger_holdoff_time(30e-6)
print('Holdoff time: {} s'.format(D5b.get_trigger_holdoff_time()))

We’ll use DAC output 0 and 1 (output 1 and 2 on the module front) to generate the step. For this we set them both to toggle mode and to 2 Volt bipolar. It will keep the output voltage outside of toggling at 0V (set_DAC_voltage) and we set it to toggle between -0.02V and 0.02V.

DACs = [0, 1, 2]

for DAC in DACs:
    D5b.set_DAC_span(DAC, '2V_bi')
    D5b.set_DAC_mode(DAC, 'toggle')

    D5b.set_DAC_voltage(DAC, 0)
    D5b.set_DAC_neg_toggle_voltage(DAC, -0.02)
    D5b.set_DAC_pos_toggle_voltage(DAC, 0.02)

The ADC modules will listen on the backplane for the triggers of the D5b module, and have a set holoff time of zero seconds. As the D5b module generates two triggers, we will tell the B2b modules to expect the same amount.

meas_modules = [B2b_1, B2b_2]

for B2b in meas_modules:
    B2b.set_trigger_input('D5b')
    B2b.set_trigger_holdoff_time(0)
    B2b.set_trigger_amount(2)

We will set the ADC modules to the fastest sampling rate possible (filter rate zero), with filter type sinc3 and 2000 samples per trigger. For a list of all the filter settings, see the filter table on the website.

filter_type = 'sinc3'
filter_rate = 0
no_samples = 1000

for B2b in meas_modules:
    B2b.set_ADC_enable(0, True)
    B2b.set_sample_amount(0, no_samples)
    B2b.set_filter_type(0, filter_type)
    B2b.set_filter_rate(0, filter_rate)

Now we use the D5b software trigger to start the measurement. We can use the is_running() function from one of the B2b modules to see if the measurement is finished. Once its finished, we can get the data. In this case we will only use the channel 1 data.

D5b.software_trigger()

while D5b.is_running():
    sleep(0.1)

print('Done!')

B2b_1_Ch1, _ = B2b_1.get_data()
B2b_2_Ch1, _ = B2b_2.get_data()

Now we plot the step response. As the DAC toggles twice (from 0 -> 2V -> -2V) we get two step responses. The timing between these two steps is not directly obvious from the data and might lead to a wrong interpretation. Therefore we’ll only look at the first step response.

We can get the sample time/data rate from the B2b module, given the filter type and filter rate.

timestep = B2b_1.sample_time[filter_type][filter_rate]
fs = 1/timestep
timearray = np.arange(0, no_samples*timestep, timestep)

M1e_gain = 10e6 * 10
M1f_gain = 10e6 * 10

pldata_1 = go.Scattergl(x=timearray, y=B2b_1_Ch1[:no_samples]/M1e_gain, mode='lines+markers', name='B2b_1')
pldata_2 = go.Scattergl(x=timearray, y=B2b_2_Ch1[:no_samples]/M1f_gain, mode='lines+markers', name='B2b_2')
plot_data = [pldata_1, pldata_2]

layout = go.Layout(
    xaxis = dict(title='Time (s)'),
    yaxis = dict(title='Current (A)'),
)

fig = go.Figure(data=plot_data, layout=layout)

iplot(fig)

Lock-in measurement

First we create a simple lock_in class which contains the source module (the previously defined D5b object) and (multiple) measurement modules (the B2b modules). This class gives us a simple interface to use these modules as a lock-in.

class lock_in(object):
    def __init__(self, source_module, measure_modules, reset_source_output=True):
        """
        Inits the modules so they can be used as a lock-in. Sets all the measurement
        modules to be triggered by the source module.
        
        Args:
            source_module: D5b object that acts as source module
            measure_modules: list of B2b/D4b objects that act as measurement modules
            measure_names: a list (of lists) with module and channel names. Should have entry for each module
        """
        self.source_module = source_module
        self.measure_modules = measure_modules
        
        self.toggle_time = None
        self.toggle_amount = None
        self.meas_holdoff_time = None
        
        self.sample_amount = None
        
        for module in self.measure_modules:
            module.set_trigger_input('D5b')
            module.set_ADC_enable(0, True)
            module.set_ADC_enable(1, True)
            
        if reset_source_output:
            for DAC in range(8):
                self.source_module.set_DAC_mode(DAC, 'DC')
                self.source_module.set_DAC_span(DAC, '4V_bi', update=False)
                self.source_module.set_DAC_voltage(DAC, 0.0, update=True)
    
    def enable_output(self, DAC, enable):
        """Set DAC output to lock-in mode
        
        Multiple DACs can be set to lock-in mode. They can all have different
        output amplitudes and voltages, but they will all run at the same frequency
        and the same number of periods.
        
        Args:
            DAC (int:0-7): DAC output to enable/disable for lock-in mode
            enable (bool): enable or disable lock-in mode
        """
        if enable:
            self.source_module.set_DAC_mode(DAC, 'toggle')
        else:
            self.source_module.set_DAC_mode(DAC, 'DC')
    
    def set_frequency(self, frequency, no_periods):
        """Sets the measurement frequency and number of periods
        
        Args:
            frequency: measurement frequency
            no_periods (int): number of periods to measure
        """
        toggle_time = 1/(2*frequency)        
        
        self.source_module.set_toggle_time(toggle_time)
        self.source_module.set_toggle_amount(int(no_periods*2))
        
        for module in self.measure_modules:
            module.set_trigger_amount(int(no_periods*2))
        
        self.toggle_time = toggle_time
        self.toggle_amount = no_periods*2
    
    def set_output(self, DAC, offset, amplitude):
        """ Sets the DAC output voltages
        
        Args:
            DAC (int:0-7): DAC output to change 
            offset (float): offset voltage in Volts
            amplitude (float): peak to peak amplitude in Volt
        """
        self.source_module.set_DAC_voltage(DAC, offset)
        self.source_module.set_DAC_neg_toggle_voltage(DAC, offset - (amplitude/2))
        self.source_module.set_DAC_pos_toggle_voltage(DAC, offset + (amplitude/2))
    
    def set_output_range(self, DAC, output_range, update=True):
        """Set the software span of the selected DAC

        Changes the span of the selected DAC. If update is True the span gets updated
        immediately. If False, it will update with the next span or value setting.

        Args:
            DAC (int: 0-7): DAC inside the module of which to set the span
            span (string): the span to be set (4V_uni, 8V_uni, 4V_bi, 8V_bi, 2V_bi)
            update (bool): if True updates the span immediately, if False updates
                           with the next span/value update
        """
        self.source_module.set_DAC_span(DAC, output_range, update)
        
    
    def set_trigger_holdoff(self, holdoff_time):
        """ Sets the DAC trigger holdoff time
        
        Sets the time the system waits after the trigger for outputting the toggling
        DACs. The mimimum time is 30 us, and the resolution is 100ns.
        
        Args:
            holdoff_time (float): holdoff time in seconds (min 30 us)
        """
        self.source_module.set_trigger_holdoff_time(holdoff_time)
    
    def set_measure_holdoff(self, holdoff_time):
        """ Sets the ADC trigger holdoff time
        
        Sets the time the system waits after a D5b trigger before measuring. Resolution
        of 100 ns
        
        Args:
            holdoff_time (float): holdoff time in seconds
        """
        for module in self.measure_modules:
            module.set_trigger_holdoff_time(holdoff_time)
            
        self.holdoff_time = holdoff_time
    
    def set_sample_amount(self, amount):
        """ Sets the ADC sample amount
        
        Sets the amount of samples that the ADC channel takes per trigger.
        
        Args:
            amount (int): sample amount per trigger
        """
        for module in self.measure_modules:
            for i in range(2):
                module.set_sample_amount(i, amount)
            
        self.sample_amount = amount
    
    def set_filter(self, filter_type, filter_rate): 
        """ Sets the ADC filters
        
        The filter rate together with the filter type determines the cutoff frequency, 
        sample rate, the resolution and the 50 Hz rejection. See the filter table to 
        determine which setting to use.
        
        Args:
            filter_type (string): either sinc3 or sinc5
            filter_rate (int:0-20): filter setting
        """
        for module in self.measure_modules:
            for i in range(2):
                module.set_filter_type(i, filter_type)
                module.set_filter_rate(i, filter_rate)
    
    def software_trigger(self):
        """ Triggers the source (D5b) module
        
        This allows the user to trigger the S5b via software, not using the trigger lines
        on the backplane of the SPI rack.
        """
        self.source_module.software_trigger()
        
    def amplitude_sweep(self, DAC_list, offset, voltage_list):
        meas_res = np.zeros([len(voltage_list), len(self.measure_modules)*2])
        
        for i, voltage in enumerate(voltage_list):
            for DAC in DAC_list:
                self.set_output(DAC=DAC, offset=offset, amplitude=voltage)
            self.software_trigger()
            while self.source_module.is_running():
                sleep(0.01)
            meas_res[i] = self.get_measurement_result()            
        
        return meas_res            
    
    def offset_sweep(self, DAC_list, offset_list, amplitude):
        meas_res = np.zeros([len(offset_list), len(self.measure_modules)*2])
        
        for i, voltage in enumerate(offset_list):
            for DAC in DAC_list:
                self.set_output(DAC=DAC, offset=voltage, amplitude=amplitude)
            self.software_trigger()
            while self.source_module.is_running():
                sleep(0.01)
            meas_res[i] = self.get_measurement_result()
        return meas_res
    
    def get_measurement_result(self):
        result = []
        for module in self.measure_modules:
            while module.is_running():
                sleep(0.01)
                
            ADC0, ADC1 = module.get_data()
            
            ADC0 = ADC0.reshape(self.toggle_amount, -1)
            avg_values = np.sum(ADC0, axis=1)/self.sample_amount
            result.append(np.sum(avg_values[0::2] - avg_values[1::2])/(self.toggle_amount/2))
            
            ADC1 = ADC1.reshape(self.toggle_amount, -1)
            avg_values = np.sum(ADC1, axis=1)/self.sample_amount
            result.append(np.sum(avg_values[0::2] - avg_values[1::2])/(self.toggle_amount/2))

        return np.array(result)

We now create the lock_in object using the previously created D5b and B2b modules.

li_dev = lock_in(D5b, [B2b_1, B2b_2], reset_source_output=False)

For the lock-in source we use DAC output 0 and 1 (1 and 2 on the frontpanel), at a frequency of 125 Hz and we’ll measure for 10 periods.

for DAC in range(2):
    li_dev.enable_output(DAC, enable=True)
    li_dev.set_output_range(DAC, '2V_bi')

li_dev.set_trigger_holdoff(30e-6)

li_dev.set_frequency(frequency=125, no_periods=10)

On the measurement side we’ll set a measurement holdoff of 2 ms with filter setting 8. This gives a 200 μs settling time with a resolution of 19.3 bits. At 125 Hz and a holdoff time of 2 ms, we have 2ms left for measurements. With filter setting 8 this should give us 10 samples per toggle.

li_dev.set_measure_holdoff(0)
li_dev.set_sample_amount(10)
li_dev.set_filter('sinc5', 8)

Amplitude sweep

The current measurement units we use are the M1e and the M1f, we have both of them set to an output gain of 10M V/A with a post gain of 10. We will sweep the voltage from -0.05V to 0.05V for different resistances and plot the IV-curves below.

v_sweep = np.linspace(-0.05, 0.05, num=30, endpoint=True)
DAC_list = [0, 1, 2]

meas_results = li_dev.amplitude_sweep(DAC_list, 0, v_sweep)
plot_data = []

M1e_gain = -10*10e6
M1f_gain = 10*10e6

M1e_meas = meas_results[:,0]/M1e_gain
M1f_meas = meas_results[:,2]/M1f_gain

plot_data.append(go.Scattergl(x=v_sweep, y=M1e_meas, mode='lines+markers', name='M1e'))
plot_data.append(go.Scattergl(x=v_sweep, y=M1f_meas, mode='lines+markers', name='M1f'))

layout = go.Layout(
    xaxis = dict(title='Amplitude Voltage (V)'),
    yaxis = dict(title='Current (A)'),
)

fig = go.Figure(data=plot_data, layout=layout)

iplot(fig)

We can now calculate the resistance from the IV data.

plot_data = []

plot_data.append(go.Scattergl(x=v_sweep, y=v_sweep/M1e_meas, mode='lines+markers', name='M1e'))
plot_data.append(go.Scattergl(x=v_sweep, y=v_sweep/M1f_meas, mode='lines+markers', name='M1f'))

layout = go.Layout(
    xaxis = dict(title='Amplitude Voltage (V)'),
    yaxis = dict(title='Resistance (Ohm)', type='log'),
)

fig = go.Figure(data=plot_data, layout=layout)

iplot(fig)

Derivative measurement

Here we set the amplitude fixed and shift the offset voltage over a defined range

offset_sweep = np.linspace(-0.05, 0.05, num=50, endpoint=True)
DAC_list = [0, 1, 2]
toggle_amplitude = 0.005

meas_results = li_dev.offset_sweep(DAC_list, offset_sweep, toggle_amplitude)
plot_data = []

plot_data.append(go.Scattergl(x=offset_sweep, y=toggle_amplitude/M1e_meas, mode='lines+markers', name='M1e'))
plot_data.append(go.Scattergl(x=offset_sweep, y=toggle_amplitude/M1f_meas, mode='lines+markers', name='M1f'))

layout = go.Layout(
    xaxis = dict(title='Offset Voltage (V)'),
    yaxis = dict(title='Resistance (Ohm)', type='log'),
)

fig = go.Figure(data=plot_data, layout=layout)

iplot(fig)