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:
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()))
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)
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)
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)
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)