Source code for quantify_scheduler.helpers.waveforms

# Repository: https://gitlab.com/quantify-os/quantify-scheduler
# Licensed according to the LICENCE file on the main branch
"""Module containing helper functions related to waveforms."""
from __future__ import annotations

import inspect
import math
from functools import partial
from typing import TYPE_CHECKING, Any, Protocol

import numpy as np

from quantify_scheduler import math as math_helpers
from quantify_scheduler import waveforms
from quantify_scheduler.helpers import schedule as schedule_helpers
from quantify_scheduler.helpers.importers import import_python_object_from_string

if TYPE_CHECKING:
    from quantify_scheduler.schedules.schedule import Schedule


[docs] class GetWaveformPartial(Protocol): # typing.Protocol """Protocol type definition class for the get_waveform partial function.""" def __call__(self, sampling_rate: int) -> np.ndarray: """ Execute partial get_waveform function. Parameters ---------- sampling_rate The waveform sampling rate. Returns ------- : The waveform array. """ ...
[docs] def get_waveform_size(waveform: np.ndarray, granularity: int) -> int: """ Return the number of samples required to respect the granularity. Parameters ---------- waveform Numerical waveform. granularity The granularity. """ size: int = len(waveform) if size % granularity != 0: size = math_helpers.closest_number_ceil(size, granularity) return max(size, granularity)
[docs] def resize_waveforms(waveforms_dict: dict[int, np.ndarray], granularity: int) -> None: """ Resizes the waveforms to a multiple of the given granularity. Parameters ---------- waveforms_dict The waveforms dictionary. granularity The granularity. """ # Modify the list while iterating to avoid copies for pulse_id, waveform in waveforms_dict.items(): waveforms_dict[pulse_id] = resize_waveform(waveform, granularity)
[docs] def resize_waveform(waveform: np.ndarray, granularity: int) -> np.ndarray: """ Return the waveform in a size that is a modulo of the given granularity. Parameters ---------- waveform The waveform array. granularity The waveform granularity. Returns ------- : The resized waveform with a length equal to `mod(len(waveform), granularity) == 0`. """ size: int = len(waveform) if size == 0: return np.zeros(granularity) if size % granularity == 0: return waveform remainder = math_helpers.closest_number_ceil(size, granularity) - size # Append the waveform with the remainder zeros return np.concatenate([waveform, np.zeros(remainder)])
[docs] def shift_waveform( waveform: np.ndarray, start_in_seconds: float, sampling_rate: int, resolution: int ) -> tuple[int, np.ndarray]: """ Return the waveform shifted with a number of samples. This compensates for rounding errors that cause misalignment of the waveform in the clock time domain. .. Note:: when using this method be sure that the pulse starts at a `round(start_in_sequencer_count)`. .. code-block:: waveform = np.ones(32) sampling_rate = int(2.4e9) resolution: int = 8 t0: float = 16e-9 # 4.8 = 16e-9 / (8 / 2.4e9) start_in_sequencer_count = (t0 // (resolution / sampling_rate)) start_waveform_at_sequencer_count(start_in_sequencer_count, waveform) Parameters ---------- waveform The waveform. start_in_seconds The start time (in seconds). sampling_rate The sampling rate resolution The sequencer resolution. """ start_in_samples_count = round(start_in_seconds * sampling_rate) samples_shift = start_in_samples_count % resolution start_in_sequencer_count = start_in_samples_count // resolution if samples_shift == 0: return start_in_sequencer_count, waveform return start_in_sequencer_count, np.concatenate([np.zeros(samples_shift), waveform])
[docs] def get_waveform( pulse_info: dict[str, Any], sampling_rate: float, ) -> np.ndarray: """ Return the waveform of a pulse_info dictionary. Parameters ---------- pulse_info The pulse_info dictionary. sampling_rate The sample rate of the waveform. Returns ------- : The waveform. """ t: np.ndarray = np.arange(0, 0 + pulse_info["duration"], 1 / sampling_rate) wf_func: str = pulse_info["wf_func"] waveform: np.ndarray = exec_waveform_function(wf_func, t, pulse_info) return waveform
[docs] def get_waveform_by_pulseid( schedule: Schedule, ) -> dict[int, GetWaveformPartial]: """ Return a lookup dictionary of pulse_id and its partial waveform function. The keys are pulse info ids while the values are partial functions. Executing the waveform will return a :class:`numpy.ndarray`. Parameters ---------- schedule The schedule. """ pulseid_waveformfn_dict: dict[int, GetWaveformPartial] = {} for schedulable in schedule.schedulables.values(): operation = schedule.operations[schedulable["operation_id"]] for pulse_info in operation["pulse_info"]: pulse_id = schedule_helpers.get_pulse_uuid(pulse_info) if pulse_id in pulseid_waveformfn_dict: # Unique waveform already populated in the dictionary. continue pulseid_waveformfn_dict[pulse_id] = partial(get_waveform, pulse_info=pulse_info) for acq_info in operation["acquisition_info"]: for pulse_info in acq_info["waveforms"]: pulse_id = schedule_helpers.get_pulse_uuid(pulse_info) pulseid_waveformfn_dict[pulse_id] = partial(get_waveform, pulse_info=pulse_info) return pulseid_waveformfn_dict
[docs] def exec_waveform_partial( pulse_id: int, pulseid_waveformfn_dict: dict[int, GetWaveformPartial], sampling_rate: int, ) -> np.ndarray: """ Return the result of the partial waveform function. Parameters ---------- pulse_id The pulse uuid. pulseid_waveformfn_dict The partial waveform lookup dictionary. sampling_rate The sampling rate. Returns ------- : The waveform array. """ # Execute partial function get_waveform that already has # 'pulse_info' assigned. The following method execution # adds the missing required parameters. waveform_fn: GetWaveformPartial = pulseid_waveformfn_dict[pulse_id] waveform: np.ndarray = waveform_fn( sampling_rate=sampling_rate, ) return waveform
[docs] def exec_waveform_function(wf_func: str, t: np.ndarray, pulse_info: dict) -> np.ndarray: """ Return the result of the pulse's waveform function. If the wf_func is defined outside quantify-scheduler then the wf_func is dynamically loaded and executed using :func:`~quantify_scheduler.helpers.waveforms.exec_custom_waveform_function`. Parameters ---------- wf_func The custom waveform function path. t The linear timespace. pulse_info The dictionary containing pulse information. Returns ------- : Returns the computed waveform. """ whitelist: list[str] = ["square", "ramp", "soft_square", "drag"] fn_name: str = wf_func.split(".")[-1] waveform: np.ndarray | None = None if wf_func.startswith("quantify_scheduler.waveforms") and fn_name in whitelist: if fn_name == "square": waveform = waveforms.square(t=t, amp=pulse_info["amp"]) elif fn_name == "ramp": if "offset" in pulse_info: waveform = waveforms.ramp( t=t, amp=pulse_info["amp"], offset=pulse_info["offset"], duration=pulse_info["duration"], ) else: waveform = waveforms.ramp( t=t, amp=pulse_info["amp"], duration=pulse_info["duration"] ) elif fn_name == "soft_square": waveform = waveforms.soft_square(t=t, amp=pulse_info["amp"]) elif fn_name == "drag": waveform = waveforms.drag( t=t, G_amp=pulse_info["G_amp"], D_amp=pulse_info["D_amp"], duration=pulse_info["duration"], nr_sigma=pulse_info["nr_sigma"], sigma=pulse_info["sigma"], phase=pulse_info["phase"], ) else: waveform = exec_custom_waveform_function(wf_func, t, pulse_info) if waveform is None: raise RuntimeError(f"Could not create a waveform from {wf_func}") return waveform
[docs] def exec_custom_waveform_function(wf_func: str, t: np.ndarray, pulse_info: dict) -> np.ndarray: """ Load and import an ambiguous waveform function from a module by string. The parameters of the dynamically loaded wf_func are extracted using :func:`inspect.signature` while the values are extracted from the pulse_info dictionary. Parameters ---------- wf_func The custom waveform function path. t The linear timespace. pulse_info The dictionary containing pulse information. Returns ------- : Returns the computed waveform. """ # Load the waveform function from string function = import_python_object_from_string(wf_func) # select the arguments for the waveform function that are present # in pulse info par_map = inspect.signature(function).parameters wf_kwargs = {} for key in par_map: if key in pulse_info: wf_kwargs[key] = pulse_info[key] # Calculate the numerical waveform using the wf_func return function(t=t, **wf_kwargs)
[docs] def apply_mixer_skewness_corrections( waveform: np.ndarray, amplitude_ratio: float, phase_shift: float ) -> np.ndarray: r""" Apply a correction for amplitude imbalances and phase errors. Using an IQ mixer from previously calibrated values. Phase correction is done using: .. math:: Re(z_{corrected}) (t) = Re(z (t)) + Im(z (t)) \tan(\phi) Im(z_{corrected}) (t) = Im(z (t)) / \cos(\phi) The amplitude correction is achieved by rescaling the waveforms back to their original amplitudes and multiplying or dividing the I and Q signals respectively by the square root of the amplitude ratio. Parameters ---------- waveform: The complex valued waveform on which the correction will be applied. amplitude_ratio: The ratio between the amplitudes of I and Q that is used to correct for amplitude imbalances between the different paths in the IQ mixer. phase_shift: The phase error (in deg) used to correct the phase between I and Q. Returns ------- : The complex valued waveform with the applied phase and amplitude corrections. """ def skew_real(_waveform: np.ndarray, alpha: float, phi: float) -> np.ndarray: original_amp = np.max(np.abs(_waveform.real)) intermediate_wf = _waveform.real + _waveform.imag * np.tan(phi) new_amp = np.max(np.abs(intermediate_wf)) intermediate_wf = ( intermediate_wf / new_amp if new_amp != 0 else np.zeros(intermediate_wf.shape) ) return intermediate_wf * original_amp * np.sqrt(alpha) def skew_imag(_waveform: np.ndarray, alpha: float, phi: float) -> np.ndarray: original_amp = np.max(np.abs(_waveform.imag)) intermediate_wf = _waveform.imag / np.cos(phi) new_amp = np.max(np.abs(intermediate_wf)) intermediate_wf = ( intermediate_wf / new_amp if new_amp != 0 else np.zeros(intermediate_wf.shape) ) return intermediate_wf * original_amp / np.sqrt(alpha) corrected_re = skew_real(waveform, amplitude_ratio, np.deg2rad(phase_shift)) corrected_im = skew_imag(waveform, amplitude_ratio, np.deg2rad(phase_shift)) return corrected_re + 1.0j * corrected_im
[docs] def modulate_waveform( t: np.ndarray, envelope: np.ndarray, freq: float, t0: float = 0 ) -> np.ndarray: r""" Generate a (single sideband) modulated waveform from a given envelope. This is done by multiplying it with a complex exponential. .. math:: z_{mod} (t) = z (t) \cdot e^{2\pi i f (t+t_0)} The signs are chosen such that the frequencies follow the relation RF = LO + IF for LO, IF > 0. Parameters ---------- t A numpy array with time values envelope The complex-valued envelope of the modulated waveform freq The frequency of the modulation t0 Time offset for the modulation Returns ------- : The modulated waveform """ modulation = np.exp(1.0j * 2 * np.pi * freq * (t + t0)) return envelope * modulation
[docs] def normalize_waveform_data(data: np.ndarray) -> tuple[np.ndarray, float, float]: """ Normalize waveform data, such that the value is +1.0 where the absolute value is maximal. This means that two waveforms where waveform_1 = c * waveform_2 (c can be any real number) will be normalized to the same normalized waveform data; this holds separately for the real and imaginary parts. Parameters ---------- data The waveform data to rescale. Returns ------- rescaled_data The rescaled data. amp_real The original amplitude of the real part. amp_imag The original amplitude of the imaginary part. """ amp_real_index = np.argmax(np.abs(data.real)) amp_imag_index = np.argmax(np.abs(data.imag)) amp_real = data.real[amp_real_index] amp_imag = data.imag[amp_imag_index] norm_data_re = ( data.real / amp_real if not math.isclose(amp_real, 0.0) else np.zeros(data.real.shape) ) if math.isclose(amp_imag, 0.0): rescaled_data = norm_data_re else: norm_data_im = data.imag / amp_imag rescaled_data = norm_data_re + 1.0j * norm_data_im return rescaled_data, amp_real, amp_imag
[docs] def area_pulses(pulses: list[dict[str, Any]], sampling_rate: float) -> float: """ Calculate the area of a set of pulses. For details of the calculation see `area_pulse`. Parameters ---------- pulses List of dictionary with information of the pulses sampling_rate Sampling rate for the pulse Returns ------- : The area formed by all the pulses """ area: float = 0.0 for pulse in pulses: area += area_pulse(pulse, sampling_rate) return area
[docs] def area_pulse(pulse: dict[str, Any], sampling_rate: float) -> float: """ Calculate the area of a single pulse. The sampled area is calculated, which means that the area calculated is based on the sampled waveform. This can differ slightly from the ideal area of the parameterized pulse. The duration used for calculation is the duration of the pulse. This duration is equal to the duration of the sampled waveform for pulse durations that are integer multiples of the 1/`sampling_rate`. Parameters ---------- pulse The dictionary with information of the pulse sampling_rate Sampling rate for the pulse Returns ------- : The area defined by the pulse """ if not sampling_rate > 0: raise ValueError( f"Attempting to calculate the area of a single pulse with " f"{sampling_rate=}. Sampling rate should be a positive number." ) # Nice to have: Give the user the option to choose integration algorithm if pulse["wf_func"] == "quantify_scheduler.waveforms.square": return pulse["amp"] * pulse["duration"] waveform: np.ndarray = get_waveform(pulse, sampling_rate) return waveform.mean() * pulse["duration"]