# Copyright 2024 Q-CTRL
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Dynamical decoupling module.
"""
from __future__ import annotations
from typing import (
Any,
Optional,
)
import numpy as np
from ..driven_controls.driven_control import DrivenControl
from ..exceptions import ArgumentsValueError
from ..utils import (
Coordinate,
FileFormat,
FileType,
check_arguments,
create_repr_from_attributes,
)
[docs]class DynamicDecouplingSequence:
r"""
Creates a dynamic decoupling sequence.
Parameters
----------
duration : float
The total time in seconds for the sequence :math:`\tau`.
offsets : np.ndarray
The times offsets :math:`\{t_j\}` in seconds for the center of pulses.
rabi_rotations : np.ndarray
The Rabi rotation :math:`\omega_j` at each time offset :math:`t_j`.
azimuthal_angles : np.ndarray
The azimuthal angle :math:`\phi_j` at each time offset :math:`t_j`.
detuning_rotations : np.ndarray
The detuning rotation :math:`\delta_j` at each time offset :math:`t_j`.
name : str, optional
Name of the sequence. Defaults to None.
Notes
-----
Dynamical decoupling sequence (DDS) is canonically defined as a series of
:math:`n`-instantaneous unitary operations, often :math:`\pi`-pulses, executed
at time offsets :math:`\{t_j\}_{j=1}^n` over the time interval with a total
duration :math:`\tau`. The :math:`j`-th operation applied at time
:math:`t_j` can be parameterized as
.. math::
U_j = \exp\left[-\frac{i}{2}(\omega_j \cos \phi_j \sigma_x + \omega_j\sin \phi_j\sigma_y
+ \delta_j\sigma_z)\right] \;,
Note that in practice all DDSs typically have a :math:`X_{\pi/2}` operation at the start
:math:`t = 0` and end :math:`t = \tau` of the sequence. This is because it is assumed that the
qubit is initially in the state :math:`|0\rangle` and a superposition needs to be created and
removed to make the qubit sensitive to dephasing.
"""
def __init__(
self,
duration: float,
offsets: np.ndarray,
rabi_rotations: np.ndarray,
azimuthal_angles: np.ndarray,
detuning_rotations: np.ndarray,
name: Optional[str] = None,
):
check_arguments(
duration > 0,
"Sequence duration must be above zero.",
{"duration": duration},
)
offsets = np.asarray(offsets)
check_arguments(
np.all((offsets >= 0) & (duration >= offsets)),
"Offsets for dynamic decoupling sequence must be between 0 and the sequence "
"duration (inclusive). ",
{"offsets": offsets, "duration": duration},
)
rabi_rotations = np.asarray(rabi_rotations, dtype=float)
check_arguments(
np.all(rabi_rotations >= 0),
"Rabi rotations must be nonnegative.",
{"rabi_rotations": rabi_rotations},
)
_offset_count = len(offsets)
check_arguments(
len(rabi_rotations) == _offset_count,
"rabi rotations must have the same length as offsets. ",
{"offsets": offsets, "rabi_rotations": rabi_rotations},
)
check_arguments(
len(azimuthal_angles) == _offset_count,
"azimuthal angles must have the same length as offsets. ",
{"offsets": offsets, "azimuthal_angles": azimuthal_angles},
)
check_arguments(
len(detuning_rotations) == _offset_count,
"detuning rotations must have the same length as offsets. ",
{"offsets": offsets, "detuning_rotations": detuning_rotations},
)
self.duration = duration
self.offsets = offsets
self.rabi_rotations = rabi_rotations
self.azimuthal_angles = np.asarray(azimuthal_angles, dtype=float)
self.detuning_rotations = np.asarray(detuning_rotations, dtype=float)
self.name = name
[docs] def export(self) -> dict[str, Any]:
"""
Returns a dictionary for plotting using the Q-CTRL Visualizer package.
Returns
-------
dict
Dictionary with plot data that can be used by the `plot_sequences`
method of the Q-CTRL Visualizer package. It has keywords 'Rabi'
and 'Detuning'.
"""
return {
"Rabi": {
"rotations": self.rabi_rotations * np.exp(1.0j * self.azimuthal_angles),
"offsets": self.offsets,
},
"Detuning": {"rotations": self.detuning_rotations, "offsets": self.offsets},
}
def __repr__(self):
"""
Returns a string representation for the object.
The returned string looks like a valid
Python expression that could be used to recreate the object, including default arguments.
Returns
-------
str
String representation of the object including the values of the arguments.
"""
attributes = [
"duration",
"offsets",
"rabi_rotations",
"azimuthal_angles",
"detuning_rotations",
"name",
]
return create_repr_from_attributes(self, attributes)
def __str__(self):
"""
Prepares a friendly string format for a dynamical decoupling sequence.
"""
def _array_to_str(arr: np.ndarray) -> str:
"""
Converts elements of an array to a string.
[1, 2] -> "1, 2"
"""
return ", ".join(arr.astype(str))
sequence_string = []
if self.name is not None:
sequence_string.append(f"{self.name}:")
sequence_string.append(f"Duration = {self.duration}")
sequence_string.append(
f"Offsets = [{_array_to_str(self.offsets / self.duration)}] × {self.duration}"
)
sequence_string.append(
f"Rabi Rotations = [{_array_to_str(self.rabi_rotations / np.pi)}] × pi"
)
sequence_string.append(
f"Azimuthal Angles = [{_array_to_str(self.azimuthal_angles / np.pi)}] × pi"
)
sequence_string.append(
f"Detuning Rotations = [{_array_to_str(self.detuning_rotations / np.pi)}] × pi"
)
return "\n".join(sequence_string)
[docs] def export_to_file(
self,
filename: str,
file_format: str = FileFormat.QCTRL.value,
file_type: str = FileType.CSV.value,
coordinates: str = Coordinate.CYLINDRICAL.value,
maximum_rabi_rate: float = 2 * np.pi,
maximum_detuning_rate: float = 2 * np.pi,
) -> None:
r"""
Prepares and saves the dynamical decoupling sequence in a file.
Parameters
----------
filename : str
Name and path of the file to save the control into.
file_format : str
Specified file format for saving the control. Defaults to
'Q-CTRL expanded'. Currently it does not support any other format.
For detail of the `Q-CTRL Expanded Format` consult
:py:meth:`DrivenControl.export_to_file`.
file_type : str, optional
One of 'CSV' or 'JSON'. Defaults to 'CSV'.
coordinates : str, optional
Indicates the coordinate system requested. Must be one of
'cylindrical' or 'cartesian'. Defaults to 'cylindrical'.
maximum_rabi_rate : float, optional
Maximum Rabi rate. Defaults to :math:`2\pi`.
maximum_detuning_rate : float, optional
Maximum detuning rate. Defaults to :math:`2\pi`.
Raises
------
ArgumentsValueError
Raised if some of the parameters are invalid.
Notes
-----
The sequence is converted to a driven control using the maximum Rabi and detuning
rate. The driven control is then exported.
"""
convert_dds_to_driven_control(
dynamic_decoupling_sequence=self,
maximum_rabi_rate=maximum_rabi_rate,
maximum_detuning_rate=maximum_detuning_rate,
name=self.name,
).export_to_file(
filename=filename,
file_format=file_format,
file_type=file_type,
coordinates=coordinates,
)
[docs]def convert_dds_to_driven_control(
dynamic_decoupling_sequence: DynamicDecouplingSequence,
maximum_rabi_rate: float,
maximum_detuning_rate: float,
minimum_segment_duration: float = 0.0,
name: Optional[str] = None,
) -> DrivenControl:
r"""
Creates a Driven Control based on the supplied DDS and other relevant information.
Currently, pulses that simultaneously contain Rabi and detuning rotations are not
supported.
Parameters
----------
dynamic_decoupling_sequence : DynamicDecouplingSequence
The base DDS. Its offsets should be sorted in ascending order in time.
maximum_rabi_rate : float
Maximum Rabi rate.
maximum_detuning_rate : float
Maximum detuning rate.
minimum_segment_duration : float, optional
If set, further restricts the duration of every segment of the Driven Controls.
Defaults to 0, in which case it does not affect the duration of the pulses.
Must be greater than or equal to 0, if set.
name : str, optional
Name of the sequence. Defaults to None.
Returns
-------
DrivenControls
The Driven Control that contains the segments
corresponding to the Dynamic Decoupling Sequence operation.
Raises
------
ArgumentsValueError
Raised when an argument is invalid or a valid driven control cannot be
created from the sequence parameters, maximum Rabi rate and maximum detuning
rate provided.
Notes
-----
Driven pulse is defined as a sequence of control segments. Each segment performs
an operation (rotation around one or more axes). While the dynamic decoupling
sequence operation contains ideal instant operations, the maximum Rabi (detuning) rate
defines a minimum time required to perform a given rotation operation. Therefore, each
operation in sequence is converted to a flat-topped control segment with a finite duration.
Each offset is taken as the mid-point of the control segment and the width of the
segment is determined by (rotation/max_rabi(detuning)_rate).
If the sequence contains operations at either of the extreme ends
:math:`\tau_0=0` and :math:`\tau_{n+1}=\tau`(duration of the sequence), there
will be segments outside the boundary (segments starting before :math:`t<0`
or finishing after the sequence duration :math:`t>\tau`). In these cases, the segments
on either of the extreme ends are shifted appropriately so that their start/end time
falls entirely within the duration of the sequence.
Moreover, a check is made to make sure the resulting control segments are non-overlapping.
If appropriate control segments cannot be created, the conversion process raises
an ArgumentsValueError.
"""
check_arguments(
maximum_detuning_rate > 0,
"Maximum detuning rate must be positive.",
{"maximum_detuning_rate": maximum_detuning_rate},
)
check_arguments(
maximum_rabi_rate > 0,
"Maximum Rabi rate must be positive.",
{"maximum_rabi_rate": maximum_rabi_rate},
)
check_arguments(
minimum_segment_duration >= 0,
"Minimum segment duration must be greater than or equal to 0.",
{"minimum_segment_duration": minimum_segment_duration},
)
sequence_duration = dynamic_decoupling_sequence.duration
offsets = dynamic_decoupling_sequence.offsets
rabi_rotations = dynamic_decoupling_sequence.rabi_rotations
azimuthal_angles = dynamic_decoupling_sequence.azimuthal_angles
detuning_rotations = dynamic_decoupling_sequence.detuning_rotations
# check if all Rabi rotations are valid (i.e. have positive values)
check_arguments(
np.all(rabi_rotations >= 0.0),
"Sequence contains negative values for Rabi rotations.",
{"dynamic_decoupling_sequence": dynamic_decoupling_sequence},
)
# check for valid operation
check_arguments(
_check_valid_operation(
rabi_rotations=rabi_rotations, detuning_rotations=detuning_rotations
),
"Sequence operation includes Rabi rotation and "
"detuning rotation at the same instance.",
{"dynamic_decoupling_sequence": dynamic_decoupling_sequence},
extras={
"maximum_rabi_rate": maximum_rabi_rate,
"maximum_detuning_rate": maximum_detuning_rate,
},
)
if offsets.size == 0:
offsets = np.array([0, sequence_duration])
rabi_rotations = np.array([0, 0])
azimuthal_angles = np.array([0, 0])
detuning_rotations = np.array([0, 0])
if offsets[0] != 0:
offsets = np.append([0], offsets)
rabi_rotations = np.append([0], rabi_rotations)
azimuthal_angles = np.append([0], azimuthal_angles)
detuning_rotations = np.append([0], detuning_rotations)
if offsets[-1] != sequence_duration:
offsets = np.append(offsets, [sequence_duration])
rabi_rotations = np.append(rabi_rotations, [0])
azimuthal_angles = np.append(azimuthal_angles, [0])
detuning_rotations = np.append(detuning_rotations, [0])
# check that the offsets are correctly sorted in time
if any(np.diff(offsets) <= 0.0):
raise ArgumentsValueError(
"Pulse timing could not be properly deduced from "
"the sequence offsets. Make sure all offsets are "
"in increasing order.",
{"dynamic_decoupling_sequence": dynamic_decoupling_sequence},
extras={"offsets": offsets},
)
offsets = offsets[np.newaxis, :]
rabi_rotations = rabi_rotations[np.newaxis, :]
azimuthal_angles = azimuthal_angles[np.newaxis, :]
detuning_rotations = detuning_rotations[np.newaxis, :]
operations = np.concatenate(
(offsets, rabi_rotations, azimuthal_angles, detuning_rotations), axis=0
)
pulse_mid_points = operations[0, :]
pulse_start_ends = np.zeros((operations.shape[1], 2))
for op_idx in range(operations.shape[1]):
# Pulses that cause no rotations can have 0 duration
half_pulse_duration = 0.0
if not np.isclose(operations[1, op_idx], 0.0): # Rabi rotation
half_pulse_duration = 0.5 * max(
operations[1, op_idx] / maximum_rabi_rate, minimum_segment_duration
)
elif not np.isclose(operations[3, op_idx], 0.0): # Detuning rotation
half_pulse_duration = 0.5 * max(
np.abs(operations[3, op_idx]) / maximum_detuning_rate,
minimum_segment_duration,
)
pulse_start_ends[op_idx, 0] = pulse_mid_points[op_idx] - half_pulse_duration
pulse_start_ends[op_idx, 1] = pulse_mid_points[op_idx] + half_pulse_duration
# check if any of the pulses have gone outside the time limit [0, sequence_duration]
# if yes, adjust the segment timing
if pulse_start_ends[0, 0] < 0.0:
translation = 0.0 - (pulse_start_ends[0, 0])
pulse_start_ends[0, :] = pulse_start_ends[0, :] + translation
if pulse_start_ends[-1, 1] > sequence_duration:
translation = pulse_start_ends[-1, 1] - sequence_duration
pulse_start_ends[-1, :] = pulse_start_ends[-1, :] - translation
# check if the minimum_segment_duration is respected in the gaps between the pulses
# as minimum_segment_duration >= 0, this also excludes overlaps
gap_durations = pulse_start_ends[1:, 0] - pulse_start_ends[:-1, 1]
if not np.all(
np.logical_or(
np.greater(gap_durations, minimum_segment_duration),
np.isclose(gap_durations, minimum_segment_duration),
)
):
raise ArgumentsValueError(
"Distance between pulses does not respect minimum_segment_duration. "
"Try decreasing the minimum_segment_duration or increasing "
"the maximum_rabi_rate or the maximum_detuning_rate.",
{
"dynamic_decoupling_sequence": dynamic_decoupling_sequence,
"maximum_rabi_rate": maximum_rabi_rate,
"maximum_detuning_rate": maximum_detuning_rate,
"minimum_segment_duration": minimum_segment_duration,
},
extras={
"deduced_pulse_start_timing": pulse_start_ends[:, 0],
"deduced_pulse_end_timing": pulse_start_ends[:, 1],
"gap_durations": gap_durations,
},
)
if np.allclose(pulse_start_ends, 0.0):
# the original sequence should be a free evolution
return DrivenControl(
rabi_rates=np.array([0.0]),
azimuthal_angles=np.array([0.0]),
detunings=np.array([0.0]),
durations=np.array([sequence_duration]),
name=name,
)
control_rabi_rates = np.zeros((operations.shape[1] * 2,))
control_azimuthal_angles = np.zeros((operations.shape[1] * 2,))
control_detunings = np.zeros((operations.shape[1] * 2,))
control_durations = np.zeros((operations.shape[1] * 2,))
pulse_segment_idx = 0
for op_idx in range(0, operations.shape[1]):
pulse_width = pulse_start_ends[op_idx, 1] - pulse_start_ends[op_idx, 0]
control_durations[pulse_segment_idx] = pulse_width
if pulse_width > 0.0:
if not np.isclose(operations[1, op_idx], 0.0): # Rabi rotation
control_rabi_rates[pulse_segment_idx] = (
operations[1, op_idx] / pulse_width
)
control_azimuthal_angles[pulse_segment_idx] = operations[2, op_idx]
elif not np.isclose(operations[3, op_idx], 0.0): # Detuning rotation
control_detunings[pulse_segment_idx] = (
operations[3, op_idx] / pulse_width
)
if op_idx != (operations.shape[1] - 1):
control_rabi_rates[pulse_segment_idx + 1] = 0.0
control_azimuthal_angles[pulse_segment_idx + 1] = 0.0
control_detunings[pulse_segment_idx + 1] = 0.0
control_durations[pulse_segment_idx + 1] = (
pulse_start_ends[op_idx + 1, 0] - pulse_start_ends[op_idx, 1]
)
pulse_segment_idx += 2
# almost there; let us check if there is any segments with durations = 0
control_rabi_rates = control_rabi_rates[control_durations > 0.0]
control_azimuthal_angles = control_azimuthal_angles[control_durations > 0.0]
control_detunings = control_detunings[control_durations > 0.0]
control_durations = control_durations[control_durations > 0.0]
return DrivenControl(
rabi_rates=control_rabi_rates,
azimuthal_angles=control_azimuthal_angles,
detunings=control_detunings,
durations=control_durations,
name=name,
)
def _check_valid_operation(
rabi_rotations: np.ndarray, detuning_rotations: np.ndarray
) -> bool:
"""
Private method to check if there is a rabi_rotation and detuning rotation at the same
offset.
"""
rabi_rotation_index = set(np.where(rabi_rotations > 0.0)[0])
detuning_rotation_index = set(np.where(detuning_rotations > 0.0)[0])
return not rabi_rotation_index.intersection(detuning_rotation_index)