Welcome to this tutorial designed for LeeQ users. In this guide, we'll delve into the key aspects of LeeQ, focusing on the parameter storage and update mechanisms central to its operation.
At the heart of LeeQ is the DUT (Device Under Test) object, such as the TransmonElement
object. This object is pivotal for storing configurations that describe various elements like the channel, qubit frequency, and pulse shape. To construct a DUT object, you define a dictionary as follows:
TransmonElement(name="<name of the dut>", parameters={
'hrid': 'Q1', # Human-readable ID
'lpb_collections': lpb_collections, # LPB collection definition dictionary
'measurement_primitives': measurement_primitives # Measurement primitives definition dictionary
})
When it comes to saving the calibration log, the configuration of each DUT object is stored on disk. This allows for the object to be reloaded and reconstructed later on.
A collection represents a group of pulses or virtual operations (like a phase shift for a virtual Z gate) that share common parameters. Below is an example of a collection configuration:
lpb_collections = {
'f01': {
'type': 'SimpleDriveCollection', # Class of the collection
'freq': 4888.20, # Frequency in MHz
'channel': 0, # Refer to QubiC LeeQ channel map for details
'shape': 'blackman_drag',
'amp': 0.21,
'phase': 0., # Phase in radians
'width': 0.05, # Width in microseconds
'alpha': 1e9,
'trunc': 1.2
}
}
You can add more items to the LPB collections to define various pulses. The convention "f" is used to denote a pulse that drives transitions in a subspace, like f13
for a two-photon transition drive between the 1 and 3 states of a transmon.
Measurement primitives are definitions for measurement pulses. When a measurement primitive is activated, the data acquisition device automatically starts collecting data. Here's an example definition for qubit and qutrit readouts:
measurement_primitives = {
'0': {
'type': 'SimpleDispersiveMeasurement',
'freq': 9997.6, # Frequency in MHz
'channel': 1, # Refer to QubiC LeeQ channel map for details
'shape': 'square',
'amp': 0.06,
'phase': 0., # Phase in radians
'width': 8, # Width in microseconds
'trunc': 1.2,
'distinguishable_states': [0, 1] # Distinguishable states
},
'1': {
'type': 'SimpleDispersiveMeasurement',
'freq': 9997.55,
'channel': 1,
'shape': 'square',
'amp': 0.06,
'phase': 0.,
'width': 8,
'trunc': 1.2,
'distinguishable_states': [0, 1, 2]
}
}
Post-experiment, a state classifier, such as one generated by the MeasurementCalibrationMultilevelGMM
experiment, must be trained. This classifier is then stored in memory and applied to subsequent experiments until the kernel is shut down. To update the classifier, simply rerun the calibration process.
In LeeQ, pulse shapes are defined through functions that take a sampling rate as their first argument, followed by several other parameters, including optional ones.
import numpy as np
from leeq.compiler.utils.time_base import get_t_list
def custom_gaussian_func(sampling_rate: int, amp: float, phase: float, width: float, trunc: float) -> np.array:
gauss_width = width / 2.0
t = get_t_list(sampling_rate, width * trunc)
return amp * np.exp(1.0j * phase) * np.exp(-((t - gauss_width) / gauss_width) ** 2).astype("complex64")
To integrate custom pulse shapes into LeeQ, use the PulseShapeFactory
object. This singleton facilitates the registration of new pulse shapes. Custom pulse shapes can be added to leeq/compiler/utils/pulse_shapes/basic_shapes.py
and made visible by including their names in the file’s __all__
list for automatic loading. Alternatively, pulse shapes can be registered manually as needed:
from leeq.compiler.utils.pulse_shape_utils import PulseShapeFactory
PulseShapeFactory().register_pulse_shape(
pulse_shape_name='custom_gaussian_func',
pulse_shape_function=custom_gaussian_func
)
LeeQ employs a tree structure for scheduling rather than a predefined schedule, introducing two key concepts:
-
Logical Primitive (LP): The basic operation, typically a single pulse or delay, serving as a tree's leaf.
-
Logical Primitive Block (LPB): A composite element within the tree, including
LogicalPrimitiveBlockSeries
,LogicalPrimitiveBlockParallel
, andLogicalPrimitiveBlockSweep
.
LogicalPrimitiveBlockSeries
signifies sequential execution of its children. It can be constructed using the +
operator to combine LPs or LPBs.
LogicalPrimitiveBlockParallel
indicates simultaneous start times for its children, created using the *
operator.
LogicalPrimitiveBlockSweep
pairs with a Sweeper
for dynamic pulse adjustments during a sequence sweep.
Example:
lpb_1 = LogicalPrimitiveBlockSeries([lp_1, lp_2, lp_3])
lpb_2 = LogicalPrimitiveParallel([lpb_1, lp_4]) # Mixing LPBs and LPs
lpb_3 = lpb_1 + lpb_2 # Series combination
lpb_4 = lpb_1 * lpb_2 # Parallel combination
Single qubit gates are accessible through the DUT object's collection, which organizes the operations by subspace. For instance:
dut = duts_dict['Q1']
c1 = dut.get_c1('f01') # Access the single qubit drive collection for subspace 0,1
lp = c1['X'] # X gate
lp = c1['Y'] # Y gate
lp = c1['Yp'] # +pi/2 Y gate
lp = c1['Ym'] # -pi/2 Y gate
Shortcut methods are available for composite gates, like:
gate = dut.get_gate('qutrit_hadamard')
The returned object, typically an LPB, consists of a sequence of gates. Detailed documentation is available for get_gate
.
To access measurement primitives:
mprim = dut.get_measurement_prim_intlist(name='0')
get_measurement_prim_intlist
offers single-shot, demodulated, and aggregated readouts, among other options detailed in the documentation.
You can update parameters for gates or measurement primitives on-the-fly, with changes stored in memory. To persist adjustments:
dut.save_calibration_log()
This saves the configuration to disk. To load a saved configuration:
dut = TransmonElement.load_from_calibration_log('<Qubit hrid>')
This method retrieves the latest calibration log. If LEEQ_CALIBRATION_LOG_PATH
is unset, logs are saved in the default .\calibration_log
directory.
LeeQ provides integrated support for setups utilizing Single Board RPC control within the LBNL QubiC system. Here's a straightforward example of how to define your setup:
class QubiCDemoSetup(QubiCSingleBoardRemoteRPCSetup):
def __init__(self):
super().__init__(
name='qubic_demo_setup',
rpc_uri='http://192.168.1.80:9095' # The RPC address for QubiC system
)
The system can be configured to adjust pulse parameters before they are submitted to the compiler. This feature is particularly useful, for instance, when integrating the QubiC system to synthesize an Intermediate Frequency (IF) signal that will be mixed with an external Local Oscillator (LO).
class QubiCSetup(QubiCSingleBoardRemoteRPCSetup):
@staticmethod
def _readout_frequency_mixing_callback(parameters: dict):
"""
This function changes the frequency of the lpb parameters of the readout channel,
considering we have a mixing of 15GHz signal.
"""
if 'freq' not in parameters:
return parameters
modified_parameters = parameters.copy()
modified_parameters['freq'] = 15000-parameters['freq'] # 4-8 GHz for IF Readout
return modified_parameters
def __init__(self):
super().__init__(
name='qubic_setup',
rpc_uri='http://192.168.1.80:9095'
)
# Register call function for all readout channels
for i in range(8):
readout_channel = 2 * i + 1
self._status.register_compile_lpb_callback(
channel=readout_channel,
callback=self._readout_frequency_mixing_callback
)
Customizing an experiment can generally be segmented into three main components: (1) the setup and execution of the experiment, including data acquisition; (2) data processing and analysis; and (3) data visualization.
Below, we present an example outlining the implementation of a basic experiment aimed at measuring the T1 relaxation time of a qubit. For the sake of brevity and clarity, certain details in the data visualization section are simplified.
class SimpleT1(Experiment): # The experiment class should inherit from Experiment
@log_and_record # This decorator is used to log the experiment and record the data
def run(self, # The run function should be defined to carry out the experiment
qubit: Any, # Add the expected type for 'qubit' instead of Any
collection_name: str = 'f01',
initial_lpb: Optional[Any] = None, # Add the expected type for 'initial_lpb' instead of Any
mprim_index: int = 0,
time_length: float = 100.0,
time_resolution: float = 1.0
) -> None:
c1 = qubit.get_c1(collection_name)
mp = qubit.get_measurement_prim_intlist(mprim_index)
self.mp = mp # Store the measurement primitive for the live data plot
delay = prims.Delay(0)
lpb = c1['X'] + delay + mp
if initial_lpb:
lpb = initial_lpb + lpb
sweep_range = np.arange(0.0, time_length, time_resolution)
swp = Sweeper(sweep_range,
params=[sparam.func(delay.set_delay, {}, 'delay')])
# The basic function is used to run the experiment
# The swp is used to sweep the delay time
# The basis parameter is used to set what to return from the experiment,
# here we return the probability of the qubit in the excited state
basic(lpb, swp, 'p(1)')
self.trace = np.squeeze(mp.result())
# This decorator is used to register the function for visualization.
#It will be shown after the experiment is finished.
@register_browser_function()
def plot_t1(self, fit=True, step_no=None) -> go.Figure:
self.trace = None
self.fit_params = {} # Initialize as an empty dictionary or suitable default value
args = self.get_run_args_dict() # Retrieve the arguments from the run function
t = np.arange(0, args['time_length'], args['time_resolution'])
trace = np.squeeze(self.mp.result())
if step_no is not None: # The step number is used to plot the live data
t = t[:step_no[0]]
trace = trace[:step_no[0]]
# Build the plot... See the source code for more details
fig = go.Figure(data=data, layout=layout)
return fig
def live_plots(self, step_no):
return self.plot_t1(fit=step_no[0] > 10, step_no=step_no) # The live plot function is used to plot the live data
To initiate and execute the experiment, use the following snippet:
t1_exp = SimpleT1(qubit=duts_dict['Q1'], time_length=100, time_resolution=0.5)
This command automatically runs the experiment and presents the results.
LeeQ leverages LabChronicle for data persistence. To ensure proper data logging from the outset, initiate the logging process at the beginning of your notebook as shown below. Additionally, remember to annotate the run
method of your experiment class with @log_and_record
to enable experiment logging.
from labchronicle import Chronicle
Chronicle().start_log()
For each experiment, LabChronicle automatically generates a data path and experiment ID, which can be used to access the recorded data. For comprehensive information on data retrieval and additional functionalities, consult the LabChronicle documentation.