2.22. Zurich Instruments Hardware Setup

This notebook shows an exemplary use of qupulse with a ZI HDAWG and MFLI. The drivers for these instruments are kept in external packages to facilitate easy driver customization. Depending on your python version and hardware version you either need qupulse-hdawg-legacy or qupulse-hdawg for the HDAWG and qupulse-mfli for the MFLI.

2.22.1. Connections and wiring

The example here assumes a very nonsensical wiring that does not require anything else besides an HDAWG, and MFLI and three cables/adapters to connect SMB to BNC ports. We assume the following connections:

HDAWG_1_WAVE -> MFLI_AUX_IN_1
HDAWG_2_WAVE -> MFLI_AUX_IN_2
HDAWG_1_MARK_FRONT -> MFLI_TRIG_IN_1

MFLI_TRIG_IN_1 is located on the back of the device.

2.22.2. Hardware Setup

The hardware setup class provides a layer to map output channels to an arbitrary number of physical channels. It also provides a mapping of measurement windows to specific dac instruments.

[ ]:
from qupulse.hardware.setup import HardwareSetup

hw_setup = HardwareSetup()
[ ]:
# This abstracts over possibly installed hdawg drivers
from qupulse.hardware.awgs.zihdawg import HDAWGRepresentation

awg_serial = 'DEVXXXX'
assert awg_serial != 'DEVXXXX', "Please enter the serial of a connected HDAWG"

hdawg = HDAWGRepresentation(awg_serial, 'USB' )

2.22.2.1. Channel groupings

The AWG class abstracts over a set of dependently programmable channels. The HDAWG supports multiple channel groupings which decouples individual channel groups. The most robust setting for qupulse is to use the 1x8 channel grouping which executes the same sequencing program on all channels and only differs in the waveform data that is sequenced. This results in a single channel tuple/AWG object which represents all eight channels.

[ ]:
from qupulse.hardware.awgs.zihdawg import HDAWGChannelGrouping
from qupulse.hardware.setup import PlaybackChannel, MarkerChannel

hdawg.channel_grouping = HDAWGChannelGrouping.CHAN_GROUP_1x8
awg, = hdawg.channel_tuples

# here we assume plunger one and two are connected to the two first channels of the AWG
# It is considered best practice to use such names that relate to the connected sample gates
hw_setup.set_channel('P1', PlaybackChannel(awg, 0))
hw_setup.set_channel('P2', PlaybackChannel(awg, 1))

# We connect the trigger to the marker output of the first channel
hw_setup.set_channel('Trig', MarkerChannel(awg, 0))

# We can assign the same channel to multiple identifiers. Here we just assign all channels to a hardware name
for channel_idx, channel_letter in enumerate('ABCDEFGH'):
    channel_name = f"{hdawg.serial}_{channel_letter}"
    hw_setup.set_channel(channel_name, PlaybackChannel(awg, channel_idx), allow_multiple_registration=True)

# We can also assign multiple channels to the same identifier
hw_setup.set_channel(f"{hdawg.serial}_ALL", [PlaybackChannel(awg, idx) for idx in range(8)], allow_multiple_registration=True)

2.22.2.2. MFLI

Next we will connect the MFLI.

[ ]:
from qupulse_mfli.mfli import MFLIDAQ, postprocessing_average_within_windows

mfli_serial = 'DEVXXXX'
assert mfli_serial != 'DEVXXXX', "Please enter the serial of a connected MFLI"

mfli = MFLIDAQ.connect_to(mfli_serial)

2.22.2.3. Measurement masks

qupulse has multiple layers where measurements are mapped. The hardware setup can map measurement windows to potentially multiple measurement masks, which are a combination of an instrument and an instrument specific identifier.

[ ]:
from qupulse.hardware.setup import MeasurementMask

hw_setup.set_measurement('SET1', MeasurementMask(mfli, 'AverageAux1'))
hw_setup.set_measurement('SET2', MeasurementMask(mfli, 'AverageAux2'))
hw_setup.set_measurement('SET_ALL', [MeasurementMask(mfli, 'AverageAux1'), MeasurementMask(mfli, 'AverageAux2')], allow_multiple_registration=True)

Each instrument can do arbitrary things with the identifier from the mask which heavily depends on what the instrument can do and what you use it for.

The MLFI maps the names to internal paths following your configuration. You can make the configuration global or program specific.

[ ]:
# linking the measurement mask names to physical input channels
mfli.register_measurement_channel(program_name=None, channel_path="demods/0/sample.AuxIn0", window_name="AverageAux2")
mfli.register_measurement_channel(program_name=None, channel_path="auxins/0/sample.AuxIn1", window_name="AverageAux1")

The other inputs can be addressed via strings as the following:

{
    "R": ["demods/0/sample.R"],
    "X": ["demods/0/sample.X"],
    "Y": ["demods/0/sample.Y"],
    "A": ["auxins/0/sample.AuxIn0.avg"],
    "many": ["demods/0/sample.R", "auxins/0/sample.AuxIn0.avg", "demods/0/sample.X", "demods/0/sample.Y"]
}

where the keys of the dict are the values for the window_name, and the values of the dict are the channel_path inputs. Note that these can also be lists to record multiple channels under one name. I.e. for IQ demodulation.

2.22.2.4. Operations

Each driver can automatically perform certain operations on the recorded data. The MFLI expects a callable that processes the raw data returned by the instrument. This is suboptimal but the current solution. If you want to implement your own operation look at the shipped postprocessing functions for the signature.

There are other functions you can use defined in the mfli package like postprocessing_crop_windows. Please file an issue if this documentation here is out of date.

[ ]:
# configuring the driver to average all datapoint for each window.
mfli.register_operations(
    program_name=None,
    operations=postprocessing_average_within_windows
)
[ ]:
# registering trigger settings for a standard configuration
# The measurement is perfomed once after one trigger on TrigIn1 is observed.
mfli.register_trigger_settings(program_name=None,
                                   trigger_input=f"demods/0/sample.TrigIn1", # here TrigInN referrers to the printer label N
                                   edge="rising",
                                   trigger_count=1,
                                   level=.5,
                                   measurement_count=1,
                                   other_settings={"holdoff/time": 1e-3}
                                   )

2.22.3. Pulse definition

Next we define a pulse that we want to use. We settle for a two-dimensional scan of a voltage space but we define the scan in terms of virtual gates, i.e. the potentials that the quantum dots Q1 and Q2 see. Then we provide a linear transformation that maps them to the output voltages P1 and P2.

[ ]:
from qupulse.pulses import *
import numpy as np
from qupulse.program.transformation import LinearTransformation
from qupulse.program.loop import Loop, LoopBuilder, roll_constant_waveforms

awg_sample_rate = 10**9
hdawg.set_sample_clock(awg_sample_rate)

pt = (ConstantPT(2**20, {
    'Q1': '-0.1 + x_i * 0.02',
    'Q2': '-0.2 + y_i * 0.01'}, measurements=[('meas', 0, 2**20)])
      .with_iteration('x_i', 'N_x')
      .with_iteration('y_i', 'N_y')
      .with_parallel_channels({'Marker': 1}))

trafo = LinearTransformation(np.array([[1., -.1], [-.09, 1.]])*0.5,
                             ('Q1', 'Q2'),
                             ('P1', 'P2'))

measurement_mapping = {'meas': 'SET_ALL'}

# we chose the default LoopBuilder program builder here as it is the only supported as the time of writing this example
program: Loop = pt.create_program(parameters={'N_x': 20, 'N_y': 30},
                                  global_transformation=trafo,
                                  program_builder=LoopBuilder(),
                                  measurement_mapping=measurement_mapping,
                                  channel_mapping={'Marker': 'Trig'})

2.22.4. HDAWG: Waveform compression and sample rate reduction

The HDAWG has the capability to dynamically reduce the sample rate by a power of two during playback. The driver does this automatically if it detects a compatible waveform that is (piecewise) constant.

However, the current implementation samples all waveforms in the computer memory. We have a lot (N_x * N_y) of very long waveforms which each take 4 MB in computer memory when sampled with 1GHz. For a sufficiently high resolution this will eat up our RAM with constant waveforms. qupulse provides roll_constant_waveforms to detect long constant waveforms and roll them into loops inplace if possible with the given parameters. This will remove the measurements from the Loop program because they cannot be preserved by the logic. Therefore, we extract them beforehand.

[ ]:
# extract measurement positions
measurements = program.get_measurement_windows(drop=True)

print(f'Single point before rolling: {program[0]!r}')

# Compress program
roll_constant_waveforms(program, sample_rate=awg_sample_rate / 10**9, waveform_quantum=256, minimal_waveform_quanta=16)

print(f'Single point after rolling: {program[0]!r}')
[ ]:
hw_setup.clear_programs()
hw_setup.register_program('csd', program, awg.run_current_program, measurements=measurements)
[ ]:
hdawg.output(1, True)
hdawg.output(2, True)
[ ]:
hw_setup.arm_program('csd')
[ ]:
hw_setup.run_program('csd')
import time; time.sleep(float(program.duration) / 1e9)
[ ]:
hdawg.output(1, False)
hdawg.output(2, False)

The data extration is not standardized at the time of writing this example because it heavily depends on your data processing pipeline how the data is handled and where it shall go. qupulse has no functionality to associate a measured value with the value of some parameter that might have been varied during the measurement.

[ ]:
# receaving the recorded data from the MFLI

data = mfli.measure_program(wait=True) # wait=True would wait until the aquisition is finished.

The recorded data is sliced to the measurement windows in the default configuration. Thus my_lockin.measure_program returns a list (number of measurements) of dicts (the qupulse channels), of dicts (the lockin channels), of lists (the observed trigger), of lists of xarray DataArrays (each DataArray containing the data sliced for one window) or numpy arrays (containing the data resulting from averaging over the windows). I.e. returned_data[<i_measurement>][<qupulse channel>][<lockin channel>][<i_triggerevent>] leads to ether the list of DataArrays or to a numpy array.

[ ]:
data_0 = data[0]
(average_1,), = data_0['AverageAux1'].values()
(average_2,), = data_0['AverageAux2'].values()

Warning: As the time of writing this example there are problems when no demodulator is used at all. One channel looks like it has a sliding window average. Contribution in fixing that is highly appreciated.

[ ]:
import matplotlib.pyplot as plt

plt.plot(average_1, '*')
[ ]:
plt.plot(average_2)