Skip to content

Latest commit

 

History

History
305 lines (219 loc) · 12.1 KB

tutorial.md

File metadata and controls

305 lines (219 loc) · 12.1 KB

Tutorial

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.

Parameter Storage and Update

DUT Object

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.

Collection

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

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.

Customizing Pulse Shapes

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
)

Orchestrating Pulses

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, and LogicalPrimitiveBlockSweep.

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 Operations

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.

Adjusting Runtime Parameters

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.

Customizing Your Setup

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
            )

Creating a Customized Experiment

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.

Data Persistence

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.