diff --git a/qiskit/transpiler/passes/__init__.py b/qiskit/transpiler/passes/__init__.py index ebf095b54edc..c298e9999d9b 100644 --- a/qiskit/transpiler/passes/__init__.py +++ b/qiskit/transpiler/passes/__init__.py @@ -88,6 +88,7 @@ EchoRZXWeylDecomposition ResetAfterMeasureSimplification OptimizeCliffords + NormalizeRXAngle Calibration ============= @@ -98,6 +99,7 @@ PulseGates RZXCalibrationBuilder RZXCalibrationBuilderNoEcho + RXCalibrationBuilder Scheduling ============= @@ -232,6 +234,7 @@ from .optimization import CollectCliffords from .optimization import ResetAfterMeasureSimplification from .optimization import OptimizeCliffords +from .optimization import NormalizeRXAngle # circuit analysis from .analysis import ResourceEstimation @@ -256,6 +259,7 @@ from .calibration import PulseGates from .calibration import RZXCalibrationBuilder from .calibration import RZXCalibrationBuilderNoEcho +from .calibration import RXCalibrationBuilder # circuit scheduling from .scheduling import TimeUnitConversion diff --git a/qiskit/transpiler/passes/calibration/__init__.py b/qiskit/transpiler/passes/calibration/__init__.py index 10810c1cdff1..990249373f3f 100644 --- a/qiskit/transpiler/passes/calibration/__init__.py +++ b/qiskit/transpiler/passes/calibration/__init__.py @@ -14,3 +14,4 @@ from .pulse_gate import PulseGates from .rzx_builder import RZXCalibrationBuilder, RZXCalibrationBuilderNoEcho +from .rx_builder import RXCalibrationBuilder diff --git a/qiskit/transpiler/passes/calibration/builders.py b/qiskit/transpiler/passes/calibration/builders.py index 93adb4f945f1..49c2c6427317 100644 --- a/qiskit/transpiler/passes/calibration/builders.py +++ b/qiskit/transpiler/passes/calibration/builders.py @@ -17,3 +17,4 @@ # pylint: disable=unused-import from .pulse_gate import PulseGates from .rzx_builder import RZXCalibrationBuilder, RZXCalibrationBuilderNoEcho +from .rx_builder import RXCalibrationBuilder diff --git a/qiskit/transpiler/passes/calibration/rx_builder.py b/qiskit/transpiler/passes/calibration/rx_builder.py new file mode 100644 index 000000000000..1aff88ec802f --- /dev/null +++ b/qiskit/transpiler/passes/calibration/rx_builder.py @@ -0,0 +1,160 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Add single-pulse RX calibrations that are bootstrapped from the SX calibration.""" + +from typing import Union +from functools import lru_cache +import numpy as np + +from qiskit.circuit import Instruction +from qiskit.pulse import Schedule, ScheduleBlock, builder, ScalableSymbolicPulse +from qiskit.pulse.channels import Channel +from qiskit.pulse.library.symbolic_pulses import Drag +from qiskit.transpiler.passes.calibration.base_builder import CalibrationBuilder +from qiskit.transpiler import Target +from qiskit.circuit.library.standard_gates import RXGate +from qiskit.exceptions import QiskitError + + +class RXCalibrationBuilder(CalibrationBuilder): + """Add single-pulse RX calibrations that are bootstrapped from the SX calibration. + + .. note:: + + Requirement: NormalizeRXAngles pass (one of the optimization passes). + + It is recommended to place this pass in the post-optimization stage of a passmanager. + A simple demo: + + .. code-block:: python + + from qiskit.providers.fake_provider import FakeBelemV2 + from qiskit.transpiler import PassManager, PassManagerConfig + from qiskit.transpiler.preset_passmanagers import level_1_pass_manager + from qiskit.circuit import Parameter + from qiskit.circuit.library import QuantumVolume + from qiskit.circuit.library.standard_gates import RXGate + + from calibration.rx_builder import RXCalibrationBuilder + + qv = QuantumVolume(4, 4, seed=1004) + + # Transpiling with single pulse RX gates enabled + backend_with_single_pulse_rx = FakeBelemV2() + rx_inst_props = {} + for i in range(backend_with_single_pulse_rx.num_qubits): + rx_inst_props[(i,)] = None + backend_with_single_pulse_rx.target.add_instruction(RXGate(Parameter("theta")), rx_inst_props) + config_with_rx = PassManagerConfig.from_backend(backend=backend_with_single_pulse_rx) + pm_with_rx = level_1_pass_manager(pass_manager_config=config_with_rx) + rx_builder = RXCalibrationBuilder(target=backend_with_single_pulse_rx.target) + pm_with_rx.post_optimization = PassManager([rx_builder]) + transpiled_circ_with_single_pulse_rx = pm_with_rx.run(qv) + transpiled_circ_with_single_pulse_rx.count_ops() + + # Conventional transpilation: each RX gate is decomposed into a sequence with two SX gates + original_backend = FakeBelemV2() + original_config = PassManagerConfig.from_backend(backend=original_backend) + original_pm = level_1_pass_manager(pass_manager_config=original_config) + original_transpiled_circ = original_pm.run(qv) + original_transpiled_circ.count_ops() + + References + * [1]: Gokhale et al. (2020), Optimized Quantum Compilation for + Near-Term Algorithms with OpenPulse. + `arXiv:2004.11205 ` + """ + + def __init__( + self, + target: Target = None, + ): + """Bootstrap single-pulse RX gate calibrations from the + (hardware-calibrated) SX gate calibration. + + Args: + target (Target): Should contain a SX calibration that will be + used for bootstrapping RX calibrations. + """ + from qiskit.transpiler.passes.optimization import NormalizeRXAngle + + super().__init__() + self.target = target + self.already_generated = {} + self.requires = [NormalizeRXAngle(self.target)] + + def supported(self, node_op: Instruction, qubits: list) -> bool: + """ + Check if the calibration for SX gate exists and it's a single DRAG pulse. + """ + return ( + isinstance(node_op, RXGate) + and self.target.has_calibration("sx", tuple(qubits)) + and (len(self.target.get_calibration("sx", tuple(qubits)).instructions) == 1) + and isinstance( + self.target.get_calibration("sx", tuple(qubits)).instructions[0][1].pulse, + ScalableSymbolicPulse, + ) + and self.target.get_calibration("sx", tuple(qubits)).instructions[0][1].pulse.pulse_type + == "Drag" + ) + + def get_calibration(self, node_op: Instruction, qubits: list) -> Union[Schedule, ScheduleBlock]: + """ + Generate RX calibration for the rotation angle specified in node_op. + """ + # already within [0, pi] by NormalizeRXAngles pass + angle = node_op.params[0] + + try: + angle = float(angle) + except TypeError as ex: + raise QiskitError("Target rotation angle is not assigned.") from ex + + params = ( + self.target.get_calibration("sx", tuple(qubits)) + .instructions[0][1] + .pulse.parameters.copy() + ) + new_rx_sched = _create_rx_sched( + rx_angle=angle, + channel=self.target.get_calibration("sx", tuple(qubits)).channels[0], + duration=params["duration"], + amp=params["amp"], + sigma=params["sigma"], + beta=params["beta"], + ) + + return new_rx_sched + + +@lru_cache +def _create_rx_sched( + rx_angle: float, + duration: int, + amp: float, + sigma: float, + beta: float, + channel: Channel, +): + """Generates (and caches) pulse calibrations for RX gates. + Assumes that the rotation angle is in [0, pi]. + """ + new_amp = rx_angle / (np.pi / 2) * amp + with builder.build() as new_rx_sched: + builder.play( + Drag(duration=duration, amp=new_amp, sigma=sigma, beta=beta, angle=0), + channel=channel, + ) + + return new_rx_sched diff --git a/qiskit/transpiler/passes/optimization/__init__.py b/qiskit/transpiler/passes/optimization/__init__.py index 6b87415194db..291fd9aec58b 100644 --- a/qiskit/transpiler/passes/optimization/__init__.py +++ b/qiskit/transpiler/passes/optimization/__init__.py @@ -35,3 +35,4 @@ from .reset_after_measure_simplification import ResetAfterMeasureSimplification from .optimize_cliffords import OptimizeCliffords from .collect_cliffords import CollectCliffords +from .normalize_rx_angle import NormalizeRXAngle diff --git a/qiskit/transpiler/passes/optimization/normalize_rx_angle.py b/qiskit/transpiler/passes/optimization/normalize_rx_angle.py new file mode 100644 index 000000000000..33b381fc2283 --- /dev/null +++ b/qiskit/transpiler/passes/optimization/normalize_rx_angle.py @@ -0,0 +1,157 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Performs three optimizations to reduce the number of pulse calibrations for +the single-pulse RX gates: +Wrap RX Gate rotation angles into [0, pi] by sandwiching them with RZ gates. +Convert RX(pi/2) to SX, and RX(pi) to X if the calibrations exist in the target. +Quantize the RX rotation angles by assigning the same value for the angles +that differ within a resolution provided by the user. +""" + +import numpy as np + +from qiskit.transpiler.basepasses import TransformationPass +from qiskit.dagcircuit import DAGCircuit +from qiskit.circuit.library.standard_gates import RXGate, RZGate, SXGate, XGate + + +class NormalizeRXAngle(TransformationPass): + """Normalize theta parameter of RXGate instruction. + + The parameter normalization is performed with following steps. + + 1) Wrap RX Gate theta into [0, pi]. When theta is negative value, the gate is + decomposed into the following sequence. + + .. code-block:: + + ┌───────┐┌─────────┐┌────────┐ + q: ┤ Rz(π) ├┤ Rx(|θ|) ├┤ Rz(-π) ├ + └───────┘└─────────┘└────────┘ + + 2) If the operation is supported by target, convert RX(pi/2) to SX, and RX(pi) to X. + + 3) Quantize theta value according to the user-specified resolution. + + This will help reduce the size of calibration data sent over the wire, + and allow us to exploit the more accurate, hardware-calibrated pulses. + Note that pulse calibration might be attached per each rotation angle. + """ + + def __init__(self, target=None, resolution_in_radian=0): + """NormalizeRXAngle initializer. + + Args: + target (Target): The :class:`~.Target` representing the target backend. + If the target contains SX and X calibrations, this pass will replace the + corresponding RX gates with SX and X gates. + resolution_in_radian (float): Resolution for RX rotation angle quantization. + If set to zero, this pass won't modify the rotation angles in the given DAG. + (=Provides aribitary-angle RX) + """ + super().__init__() + self.target = target + self.resolution_in_radian = resolution_in_radian + self.already_generated = {} + + def quantize_angles(self, qubit, original_angle): + """Quantize the RX rotation angles by assigning the same value for the angles + that differ within a resolution provided by the user. + + Args: + qubit (Qubit): This will be the dict key to access the list of quantized rotation angles. + original_angle (float): Original rotation angle, before quantization. + + Returns: + float: Quantized angle. + """ + + # check if there is already a calibration for a simliar angle + try: + angles = self.already_generated[qubit] # 1d ndarray of already generated angles + similar_angle = angles[ + np.isclose(angles, original_angle, atol=self.resolution_in_radian / 2) + ] + quantized_angle = ( + float(similar_angle[0]) if len(similar_angle) > 1 else float(similar_angle) + ) + except KeyError: + quantized_angle = original_angle + self.already_generated[qubit] = np.array([quantized_angle]) + except TypeError: + quantized_angle = original_angle + self.already_generated[qubit] = np.append( + self.already_generated[qubit], quantized_angle + ) + + return quantized_angle + + def run(self, dag): + """Run the NormalizeRXAngle pass on ``dag``. + + Args: + dag (DAGCircuit): The DAG to be optimized. + + Returns: + DAGCircuit: A DAG with RX gate calibration. + """ + + # Iterate over all op_nodes and replace RX if eligible for modification. + for op_node in dag.op_nodes(): + if not isinstance(op_node.op, RXGate): + continue + + raw_theta = op_node.op.params[0] + wrapped_theta = np.arctan2(np.sin(raw_theta), np.cos(raw_theta)) # [-pi, pi] + + if self.resolution_in_radian: + wrapped_theta = self.quantize_angles(op_node.qargs[0], wrapped_theta) + + half_pi_rotation = np.isclose( + abs(wrapped_theta), np.pi / 2, atol=self.resolution_in_radian / 2 + ) + pi_rotation = np.isclose(abs(wrapped_theta), np.pi, atol=self.resolution_in_radian / 2) + + should_modify_node = ( + (wrapped_theta != raw_theta) + or (wrapped_theta < 0) + or half_pi_rotation + or pi_rotation + ) + + if should_modify_node: + mini_dag = DAGCircuit() + mini_dag.add_qubits(op_node.qargs) + + # new X-rotation gate with angle in [0, pi] + if half_pi_rotation: + physical_qubit_idx = dag.find_bit(op_node.qargs[0]).index + if self.target.instruction_supported("sx", (physical_qubit_idx,)): + mini_dag.apply_operation_back(SXGate(), qargs=op_node.qargs) + elif pi_rotation: + physical_qubit_idx = dag.find_bit(op_node.qargs[0]).index + if self.target.instruction_supported("x", (physical_qubit_idx,)): + mini_dag.apply_operation_back(XGate(), qargs=op_node.qargs) + else: + mini_dag.apply_operation_back( + RXGate(np.abs(wrapped_theta)), qargs=op_node.qargs + ) + + # sandwich with RZ if the intended rotation angle was negative + if wrapped_theta < 0: + mini_dag.apply_operation_front(RZGate(np.pi), qargs=op_node.qargs) + mini_dag.apply_operation_back(RZGate(-np.pi), qargs=op_node.qargs) + + dag.substitute_node_with_dag(node=op_node, input_dag=mini_dag, wires=op_node.qargs) + + return dag diff --git a/releasenotes/notes/single-pulse-rx-cal-347aadcee7bfe60b.yaml b/releasenotes/notes/single-pulse-rx-cal-347aadcee7bfe60b.yaml new file mode 100644 index 000000000000..c6cc05c907cd --- /dev/null +++ b/releasenotes/notes/single-pulse-rx-cal-347aadcee7bfe60b.yaml @@ -0,0 +1,25 @@ +--- +features: + - | + Two new transpiler passes are added to generate single-pulse RX gate calibrations on the fly. + These single-pulse RX calibrations will reduce the gate time in half, as described in + P.Gokhale et al, Optimized Quantum Compilation for Near-Term Algorithms with OpenPulse + (2020), `arXiv:2004.11205 `. + + To reduce the amount of RX calibration data that needs to be generated, + :class:`~qiskit.transpiler.passes.optimization.normalize_rx_angle.NormalizeRXAngle` + performs three optimizations: wrapping RX gate rotation angles to [0, pi], + replacing RX(pi/2) and RX(pi) with SX and X gates, and quantizing the rotation angles. + This pass is required to be run before + :class:`~qiskit.transpiler.passes.calibration.rx_builder.RXCalibrationBuilder`, + which generates RX calibrations on the fly. + + The optimizations performed by ``NormalizeRXAngle`` reduce the amount of calibration data and + enable us to take advantage of the more accurate, hardware-calibrated + pulses. The calibrations generated by ``RXCalibrationBuilder`` are bootstrapped from + the SX gate calibration, which should be already present in the target. + The amplitude is linearly scaled to achieve the desired arbitrary rotation angle. + + Such single-pulse calibrations reduces the RX gate time in half, compared to the + conventional sequence that consists of two SX pulses. + There could be an improvement in fidelity due to this reduction in gate time. \ No newline at end of file diff --git a/test/python/transpiler/test_calibrationbuilder.py b/test/python/transpiler/test_calibrationbuilder.py index 7baa39c8ad6a..45ada1789a88 100644 --- a/test/python/transpiler/test_calibrationbuilder.py +++ b/test/python/transpiler/test_calibrationbuilder.py @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Test the RZXCalibrationBuilderNoEcho.""" +"""Test the CalibrationBuilder subclasses.""" from math import pi, erf @@ -18,10 +18,12 @@ from ddt import data, ddt from qiskit.converters import circuit_to_dag -from qiskit import circuit, schedule, QiskitError -from qiskit.circuit.library.standard_gates import SXGate, RZGate +from qiskit import circuit, schedule, QiskitError, QuantumCircuit +from qiskit.circuit import Parameter +from qiskit.circuit.library.standard_gates import SXGate, RZGate, RXGate from qiskit.providers.fake_provider import FakeHanoi # TODO - include FakeHanoiV2, FakeSherbrooke from qiskit.providers.fake_provider import FakeArmonk +from qiskit.providers.fake_provider import FakeBelemV2 from qiskit.pulse import ( ControlChannel, DriveChannel, @@ -30,14 +32,18 @@ Play, InstructionScheduleMap, Schedule, + Drag, + Square, ) from qiskit.pulse import builder from qiskit.pulse.transforms import target_qobj_transform +from qiskit.dagcircuit import DAGOpNode from qiskit.test import QiskitTestCase -from qiskit.transpiler import PassManager +from qiskit.transpiler import PassManager, Target, InstructionProperties from qiskit.transpiler.passes.calibration.builders import ( RZXCalibrationBuilder, RZXCalibrationBuilderNoEcho, + RXCalibrationBuilder, ) @@ -428,3 +434,95 @@ def test_pass_alive_with_dcx_ish(self): # User warning that says q0 q1 is invalid cal_qc = PassManager(pass_).run(rzx_qc) self.assertEqual(cal_qc, rzx_qc) + + +@ddt +class TestRXCalibrationBuilder(QiskitTestCase): + """Test RXCalibrationBuilder.""" + + def compute_correct_rx_amplitude(self, rx_theta: float, sx_amp: float): + """A helper function to compute the amplitude of the bootstrapped RX pulse.""" + return sx_amp * (np.abs(rx_theta) / (0.5 * np.pi)) + + def test_not_supported_if_no_sx_schedule(self): + """Test that supported() returns False when the target does not have SX calibration.""" + empty_target = Target() + tp = RXCalibrationBuilder(empty_target) + qubits = (0,) + node_op = DAGOpNode(RXGate(0.5), qubits, []) + self.assertFalse(tp.supported(node_op, qubits)) + + def test_not_supported_if_sx_not_drag(self): + """Test that supported() returns False when the default SX calibration is not a DRAG.""" + target = Target() + with builder.build() as square_sx_cal: + builder.play(Square(amp=0.1, duration=160, phase=0), DriveChannel(0)) + target.add_instruction(SXGate(), {(0,): InstructionProperties(calibration=square_sx_cal)}) + tp = RXCalibrationBuilder(target) + qubits = (0,) + node_op = DAGOpNode(RXGate(0.5), qubits, []) + self.assertFalse(tp.supported(node_op, qubits)) + + def test_raises_error_when_rotation_angle_not_assigned(self): + """Test that get_calibration() fails when the RX gate's rotation angle is + an unassigned Parameter, not a number. + The QiskitError occurs while trying to typecast the Parameter into a float. + """ + backend = FakeBelemV2() + tp = RXCalibrationBuilder(backend.target) + qubits = (0,) + rx = RXGate(Parameter("theta")) + with self.assertRaises(QiskitError): + tp.get_calibration(rx, qubits) + + # Note: These input data values should be within [0, pi] because + # the required NormalizeRXAngles pass ensures that. + @data(0, np.pi / 3, (2 / 3) * np.pi) + def test_pulse_schedule(self, theta: float): + """Test that get_calibration() returns a schedule with correct amplitude.""" + backend = FakeBelemV2() + dummy_target = Target() + sx_amp, sx_beta, sx_sigma, sx_duration, sx_angle = 0.6, 2, 40, 160, 0.5 + with builder.build(backend=backend) as dummy_sx_cal: + builder.play( + Drag( + amp=sx_amp, beta=sx_beta, sigma=sx_sigma, duration=sx_duration, angle=sx_angle + ), + DriveChannel(0), + ) + dummy_target.add_instruction( + SXGate(), {(0,): InstructionProperties(calibration=dummy_sx_cal)} + ) + + tp = RXCalibrationBuilder(dummy_target) + test = tp.get_calibration(RXGate(theta), qubits=(0,)) + + with builder.build(backend=backend) as correct_rx_schedule: + builder.play( + Drag( + amp=self.compute_correct_rx_amplitude(rx_theta=theta, sx_amp=sx_amp), + beta=sx_beta, + sigma=sx_sigma, + duration=sx_duration, + angle=0, + ), + channel=DriveChannel(0), + ) + + self.assertEqual(test, correct_rx_schedule) + + def test_with_normalizerxangles(self): + """Checks that this pass works well with the NormalizeRXAngles pass.""" + backend = FakeBelemV2() + # NormalizeRXAngle pass should also be included because it's a required pass. + pm = PassManager(RXCalibrationBuilder(backend.target)) + + qc = QuantumCircuit(1) + qc.rx(np.pi / 3, 0) + qc.rx(np.pi / 2, 0) + qc.rx(np.pi, 0) + + # Only RX(pi/3) should get a rx calibration. + # The others should be converted to SX and X + tc = pm.run(qc) + self.assertEqual(len(tc.calibrations["rx"]), 1) diff --git a/test/python/transpiler/test_normalize_rx_angle.py b/test/python/transpiler/test_normalize_rx_angle.py new file mode 100644 index 000000000000..b3669b841afe --- /dev/null +++ b/test/python/transpiler/test_normalize_rx_angle.py @@ -0,0 +1,139 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Test the NormalizeRXAngle pass""" + +import unittest +import numpy as np +from ddt import ddt, named_data + +from qiskit import QuantumCircuit + +from qiskit.transpiler.passes.optimization.normalize_rx_angle import ( + NormalizeRXAngle, +) +from qiskit.test import QiskitTestCase +from qiskit.providers.fake_provider import FakeBelemV2 +from qiskit.transpiler import Target +from qiskit.circuit.library.standard_gates import SXGate + + +@ddt +class TestNormalizeRXAngle(QiskitTestCase): + """Tests the NormalizeRXAngle pass.""" + + def test_not_convert_to_x_if_no_calib_in_target(self): + """Check that RX(pi) is NOT converted to X, + if X calibration is not present in the target""" + empty_target = Target() + tp = NormalizeRXAngle(target=empty_target) + + qc = QuantumCircuit(1) + qc.rx(90, 0) + + transpiled_circ = tp(qc) + self.assertEqual(transpiled_circ.count_ops().get("x", 0), 0) + + def test_sx_conversion_works(self): + """Check that RX(pi/2) is converted to SX, + if SX calibration is present in the target""" + target = Target() + target.add_instruction(SXGate(), properties={(0,): None}) + tp = NormalizeRXAngle(target=target) + + qc = QuantumCircuit(1) + qc.rx(np.pi / 2, 0) + + transpiled_circ = tp(qc) + self.assertEqual(transpiled_circ.count_ops().get("sx", 0), 1) + + def test_rz_added_for_negative_rotation_angles(self): + """Check that RZ is added before and after RX, + if RX rotation angle is negative""" + + backend = FakeBelemV2() + tp = NormalizeRXAngle(target=backend.target) + + # circuit to transpile and test + qc = QuantumCircuit(1) + qc.rx((-1 / 3) * np.pi, 0) + transpiled_circ = tp(qc) + + # circuit to show the correct answer + qc_ref = QuantumCircuit(1) + qc_ref.rz(np.pi, 0) + qc_ref.rx(np.pi / 3, 0) + qc_ref.rz(-np.pi, 0) + + self.assertQuantumCircuitEqual(transpiled_circ, qc_ref) + + @named_data( + {"name": "-0.3pi", "raw_theta": -0.3 * np.pi, "correct_wrapped_theta": 0.3 * np.pi}, + {"name": "1.7pi", "raw_theta": 1.7 * np.pi, "correct_wrapped_theta": 0.3 * np.pi}, + {"name": "2.2pi", "raw_theta": 2.2 * np.pi, "correct_wrapped_theta": 0.2 * np.pi}, + ) + def test_angle_wrapping_works(self, raw_theta, correct_wrapped_theta): + """Check that RX rotation angles are correctly wrapped to [0, pi]""" + backend = FakeBelemV2() + tp = NormalizeRXAngle(target=backend.target) + + # circuit to transpile and test + qc = QuantumCircuit(1) + qc.rx(raw_theta, 0) + + transpiled_circuit = tp(qc) + wrapped_theta = transpiled_circuit.get_instructions("rx")[0].operation.params[0] + self.assertAlmostEqual(wrapped_theta, correct_wrapped_theta) + + @named_data( + { + "name": "angles are within resolution", + "resolution": 0.1, + "rx_angles": [0.3, 0.303], + "correct_num_of_cals": 1, + }, + { + "name": "angles are not within resolution", + "resolution": 0.1, + "rx_angles": [0.2, 0.4], + "correct_num_of_cals": 2, + }, + { + "name": "same angle three times", + "resolution": 0.1, + "rx_angles": [0.2, 0.2, 0.2], + "correct_num_of_cals": 1, + }, + ) + def test_quantize_angles(self, resolution, rx_angles, correct_num_of_cals): + """Test that quantize_angles() adds a new calibration only if + the requested angle is not in the vicinity of the already generated angles. + """ + backend = FakeBelemV2() + tp = NormalizeRXAngle(backend.target, resolution_in_radian=resolution) + + qc = QuantumCircuit(1) + for rx_angle in rx_angles: + qc.rx(rx_angle, 0) + transpiled_circuit = tp(qc) + + angles = [ + inst.operation.params[0] + for inst in transpiled_circuit.data + if inst.operation.name == "rx" + ] + angles_without_duplicate = list(dict.fromkeys(angles)) + self.assertEqual(len(angles_without_duplicate), correct_num_of_cals) + + +if __name__ == "__main__": + unittest.main()