diff --git a/docs/conf.py b/docs/conf.py index 8e0d3f754..bea64f226 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,48 +14,46 @@ # General options: -project = 'Qiskit Dynamics' -copyright = '2020, Qiskit Development Team' # pylint: disable=redefined-builtin -author = 'Qiskit Development Team' +project = "Qiskit Dynamics" +copyright = "2020, Qiskit Development Team" # pylint: disable=redefined-builtin +author = "Qiskit Development Team" # The short X.Y version -version = '' +version = "" # The full version, including alpha/beta/rc tags -release = '0.6.0' +release = "0.6.0" extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.mathjax', - 'sphinx.ext.viewcode', - 'sphinx.ext.extlinks', - 'jupyter_sphinx', - 'sphinx_autodoc_typehints', - 'reno.sphinxext', - 'sphinx.ext.intersphinx', - 'nbsphinx', - 'sphinxcontrib.bibtex', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", + "sphinx.ext.extlinks", + "jupyter_sphinx", + "sphinx_autodoc_typehints", + "reno.sphinxext", + "sphinx.ext.intersphinx", + "nbsphinx", + "sphinxcontrib.bibtex", "qiskit_sphinx_theme", ] templates_path = ["_templates"] numfig = True -numfig_format = { - 'table': 'Table %s' -} -language = 'en' -pygments_style = 'colorful' +numfig_format = {"table": "Table %s"} +language = "en" +pygments_style = "colorful" add_module_names = False -modindex_common_prefix = ['qiskit_dynamics.'] -bibtex_default_style = 'unsrt' -bibtex_bibfiles = ['refs.bib'] +modindex_common_prefix = ["qiskit_dynamics."] +bibtex_default_style = "unsrt" +bibtex_bibfiles = ["refs.bib"] bibtex_bibliography_header = ".. rubric:: References" bibtex_footbibliography_header = bibtex_bibliography_header # html theme options -html_theme = 'qiskit-ecosystem' -html_last_updated_fmt = '%Y/%m/%d' +html_theme = "qiskit-ecosystem" +html_last_updated_fmt = "%Y/%m/%d" docs_url_prefix = "qiskit-dynamics" @@ -72,7 +70,7 @@ intersphinx_mapping = { "qiskit": ("https://docs.quantum.ibm.com/api/qiskit", None), "qiskit_experiments": ("https://qiskit-community.github.io/qiskit-experiments/", None), - "arraylias": ("https://qiskit-community.github.io/arraylias/", None) + "arraylias": ("https://qiskit-community.github.io/arraylias/", None), } # nbsphinx options (for tutorials) @@ -80,6 +78,6 @@ # TODO: swap this with always if tutorial execution is too slow for ci and needs # a separate job # nbsphinx_execute = os.getenv('QISKIT_DOCS_BUILD_TUTORIALS', 'never') -nbsphinx_execute = 'always' -nbsphinx_widgets_path = '' -exclude_patterns = ['_build', '**.ipynb_checkpoints'] \ No newline at end of file +nbsphinx_execute = "always" +nbsphinx_widgets_path = "" +exclude_patterns = ["_build", "**.ipynb_checkpoints"] diff --git a/qiskit_dynamics/backend/__init__.py b/qiskit_dynamics/backend/__init__.py index 79a1df97c..16c1943c4 100644 --- a/qiskit_dynamics/backend/__init__.py +++ b/qiskit_dynamics/backend/__init__.py @@ -21,8 +21,8 @@ This module contains the :class:`.DynamicsBackend` class, which provides a :class:`~qiskit.providers.backend.BackendV2` interface for running pulse-level simulations with -Qiskit Dynamics. The :class:`.DynamicsBackend` can directly simulate :class:`~qiskit.pulse.Schedule` -and :class:`~qiskit.pulse.ScheduleBlock` instances, and can also be configured to simulate +Qiskit Dynamics. The :class:`.DynamicsBackend` can directly simulate :class:`~qiskit_dynamics.pulse.Schedule` +and :class:`~qiskit_dynamics.pulse.ScheduleBlock` instances, and can also be configured to simulate :class:`~qiskit.circuit.QuantumCircuit`\s at the pulse-level via circuit to pulse transpilation. This module also exposes some functions utilized by :class:`.DynamicsBackend` that may be of use to diff --git a/qiskit_dynamics/backend/dynamics_backend.py b/qiskit_dynamics/backend/dynamics_backend.py index 18f1b14f5..506d93fc8 100644 --- a/qiskit_dynamics/backend/dynamics_backend.py +++ b/qiskit_dynamics/backend/dynamics_backend.py @@ -27,13 +27,13 @@ import numpy as np from scipy.integrate._ivp.ivp import OdeResult -from qiskit import pulse +from qiskit_dynamics import pulse from qiskit.qobj.utils import MeasLevel, MeasReturnType from qiskit.qobj.common import QobjHeader from qiskit.transpiler import Target, InstructionProperties from qiskit.circuit.library import Measure -from qiskit.pulse import Schedule, ScheduleBlock -from qiskit.pulse.transforms.canonicalization import block_to_schedule +from qiskit_dynamics.pulse import Schedule, ScheduleBlock +from qiskit_dynamics.pulse.transforms.canonicalization import block_to_schedule from qiskit.providers.options import Options from qiskit.providers.backend import BackendV1, BackendV2 from qiskit.providers.models.pulsedefaults import PulseDefaults @@ -86,7 +86,7 @@ class DynamicsBackend(BackendV2): ) Without further configuration, the above ``backend`` can be used to simulate either - :class:`~qiskit.pulse.Schedule` or :class:`~qiskit.pulse.ScheduleBlock` instances. + :class:`~qiskit_dynamics.pulse.Schedule` or :class:`~qiskit_dynamics.pulse.ScheduleBlock` instances. Pulse-level simulations defined in terms of :class:`~qiskit.circuit.QuantumCircuit` instances can also be performed if each gate in the circuit has a corresponding pulse-level definition, @@ -351,7 +351,7 @@ def solve( validate: Optional[bool] = True, ) -> Union[OdeResult, List[OdeResult]]: """Simulate a list of :class:`~qiskit.circuit.QuantumCircuit`, - :class:`~qiskit.pulse.Schedule`, or :class:`~qiskit.pulse.ScheduleBlock` instances and + :class:`~qiskit_dynamics.pulse.Schedule`, or :class:`~qiskit_dynamics.pulse.ScheduleBlock` instances and return the ``OdeResult``. This method is analogous to :meth:`.Solver.solve`, however it additionally utilizes diff --git a/qiskit_dynamics/pulse/__init__.py b/qiskit_dynamics/pulse/__init__.py index 99733ab58..8228e3141 100644 --- a/qiskit_dynamics/pulse/__init__.py +++ b/qiskit_dynamics/pulse/__init__.py @@ -19,7 +19,50 @@ .. currentmodule:: qiskit_dynamics.pulse -This module contains tools to interface :mod:`qiskit.pulse` with Qiskit Dynamics. Qiskit Dynamics +Qiskit-Pulse is a pulse-level quantum programming kit. This lower level of +programming offers the user more control than programming with +:py:class:`~qiskit.circuit.QuantumCircuit`\ s. + +Extracting the greatest performance from quantum hardware requires real-time +pulse-level instructions. Pulse answers that need: it enables the quantum +physicist *user* to specify the exact time dynamics of an experiment. +It is especially powerful for error mitigation techniques. + +The input is given as arbitrary, time-ordered signals (see: :ref:`Instructions `) +scheduled in parallel over multiple virtual hardware or simulator resources +(see: :ref:`Channels `). The system also allows the user to recover the +time dynamics of the measured output. + +This is sufficient to allow the quantum physicist to explore and correct for +noise in a quantum system. + +.. automodule:: qiskit.pulse.instructions +.. automodule:: qiskit.pulse.library +.. automodule:: qiskit.pulse.channels +.. automodule:: qiskit.pulse.schedule +.. automodule:: qiskit.pulse.transforms +.. automodule:: qiskit.pulse.builder + +.. currentmodule:: qiskit.pulse + +Configuration +============= + +.. autosummary:: + :toctree: ../stubs/ + + InstructionScheduleMap + +Exceptions +========== + +.. autoexception:: PulseError +.. autoexception:: BackendNotSet +.. autoexception:: NoActiveBuilder +.. autoexception:: UnassignedDurationError +.. autoexception:: UnassignedReferenceError + +This module also contains tools to interface :mod:`qiskit.pulse` with Qiskit Dynamics. Qiskit Dynamics simulates time evolution using the :class:`Signal` class, however :mod:`qiskit.pulse` specifies pulse instructions using a :class:`~qiskit.pulse.Schedule` or :class:`~qiskit.pulse.ScheduleBlock`. This module contains the required converters to convert from a :mod:`qiskit.pulse` control @@ -81,4 +124,105 @@ InstructionToSignals """ + from .pulse_to_signals import InstructionToSignals + + +# Builder imports. +from qiskit_dynamics.pulse.builder import ( + # Construction methods. + active_backend, + build, + num_qubits, + qubit_channels, + samples_to_seconds, + seconds_to_samples, + # Instructions. + acquire, + barrier, + call, + delay, + play, + reference, + set_frequency, + set_phase, + shift_frequency, + shift_phase, + snapshot, + # Channels. + acquire_channel, + control_channels, + drive_channel, + measure_channel, + # Contexts. + align_equispaced, + align_func, + align_left, + align_right, + align_sequential, + frequency_offset, + phase_offset, + # Macros. + macro, + measure, + measure_all, + delay_qubits, +) +from qiskit_dynamics.pulse.channels import ( + AcquireChannel, + ControlChannel, + DriveChannel, + MeasureChannel, + MemorySlot, + RegisterSlot, + SnapshotChannel, +) +from qiskit_dynamics.pulse.configuration import ( + Discriminator, + Kernel, + LoConfig, + LoRange, +) +from qiskit_dynamics.pulse.exceptions import ( + PulseError, + BackendNotSet, + NoActiveBuilder, + UnassignedDurationError, + UnassignedReferenceError, +) +from qiskit_dynamics.pulse.instruction_schedule_map import InstructionScheduleMap +from qiskit_dynamics.pulse.instructions import ( + Acquire, + Delay, + Instruction, + Play, + SetFrequency, + SetPhase, + ShiftFrequency, + ShiftPhase, + Snapshot, +) +from qiskit_dynamics.pulse.library import ( + Constant, + Drag, + Gaussian, + GaussianSquare, + GaussianSquareDrag, + gaussian_square_echo, + Sin, + Cos, + Sawtooth, + Triangle, + Square, + GaussianDeriv, + Sech, + SechDeriv, + SymbolicPulse, + ScalableSymbolicPulse, + Waveform, +) +from qiskit_dynamics.pulse.library.samplers.decorators import functional_pulse +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleBlock +from qiskit_dynamics.scheduler.schedule_circuit import schedule + +import update_circuit diff --git a/qiskit_dynamics/pulse/builder.py b/qiskit_dynamics/pulse/builder.py new file mode 100644 index 000000000..8275437b5 --- /dev/null +++ b/qiskit_dynamics/pulse/builder.py @@ -0,0 +1,2222 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +r""" + +.. _pulse_builder: + +============= +Pulse Builder +============= + +.. + We actually want people to think of these functions as being defined within the ``qiskit.pulse`` + namespace, not the submodule ``qiskit.pulse.builder``. + +.. currentmodule: qiskit.pulse + +Use the pulse builder DSL to write pulse programs with an imperative syntax. + +.. warning:: + The pulse builder interface is still in active development. It may have + breaking API changes without deprecation warnings in future releases until + otherwise indicated. + + +The pulse builder provides an imperative API for writing pulse programs +with less difficulty than the :class:`~qiskit.pulse.Schedule` API. +It contextually constructs a pulse schedule and then emits the schedule for +execution. For example, to play a series of pulses on channels is as simple as: + + +.. plot:: + :include-source: + + from qiskit import pulse + + dc = pulse.DriveChannel + d0, d1, d2, d3, d4 = dc(0), dc(1), dc(2), dc(3), dc(4) + + with pulse.build(name='pulse_programming_in') as pulse_prog: + pulse.play([1, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1], d0) + pulse.play([1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0], d1) + pulse.play([1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0], d2) + pulse.play([1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0], d3) + pulse.play([1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0], d4) + + pulse_prog.draw() + +To begin pulse programming we must first initialize our program builder +context with :func:`build`, after which we can begin adding program +statements. For example, below we write a simple program that :func:`play`\s +a pulse: + +.. plot:: + :include-source: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.play(pulse.Constant(100, 1.0), d0) + + pulse_prog.draw() + +The builder initializes a :class:`.pulse.Schedule`, ``pulse_prog`` +and then begins to construct the program within the context. The output pulse +schedule will survive after the context is exited and can be used like a +normal Qiskit schedule. + +Pulse programming has a simple imperative style. This leaves the programmer +to worry about the raw experimental physics of pulse programming and not +constructing cumbersome data structures. + +We can optionally pass a :class:`~qiskit.providers.Backend` to +:func:`build` to enable enhanced functionality. Below, we prepare a Bell state +by automatically compiling the required pulses from their gate-level +representations, while simultaneously applying a long decoupling pulse to a +neighboring qubit. We terminate the experiment with a measurement to observe the +state we prepared. This program which mixes circuits and pulses will be +automatically lowered to be run as a pulse program: + +.. plot:: + :include-source: + + from math import pi + from qiskit.compiler import schedule + from qiskit.circuit import QuantumCircuit + + from qiskit import pulse + from qiskit.providers.fake_provider import GenericBackendV2 + + backend = GenericBackendV2(num_qubits=5, calibrate_instructions=True) + + d2 = pulse.DriveChannel(2) + + qc = QuantumCircuit(2) + # Hadamard + qc.rz(pi/2, 0) + qc.sx(0) + qc.rz(pi/2, 0) + + qc.cx(0, 1) + + bell_sched = schedule(qc, backend) + + with pulse.build(backend) as decoupled_bell_prep_and_measure: + # We call our bell state preparation schedule constructed above. + with pulse.align_right(): + pulse.call(bell_sched) + pulse.play(pulse.Constant(bell_sched.duration, 0.02), d2) + pulse.barrier(0, 1, 2) + registers = pulse.measure_all() + + decoupled_bell_prep_and_measure.draw() + + +With the pulse builder we are able to blend programming on qubits and channels. +While the pulse schedule is based on instructions that operate on +channels, the pulse builder automatically handles the mapping from qubits to +channels for you. + +In the example below we demonstrate some more features of the pulse builder: + +.. code-block:: + + import math + from qiskit.compiler import schedule + + from qiskit import pulse, QuantumCircuit + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + qc = QuantumCircuit(2, 2) + qc.cx(0, 1) + + with pulse.build(backend) as pulse_prog: + # Create a pulse. + gaussian_pulse = pulse.Gaussian(10, 1.0, 2) + # Get the qubit's corresponding drive channel from the backend. + d0 = pulse.drive_channel(0) + d1 = pulse.drive_channel(1) + # Play a pulse at t=0. + pulse.play(gaussian_pulse, d0) + # Play another pulse directly after the previous pulse at t=10. + pulse.play(gaussian_pulse, d0) + # The default scheduling behavior is to schedule pulses in parallel + # across channels. For example, the statement below + # plays the same pulse on a different channel at t=0. + pulse.play(gaussian_pulse, d1) + + # We also provide pulse scheduling alignment contexts. + # The default alignment context is align_left. + + # The sequential context schedules pulse instructions sequentially in time. + # This context starts at t=10 due to earlier pulses above. + with pulse.align_sequential(): + pulse.play(gaussian_pulse, d0) + # Play another pulse after at t=20. + pulse.play(gaussian_pulse, d1) + + # We can also nest contexts as each instruction is + # contained in its local scheduling context. + # The output of a child context is a context-schedule + # with the internal instructions timing fixed relative to + # one another. This is schedule is then called in the parent context. + + # Context starts at t=30. + with pulse.align_left(): + # Start at t=30. + pulse.play(gaussian_pulse, d0) + # Start at t=30. + pulse.play(gaussian_pulse, d1) + # Context ends at t=40. + + # Alignment context where all pulse instructions are + # aligned to the right, ie., as late as possible. + with pulse.align_right(): + # Shift the phase of a pulse channel. + pulse.shift_phase(math.pi, d1) + # Starts at t=40. + pulse.delay(100, d0) + # Ends at t=140. + + # Starts at t=130. + pulse.play(gaussian_pulse, d1) + # Ends at t=140. + + # Acquire data for a qubit and store in a memory slot. + pulse.acquire(100, 0, pulse.MemorySlot(0)) + + # We also support a variety of macros for common operations. + + # Measure all qubits. + pulse.measure_all() + + # Delay on some qubits. + # This requires knowledge of which channels belong to which qubits. + # delay for 100 cycles on qubits 0 and 1. + pulse.delay_qubits(100, 0, 1) + + # Call a schedule for a quantum circuit thereby inserting into + # the pulse schedule. + qc = QuantumCircuit(2, 2) + qc.cx(0, 1) + qc_sched = schedule(qc, backend) + pulse.call(qc_sched) + + + # It is also be possible to call a preexisting schedule + tmp_sched = pulse.Schedule() + tmp_sched += pulse.Play(gaussian_pulse, d0) + pulse.call(tmp_sched) + + # We also support: + + # frequency instructions + pulse.set_frequency(5.0e9, d0) + + # phase instructions + pulse.shift_phase(0.1, d0) + + # offset contexts + with pulse.phase_offset(math.pi, d0): + pulse.play(gaussian_pulse, d0) + + +The above is just a small taste of what is possible with the builder. See the rest of the module +documentation for more information on its capabilities. + +.. autofunction:: build + + +Channels +======== + +Methods to return the correct channels for the respective qubit indices. + +.. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import GenericBackendV2 + + backend = GenericBackendV2(num_qubits=2, calibrate_instructions=True) + + with pulse.build(backend) as drive_sched: + d0 = pulse.drive_channel(0) + print(d0) + +.. parsed-literal:: + + DriveChannel(0) + +.. autofunction:: acquire_channel +.. autofunction:: control_channels +.. autofunction:: drive_channel +.. autofunction:: measure_channel + + +Instructions +============ + +Pulse instructions are available within the builder interface. Here's an example: + +.. plot:: + :include-source: + + from qiskit import pulse + from qiskit.providers.fake_provider import GenericBackendV2 + + backend = GenericBackendV2(num_qubits=2, calibrate_instructions=True) + + with pulse.build(backend) as drive_sched: + d0 = pulse.drive_channel(0) + a0 = pulse.acquire_channel(0) + + pulse.play(pulse.Constant(10, 1.0), d0) + pulse.delay(20, d0) + pulse.shift_phase(3.14/2, d0) + pulse.set_phase(3.14, d0) + pulse.shift_frequency(1e7, d0) + pulse.set_frequency(5e9, d0) + + with pulse.build() as temp_sched: + pulse.play(pulse.Gaussian(20, 1.0, 3.0), d0) + pulse.play(pulse.Gaussian(20, -1.0, 3.0), d0) + + pulse.call(temp_sched) + pulse.acquire(30, a0, pulse.MemorySlot(0)) + + drive_sched.draw() + +.. autofunction:: acquire +.. autofunction:: barrier +.. autofunction:: call +.. autofunction:: delay +.. autofunction:: play +.. autofunction:: reference +.. autofunction:: set_frequency +.. autofunction:: set_phase +.. autofunction:: shift_frequency +.. autofunction:: shift_phase +.. autofunction:: snapshot + + +Contexts +======== + +Builder aware contexts that modify the construction of a pulse program. For +example an alignment context like :func:`align_right` may +be used to align all pulses as late as possible in a pulse program. + +.. plot:: + :include-source: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + with pulse.build() as pulse_prog: + with pulse.align_right(): + # this pulse will start at t=0 + pulse.play(pulse.Constant(100, 1.0), d0) + # this pulse will start at t=80 + pulse.play(pulse.Constant(20, 1.0), d1) + + pulse_prog.draw() + +.. autofunction:: align_equispaced +.. autofunction:: align_func +.. autofunction:: align_left +.. autofunction:: align_right +.. autofunction:: align_sequential +.. autofunction:: frequency_offset +.. autofunction:: phase_offset + + +Macros +====== + +Macros help you add more complex functionality to your pulse program. + +.. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import GenericBackendV2 + + backend = GenericBackendV2(num_qubits=2, calibrate_instructions=True) + + with pulse.build(backend) as measure_sched: + mem_slot = pulse.measure(0) + print(mem_slot) + +.. parsed-literal:: + + MemorySlot(0) + +.. autofunction:: measure +.. autofunction:: measure_all +.. autofunction:: delay_qubits + + +Utilities +========= + +The utility functions can be used to gather attributes about the backend and modify +how the program is built. + +.. code-block:: + + from qiskit import pulse + + from qiskit.providers.fake_provider import GenericBackendV2 + + backend = GenericBackendV2(num_qubits=2, calibrate_instructions=True) + + with pulse.build(backend) as u3_sched: + print('Number of qubits in backend: {}'.format(pulse.num_qubits())) + + samples = 160 + print('There are {} samples in {} seconds'.format( + samples, pulse.samples_to_seconds(160))) + + seconds = 1e-6 + print('There are {} seconds in {} samples.'.format( + seconds, pulse.seconds_to_samples(1e-6))) + +.. parsed-literal:: + + Number of qubits in backend: 1 + There are 160 samples in 3.5555555555555554e-08 seconds + There are 1e-06 seconds in 4500 samples. + +.. autofunction:: active_backend +.. autofunction:: num_qubits +.. autofunction:: qubit_channels +.. autofunction:: samples_to_seconds +.. autofunction:: seconds_to_samples +""" +from __future__ import annotations +import contextvars +import functools +import itertools +import sys +import uuid +import warnings +from collections.abc import Generator, Callable, Iterable +from contextlib import contextmanager +from functools import singledispatchmethod +from typing import TypeVar, ContextManager, TypedDict, Union, Optional, Dict + +import numpy as np + +from qiskit.circuit.parameterexpression import ParameterExpression, ParameterValueType +from qiskit_dynamics.pulse import ( + channels as chans, + configuration, + exceptions, + instructions, + macros, + library, + transforms, +) +from qiskit.providers.backend import BackendV2 +from qiskit_dynamics.pulse.instructions import directives +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleBlock +from qiskit_dynamics.pulse.transforms.alignments import AlignmentKind + + +if sys.version_info >= (3, 12): + from typing import Unpack +else: + from typing_extensions import Unpack + +#: contextvars.ContextVar[BuilderContext]: active builder +BUILDER_CONTEXTVAR: contextvars.ContextVar["_PulseBuilder"] = contextvars.ContextVar("backend") + +T = TypeVar("T") + +StorageLocation = Union[chans.MemorySlot, chans.RegisterSlot] + + +def _requires_backend(function: Callable[..., T]) -> Callable[..., T]: + """Decorator a function to raise if it is called without a builder with a + set backend. + """ + + @functools.wraps(function) + def wrapper(self, *args, **kwargs): + if self.backend is None: + raise exceptions.BackendNotSet( + 'This function requires the builder to have a "backend" set.' + ) + return function(self, *args, **kwargs) + + return wrapper + + +class _PulseBuilder: + """Builder context class.""" + + __alignment_kinds__ = { + "left": transforms.AlignLeft(), + "right": transforms.AlignRight(), + "sequential": transforms.AlignSequential(), + } + + def __init__( + self, + backend=None, + block: ScheduleBlock | None = None, + name: str | None = None, + default_alignment: str | AlignmentKind = "left", + ): + """Initialize the builder context. + + .. note:: + At some point we may consider incorporating the builder into + the :class:`~qiskit.pulse.Schedule` class. However, the risk of + this is tying the user interface to the intermediate + representation. For now we avoid this at the cost of some code + duplication. + + Args: + backend (Backend): Input backend to use in + builder. If not set certain functionality will be unavailable. + block: Initital ``ScheduleBlock`` to build on. + name: Name of pulse program to be built. + default_alignment: Default scheduling alignment for builder. + One of ``left``, ``right``, ``sequential`` or an instance of + :class:`~qiskit.pulse.transforms.alignments.AlignmentKind` subclass. + + Raises: + PulseError: When invalid ``default_alignment`` or `block` is specified. + """ + #: Backend: Backend instance for context builder. + self._backend = backend + + # Token for this ``_PulseBuilder``'s ``ContextVar``. + self._backend_ctx_token: contextvars.Token[_PulseBuilder] | None = None + + # Stack of context. + self._context_stack: list[ScheduleBlock] = [] + + #: str: Name of the output program + self._name = name + + # Add root block if provided. Schedule will be built on top of this. + if block is not None: + if isinstance(block, ScheduleBlock): + root_block = block + elif isinstance(block, Schedule): + root_block = self._naive_typecast_schedule(block) + else: + raise exceptions.PulseError( + f"Input `block` type {block.__class__.__name__} is " + "not a valid format. Specify a pulse program." + ) + self._context_stack.append(root_block) + + # Set default alignment context + if isinstance(default_alignment, AlignmentKind): # AlignmentKind instance + alignment = default_alignment + else: # str identifier + alignment = _PulseBuilder.__alignment_kinds__.get(default_alignment, default_alignment) + if not isinstance(alignment, AlignmentKind): + raise exceptions.PulseError( + f"Given `default_alignment` {repr(default_alignment)} is " + "not a valid transformation. Set one of " + f'{", ".join(_PulseBuilder.__alignment_kinds__.keys())}, ' + "or set an instance of `AlignmentKind` subclass." + ) + self.push_context(alignment) + + def __enter__(self) -> ScheduleBlock: + """Enter this builder context and yield either the supplied schedule + or the schedule created for the user. + + Returns: + The schedule that the builder will build on. + """ + self._backend_ctx_token = BUILDER_CONTEXTVAR.set(self) + output = self._context_stack[0] + output._name = self._name or output.name + + return output + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit the builder context and compile the built pulse program.""" + self.compile() + BUILDER_CONTEXTVAR.reset(self._backend_ctx_token) + + @property + def backend(self): + """Returns the builder backend if set. + + Returns: + Optional[Backend]: The builder's backend. + """ + return self._backend + + def push_context(self, alignment: AlignmentKind): + """Push new context to the stack.""" + self._context_stack.append(ScheduleBlock(alignment_context=alignment)) + + def pop_context(self) -> ScheduleBlock: + """Pop the last context from the stack.""" + if len(self._context_stack) == 1: + raise exceptions.PulseError("The root context cannot be popped out.") + + return self._context_stack.pop() + + def get_context(self) -> ScheduleBlock: + """Get current context. + + Notes: + New instruction can be added by `.append_subroutine` or `.append_instruction` method. + Use above methods rather than directly accessing to the current context. + """ + return self._context_stack[-1] + + @property + @_requires_backend + def num_qubits(self): + """Get the number of qubits in the backend.""" + # backendV2 + if isinstance(self.backend, BackendV2): + return self.backend.num_qubits + return self.backend.configuration().n_qubits + + def compile(self) -> ScheduleBlock: + """Compile and output the built pulse program.""" + # Not much happens because we currently compile as we build. + # This should be offloaded to a true compilation module + # once we define a more sophisticated IR. + + while len(self._context_stack) > 1: + current = self.pop_context() + self.append_subroutine(current) + + return self._context_stack[0] + + def append_instruction(self, instruction: instructions.Instruction): + """Add an instruction to the builder's context schedule. + + Args: + instruction: Instruction to append. + """ + self._context_stack[-1].append(instruction) + + def append_reference(self, name: str, *extra_keys: str): + """Add external program as a :class:`~qiskit.pulse.instructions.Reference` instruction. + + Args: + name: Name of subroutine. + extra_keys: Assistance keys to uniquely specify the subroutine. + """ + inst = instructions.Reference(name, *extra_keys) + self.append_instruction(inst) + + def append_subroutine(self, subroutine: Schedule | ScheduleBlock): + """Append a :class:`ScheduleBlock` to the builder's context schedule. + + This operation doesn't create a reference. Subroutine is directly + appended to current context schedule. + + Args: + subroutine: ScheduleBlock to append to the current context block. + + Raises: + PulseError: When subroutine is not Schedule nor ScheduleBlock. + """ + if not isinstance(subroutine, (ScheduleBlock, Schedule)): + raise exceptions.PulseError( + f"'{subroutine.__class__.__name__}' is not valid data format in the builder. " + "'Schedule' and 'ScheduleBlock' can be appended to the builder context." + ) + + if len(subroutine) == 0: + return + if isinstance(subroutine, Schedule): + subroutine = self._naive_typecast_schedule(subroutine) + self._context_stack[-1].append(subroutine) + + @singledispatchmethod + def call_subroutine( + self, + subroutine: Schedule | ScheduleBlock, + name: str | None = None, + value_dict: dict[ParameterExpression, ParameterValueType] | None = None, + **kw_params: ParameterValueType, + ): + """Call a schedule or circuit defined outside of the current scope. + + The ``subroutine`` is appended to the context schedule as a call instruction. + This logic just generates a convenient program representation in the compiler. + Thus, this doesn't affect execution of inline subroutines. + See :class:`~pulse.instructions.Call` for more details. + + Args: + subroutine: Target schedule or circuit to append to the current context. + name: Name of subroutine if defined. + value_dict: Parameter object and assigned value mapping. This is more precise way to + identify a parameter since mapping is managed with unique object id rather than + name. Especially there is any name collision in a parameter table. + kw_params: Parameter values to bind to the target subroutine + with string parameter names. If there are parameter name overlapping, + these parameters are updated with the same assigned value. + + Raises: + PulseError: + - When input subroutine is not valid data format. + """ + raise exceptions.PulseError( + f"Subroutine type {subroutine.__class__.__name__} is " + "not valid data format. Call " + "Schedule, or ScheduleBlock." + ) + + @call_subroutine.register + def _( + self, + target_block: ScheduleBlock, + name: Optional[str] = None, + value_dict: Optional[Dict[ParameterExpression, ParameterValueType]] = None, + **kw_params: ParameterValueType, + ): + if len(target_block) == 0: + return + + # Create local parameter assignment + local_assignment = {} + for param_name, value in kw_params.items(): + params = target_block.get_parameters(param_name) + if not params: + raise exceptions.PulseError( + f"Parameter {param_name} is not defined in the target subroutine. " + f'{", ".join(map(str, target_block.parameters))} can be specified.' + ) + for param in params: + local_assignment[param] = value + + if value_dict: + if local_assignment.keys() & value_dict.keys(): + warnings.warn( + "Some parameters provided by 'value_dict' conflict with one through " + "keyword arguments. Parameter values in the keyword arguments " + "are overridden by the dictionary values.", + UserWarning, + ) + local_assignment.update(value_dict) + + if local_assignment: + target_block = target_block.assign_parameters(local_assignment, inplace=False) + + if name is None: + # Add unique string, not to accidentally override existing reference entry. + keys: tuple[str, ...] = (target_block.name, uuid.uuid4().hex) + else: + keys = (name,) + + self.append_reference(*keys) + self.get_context().assign_references({keys: target_block}, inplace=True) + + @call_subroutine.register + def _( + self, + target_schedule: Schedule, + name: Optional[str] = None, + value_dict: Optional[Dict[ParameterExpression, ParameterValueType]] = None, + **kw_params: ParameterValueType, + ): + if len(target_schedule) == 0: + return + + self.call_subroutine( + self._naive_typecast_schedule(target_schedule), + name=name, + value_dict=value_dict, + **kw_params, + ) + + @staticmethod + def _naive_typecast_schedule(schedule: Schedule): + # Naively convert into ScheduleBlock + from qiskit.pulse.transforms import inline_subroutines, flatten, pad + + preprocessed_schedule = inline_subroutines(flatten(schedule)) + pad(preprocessed_schedule, inplace=True, pad_with=instructions.TimeBlockade) + + # default to left alignment, namely ASAP scheduling + target_block = ScheduleBlock(name=schedule.name) + for _, inst in preprocessed_schedule.instructions: + target_block.append(inst, inplace=True) + + return target_block + + def get_dt(self): + """Retrieve dt differently based on the type of Backend""" + if isinstance(self.backend, BackendV2): + return self.backend.dt + return self.backend.configuration().dt + + +def build( + backend=None, + schedule: ScheduleBlock | None = None, + name: str | None = None, + default_alignment: str | AlignmentKind | None = "left", +) -> ContextManager[ScheduleBlock]: + """Create a context manager for launching the imperative pulse builder DSL. + + To enter a building context and starting building a pulse program: + + .. code-block:: + + from qiskit import transpile, pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.play(pulse.Constant(100, 0.5), d0) + + + While the output program ``pulse_prog`` cannot be executed as we are using + a mock backend. If a real backend is being used, executing the program is + done with: + + .. code-block:: python + + backend.run(transpile(pulse_prog, backend)) + + Args: + backend (Backend): A Qiskit backend. If not supplied certain + builder functionality will be unavailable. + schedule: A pulse ``ScheduleBlock`` in which your pulse program will be built. + name: Name of pulse program to be built. + default_alignment: Default scheduling alignment for builder. + One of ``left``, ``right``, ``sequential`` or an alignment context. + + Returns: + A new builder context which has the active builder initialized. + """ + return _PulseBuilder( + backend=backend, + block=schedule, + name=name, + default_alignment=default_alignment, + ) + + +# Builder Utilities + + +def _active_builder() -> _PulseBuilder: + """Get the active builder in the active context. + + Returns: + The active active builder in this context. + + Raises: + exceptions.NoActiveBuilder: If a pulse builder function is called + outside of a builder context. + """ + try: + return BUILDER_CONTEXTVAR.get() + except LookupError as ex: + raise exceptions.NoActiveBuilder( + "A Pulse builder function was called outside of " + "a builder context. Try calling within a builder " + 'context, eg., "with pulse.build() as schedule: ...".' + ) from ex + + +def active_backend(): + """Get the backend of the currently active builder context. + + Returns: + Backend: The active backend in the currently active + builder context. + + Raises: + exceptions.BackendNotSet: If the builder does not have a backend set. + """ + builder = _active_builder().backend + if builder is None: + raise exceptions.BackendNotSet( + 'This function requires the active builder to have a "backend" set.' + ) + return builder + + +def append_schedule(schedule: Schedule | ScheduleBlock): + """Call a schedule by appending to the active builder's context block. + + Args: + schedule: Schedule or ScheduleBlock to append. + """ + _active_builder().append_subroutine(schedule) + + +def append_instruction(instruction: instructions.Instruction): + """Append an instruction to the active builder's context schedule. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.builder.append_instruction(pulse.Delay(10, d0)) + + print(pulse_prog.instructions) + + .. parsed-literal:: + + ((0, Delay(10, DriveChannel(0))),) + """ + _active_builder().append_instruction(instruction) + + +def num_qubits() -> int: + """Return number of qubits in the currently active backend. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + with pulse.build(backend): + print(pulse.num_qubits()) + + .. parsed-literal:: + + 2 + + .. note:: Requires the active builder context to have a backend set. + """ + if isinstance(active_backend(), BackendV2): + return active_backend().num_qubits + return active_backend().configuration().n_qubits + + +def seconds_to_samples(seconds: float | np.ndarray) -> int | np.ndarray: + """Obtain the number of samples that will elapse in ``seconds`` on the + active backend. + + Rounds down. + + Args: + seconds: Time in seconds to convert to samples. + + Returns: + The number of samples for the time to elapse + """ + dt = _active_builder().get_dt() + if isinstance(seconds, np.ndarray): + return (seconds / dt).astype(int) + return int(seconds / dt) + + +def samples_to_seconds(samples: int | np.ndarray) -> float | np.ndarray: + """Obtain the time in seconds that will elapse for the input number of + samples on the active backend. + + Args: + samples: Number of samples to convert to time in seconds. + + Returns: + The time that elapses in ``samples``. + """ + return samples * _active_builder().get_dt() + + +def qubit_channels(qubit: int) -> set[chans.Channel]: + """Returns the set of channels associated with a qubit. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + with pulse.build(backend): + print(pulse.qubit_channels(0)) + + .. parsed-literal:: + + {MeasureChannel(0), ControlChannel(0), DriveChannel(0), AcquireChannel(0), ControlChannel(1)} + + .. note:: Requires the active builder context to have a backend set. + + .. note:: A channel may still be associated with another qubit in this list + such as in the case where significant crosstalk exists. + + """ + + # implement as the inner function to avoid API change for a patch release in 0.24.2. + def get_qubit_channels_v2(backend: BackendV2, qubit: int): + r"""Return a list of channels which operate on the given ``qubit``. + Returns: + List of ``Channel``\s operated on my the given ``qubit``. + """ + channels = [] + + # add multi-qubit channels + for node_qubits in backend.coupling_map: + if qubit in node_qubits: + control_channel = backend.control_channel(node_qubits) + if control_channel: + channels.extend(control_channel) + + # add single qubit channels + channels.append(backend.drive_channel(qubit)) + channels.append(backend.measure_channel(qubit)) + channels.append(backend.acquire_channel(qubit)) + return channels + + # backendV2 + if isinstance(active_backend(), BackendV2): + return set(get_qubit_channels_v2(active_backend(), qubit)) + return set(active_backend().configuration().get_qubit_channels(qubit)) + + +def _qubits_to_channels(*channels_or_qubits: int | chans.Channel) -> set[chans.Channel]: + """Returns the unique channels of the input qubits.""" + channels = set() + for channel_or_qubit in channels_or_qubits: + if isinstance(channel_or_qubit, int): + channels |= qubit_channels(channel_or_qubit) + elif isinstance(channel_or_qubit, chans.Channel): + channels.add(channel_or_qubit) + else: + raise exceptions.PulseError( + f'{channel_or_qubit} is not a "Channel" or qubit (integer).' + ) + return channels + + +# Contexts + + +@contextmanager +def align_left() -> Generator[None, None, None]: + """Left alignment pulse scheduling context. + + Pulse instructions within this context are scheduled as early as possible + by shifting them left to the earliest available time. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + with pulse.build() as pulse_prog: + with pulse.align_left(): + # this pulse will start at t=0 + pulse.play(pulse.Constant(100, 1.0), d0) + # this pulse will start at t=0 + pulse.play(pulse.Constant(20, 1.0), d1) + pulse_prog = pulse.transforms.block_to_schedule(pulse_prog) + + assert pulse_prog.ch_start_time(d0) == pulse_prog.ch_start_time(d1) + + Yields: + None + """ + builder = _active_builder() + builder.push_context(transforms.AlignLeft()) + try: + yield + finally: + current = builder.pop_context() + builder.append_subroutine(current) + + +@contextmanager +def align_right() -> Generator[None, None, None]: + """Right alignment pulse scheduling context. + + Pulse instructions within this context are scheduled as late as possible + by shifting them right to the latest available time. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + with pulse.build() as pulse_prog: + with pulse.align_right(): + # this pulse will start at t=0 + pulse.play(pulse.Constant(100, 1.0), d0) + # this pulse will start at t=80 + pulse.play(pulse.Constant(20, 1.0), d1) + pulse_prog = pulse.transforms.block_to_schedule(pulse_prog) + + assert pulse_prog.ch_stop_time(d0) == pulse_prog.ch_stop_time(d1) + + Yields: + None + """ + builder = _active_builder() + builder.push_context(transforms.AlignRight()) + try: + yield + finally: + current = builder.pop_context() + builder.append_subroutine(current) + + +@contextmanager +def align_sequential() -> Generator[None, None, None]: + """Sequential alignment pulse scheduling context. + + Pulse instructions within this context are scheduled sequentially in time + such that no two instructions will be played at the same time. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + with pulse.build() as pulse_prog: + with pulse.align_sequential(): + # this pulse will start at t=0 + pulse.play(pulse.Constant(100, 1.0), d0) + # this pulse will also start at t=100 + pulse.play(pulse.Constant(20, 1.0), d1) + pulse_prog = pulse.transforms.block_to_schedule(pulse_prog) + + assert pulse_prog.ch_stop_time(d0) == pulse_prog.ch_start_time(d1) + + Yields: + None + """ + builder = _active_builder() + builder.push_context(transforms.AlignSequential()) + try: + yield + finally: + current = builder.pop_context() + builder.append_subroutine(current) + + +@contextmanager +def align_equispaced(duration: int | ParameterExpression) -> Generator[None, None, None]: + """Equispaced alignment pulse scheduling context. + + Pulse instructions within this context are scheduled with the same interval spacing such that + the total length of the context block is ``duration``. + If the total free ``duration`` cannot be evenly divided by the number of instructions + within the context, the modulo is split and then prepended and appended to + the returned schedule. Delay instructions are automatically inserted in between pulses. + + This context is convenient to write a schedule for periodical dynamic decoupling or + the Hahn echo sequence. + + Examples: + + .. plot:: + :include-source: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + x90 = pulse.Gaussian(10, 0.1, 3) + x180 = pulse.Gaussian(10, 0.2, 3) + + with pulse.build() as hahn_echo: + with pulse.align_equispaced(duration=100): + pulse.play(x90, d0) + pulse.play(x180, d0) + pulse.play(x90, d0) + + hahn_echo.draw() + + Args: + duration: Duration of this context. This should be larger than the schedule duration. + + Yields: + None + + Notes: + The scheduling is performed for sub-schedules within the context rather than + channel-wise. If you want to apply the equispaced context for each channel, + you should use the context independently for channels. + """ + builder = _active_builder() + builder.push_context(transforms.AlignEquispaced(duration=duration)) + try: + yield + finally: + current = builder.pop_context() + builder.append_subroutine(current) + + +@contextmanager +def align_func( + duration: int | ParameterExpression, func: Callable[[int], float] +) -> Generator[None, None, None]: + """Callback defined alignment pulse scheduling context. + + Pulse instructions within this context are scheduled at the location specified by + arbitrary callback function `position` that takes integer index and returns + the associated fractional location within [0, 1]. + Delay instruction is automatically inserted in between pulses. + + This context may be convenient to write a schedule of arbitrary dynamical decoupling + sequences such as Uhrig dynamical decoupling. + + Examples: + + .. plot:: + :include-source: + + import numpy as np + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + x90 = pulse.Gaussian(10, 0.1, 3) + x180 = pulse.Gaussian(10, 0.2, 3) + + def udd10_pos(j): + return np.sin(np.pi*j/(2*10 + 2))**2 + + with pulse.build() as udd_sched: + pulse.play(x90, d0) + with pulse.align_func(duration=300, func=udd10_pos): + for _ in range(10): + pulse.play(x180, d0) + pulse.play(x90, d0) + + udd_sched.draw() + + Args: + duration: Duration of context. This should be larger than the schedule duration. + func: A function that takes an index of sub-schedule and returns the + fractional coordinate of of that sub-schedule. + The returned value should be defined within [0, 1]. + The pulse index starts from 1. + + Yields: + None + + Notes: + The scheduling is performed for sub-schedules within the context rather than + channel-wise. If you want to apply the numerical context for each channel, + you need to apply the context independently to channels. + """ + builder = _active_builder() + builder.push_context(transforms.AlignFunc(duration=duration, func=func)) + try: + yield + finally: + current = builder.pop_context() + builder.append_subroutine(current) + + +@contextmanager +def general_transforms(alignment_context: AlignmentKind) -> Generator[None, None, None]: + """Arbitrary alignment transformation defined by a subclass instance of + :class:`~qiskit.pulse.transforms.alignments.AlignmentKind`. + + Args: + alignment_context: Alignment context instance that defines schedule transformation. + + Yields: + None + + Raises: + PulseError: When input ``alignment_context`` is not ``AlignmentKind`` subclasses. + """ + if not isinstance(alignment_context, AlignmentKind): + raise exceptions.PulseError("Input alignment context is not `AlignmentKind` subclass.") + + builder = _active_builder() + builder.push_context(alignment_context) + try: + yield + finally: + current = builder.pop_context() + builder.append_subroutine(current) + + +@contextmanager +def phase_offset(phase: float, *channels: chans.PulseChannel) -> Generator[None, None, None]: + """Shift the phase of input channels on entry into context and undo on exit. + + Examples: + + .. code-block:: + + import math + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + with pulse.phase_offset(math.pi, d0): + pulse.play(pulse.Constant(10, 1.0), d0) + + assert len(pulse_prog.instructions) == 3 + + Args: + phase: Amount of phase offset in radians. + channels: Channels to offset phase of. + + Yields: + None + """ + for channel in channels: + shift_phase(phase, channel) + try: + yield + finally: + for channel in channels: + shift_phase(-phase, channel) + + +@contextmanager +def frequency_offset( + frequency: float, *channels: chans.PulseChannel, compensate_phase: bool = False +) -> Generator[None, None, None]: + """Shift the frequency of inputs channels on entry into context and undo on exit. + + Examples: + + .. code-block:: python + :emphasize-lines: 7, 16 + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + d0 = pulse.DriveChannel(0) + + with pulse.build(backend) as pulse_prog: + # shift frequency by 1GHz + with pulse.frequency_offset(1e9, d0): + pulse.play(pulse.Constant(10, 1.0), d0) + + assert len(pulse_prog.instructions) == 3 + + with pulse.build(backend) as pulse_prog: + # Shift frequency by 1GHz. + # Undo accumulated phase in the shifted frequency frame + # when exiting the context. + with pulse.frequency_offset(1e9, d0, compensate_phase=True): + pulse.play(pulse.Constant(10, 1.0), d0) + + assert len(pulse_prog.instructions) == 4 + + Args: + frequency: Amount of frequency offset in Hz. + channels: Channels to offset frequency of. + compensate_phase: Compensate for accumulated phase accumulated with + respect to the channels' frame at its initial frequency. + + Yields: + None + """ + builder = _active_builder() + # TODO: Need proper implementation of compensation. t0 may depend on the parent context. + # For example, the instruction position within the equispaced context depends on + # the current total number of instructions, thus adding more instruction after + # offset context may change the t0 when the parent context is transformed. + t0 = builder.get_context().duration + + for channel in channels: + shift_frequency(frequency, channel) + try: + yield + finally: + if compensate_phase: + duration = builder.get_context().duration - t0 + + accumulated_phase = 2 * np.pi * ((duration * builder.get_dt() * frequency) % 1) + for channel in channels: + shift_phase(-accumulated_phase, channel) + + for channel in channels: + shift_frequency(-frequency, channel) + + +# Channels +def drive_channel(qubit: int) -> chans.DriveChannel: + """Return ``DriveChannel`` for ``qubit`` on the active builder backend. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + with pulse.build(backend): + assert pulse.drive_channel(0) == pulse.DriveChannel(0) + + .. note:: Requires the active builder context to have a backend set. + """ + # backendV2 + if isinstance(active_backend(), BackendV2): + return active_backend().drive_channel(qubit) + return active_backend().configuration().drive(qubit) + + +def measure_channel(qubit: int) -> chans.MeasureChannel: + """Return ``MeasureChannel`` for ``qubit`` on the active builder backend. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + with pulse.build(backend): + assert pulse.measure_channel(0) == pulse.MeasureChannel(0) + + .. note:: Requires the active builder context to have a backend set. + """ + # backendV2 + if isinstance(active_backend(), BackendV2): + return active_backend().measure_channel(qubit) + return active_backend().configuration().measure(qubit) + + +def acquire_channel(qubit: int) -> chans.AcquireChannel: + """Return ``AcquireChannel`` for ``qubit`` on the active builder backend. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + with pulse.build(backend): + assert pulse.acquire_channel(0) == pulse.AcquireChannel(0) + + .. note:: Requires the active builder context to have a backend set. + """ + # backendV2 + if isinstance(active_backend(), BackendV2): + return active_backend().acquire_channel(qubit) + return active_backend().configuration().acquire(qubit) + + +def control_channels(*qubits: Iterable[int]) -> list[chans.ControlChannel]: + """Return ``ControlChannel`` for ``qubit`` on the active builder backend. + + Return the secondary drive channel for the given qubit -- typically + utilized for controlling multi-qubit interactions. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + with pulse.build(backend): + assert pulse.control_channels(0, 1) == [pulse.ControlChannel(0)] + + .. note:: Requires the active builder context to have a backend set. + + Args: + qubits: Tuple or list of ordered qubits of the form + `(control_qubit, target_qubit)`. + + Returns: + List of control channels associated with the supplied ordered list + of qubits. + """ + # backendV2 + if isinstance(active_backend(), BackendV2): + return active_backend().control_channel(qubits) + return active_backend().configuration().control(qubits=qubits) + + +# Base Instructions +def delay(duration: int, channel: chans.Channel, name: str | None = None): + """Delay on a ``channel`` for a ``duration``. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.delay(10, d0) + + Args: + duration: Number of cycles to delay for on ``channel``. + channel: Channel to delay on. + name: Name of the instruction. + """ + append_instruction(instructions.Delay(duration, channel, name=name)) + + +def play(pulse: library.Pulse | np.ndarray, channel: chans.PulseChannel, name: str | None = None): + """Play a ``pulse`` on a ``channel``. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.play(pulse.Constant(10, 1.0), d0) + + Args: + pulse: Pulse to play. + channel: Channel to play pulse on. + name: Name of the pulse. + """ + if not isinstance(pulse, library.Pulse): + pulse = library.Waveform(pulse) + + append_instruction(instructions.Play(pulse, channel, name=name)) + + +class _MetaDataType(TypedDict, total=False): + kernel: configuration.Kernel + discriminator: configuration.Discriminator + mem_slot: chans.MemorySlot + reg_slot: chans.RegisterSlot + name: str + + +def acquire( + duration: int, + qubit_or_channel: int | chans.AcquireChannel, + register: StorageLocation, + **metadata: Unpack[_MetaDataType], +): + """Acquire for a ``duration`` on a ``channel`` and store the result + in a ``register``. + + Examples: + + .. code-block:: + + from qiskit import pulse + + acq0 = pulse.AcquireChannel(0) + mem0 = pulse.MemorySlot(0) + + with pulse.build() as pulse_prog: + pulse.acquire(100, acq0, mem0) + + # measurement metadata + kernel = pulse.configuration.Kernel('linear_discriminator') + pulse.acquire(100, acq0, mem0, kernel=kernel) + + .. note:: The type of data acquire will depend on the execution ``meas_level``. + + Args: + duration: Duration to acquire data for + qubit_or_channel: Either the qubit to acquire data for or the specific + :class:`~qiskit.pulse.channels.AcquireChannel` to acquire on. + register: Location to store measured result. + metadata: Additional metadata for measurement. See + :class:`~qiskit.pulse.instructions.Acquire` for more information. + + Raises: + exceptions.PulseError: If the register type is not supported. + """ + if isinstance(qubit_or_channel, int): + qubit_or_channel = chans.AcquireChannel(qubit_or_channel) + + if isinstance(register, chans.MemorySlot): + append_instruction( + instructions.Acquire(duration, qubit_or_channel, mem_slot=register, **metadata) + ) + elif isinstance(register, chans.RegisterSlot): + append_instruction( + instructions.Acquire(duration, qubit_or_channel, reg_slot=register, **metadata) + ) + else: + raise exceptions.PulseError(f'Register of type: "{type(register)}" is not supported') + + +def set_frequency(frequency: float, channel: chans.PulseChannel, name: str | None = None): + """Set the ``frequency`` of a pulse ``channel``. + + Examples: + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.set_frequency(1e9, d0) + + Args: + frequency: Frequency in Hz to set channel to. + channel: Channel to set frequency of. + name: Name of the instruction. + """ + append_instruction(instructions.SetFrequency(frequency, channel, name=name)) + + +def shift_frequency(frequency: float, channel: chans.PulseChannel, name: str | None = None): + """Shift the ``frequency`` of a pulse ``channel``. + + Examples: + + .. code-block:: python + :emphasize-lines: 6 + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.shift_frequency(1e9, d0) + + Args: + frequency: Frequency in Hz to shift channel frequency by. + channel: Channel to shift frequency of. + name: Name of the instruction. + """ + append_instruction(instructions.ShiftFrequency(frequency, channel, name=name)) + + +def set_phase(phase: float, channel: chans.PulseChannel, name: str | None = None): + """Set the ``phase`` of a pulse ``channel``. + + Examples: + + .. code-block:: python + :emphasize-lines: 8 + + import math + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.set_phase(math.pi, d0) + + Args: + phase: Phase in radians to set channel carrier signal to. + channel: Channel to set phase of. + name: Name of the instruction. + """ + append_instruction(instructions.SetPhase(phase, channel, name=name)) + + +def shift_phase(phase: float, channel: chans.PulseChannel, name: str | None = None): + """Shift the ``phase`` of a pulse ``channel``. + + Examples: + + .. code-block:: + + import math + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + with pulse.build() as pulse_prog: + pulse.shift_phase(math.pi, d0) + + Args: + phase: Phase in radians to shift channel carrier signal by. + channel: Channel to shift phase of. + name: Name of the instruction. + """ + append_instruction(instructions.ShiftPhase(phase, channel, name)) + + +def snapshot(label: str, snapshot_type: str = "statevector"): + """Simulator snapshot. + + Examples: + + .. code-block:: + + from qiskit import pulse + + with pulse.build() as pulse_prog: + pulse.snapshot('first', 'statevector') + + Args: + label: Label for snapshot. + snapshot_type: Type of snapshot. + """ + append_instruction(instructions.Snapshot(label, snapshot_type=snapshot_type)) + + +def call( + target: Schedule | ScheduleBlock | None, + name: str | None = None, + value_dict: dict[ParameterValueType, ParameterValueType] | None = None, + **kw_params: ParameterValueType, +): + """Call the subroutine within the currently active builder context with arbitrary + parameters which will be assigned to the target program. + + .. note:: + + If the ``target`` program is a :class:`.ScheduleBlock`, then a :class:`.Reference` + instruction will be created and appended to the current context. + The ``target`` program will be immediately assigned to the current scope as a subroutine. + If the ``target`` program is :class:`.Schedule`, it will be wrapped by the + :class:`.Call` instruction and appended to the current context to avoid + a mixed representation of :class:`.ScheduleBlock` and :class:`.Schedule`. + If the ``target`` program is a :class:`.QuantumCircuit` it will be scheduled + and the new :class:`.Schedule` will be added as a :class:`.Call` instruction. + + Examples: + + 1. Calling a schedule block (recommended) + + .. code-block:: + + from qiskit import circuit, pulse + from qiskit.providers.fake_provider import GenericBackendV2 + + backend = GenericBackendV2(num_qubits=5, calibrate_instructions=True) + + with pulse.build() as x_sched: + pulse.play(pulse.Gaussian(160, 0.1, 40), pulse.DriveChannel(0)) + + with pulse.build() as pulse_prog: + pulse.call(x_sched) + + print(pulse_prog) + + .. parsed-literal:: + + ScheduleBlock( + ScheduleBlock( + Play( + Gaussian(duration=160, amp=(0.1+0j), sigma=40), + DriveChannel(0) + ), + name="block0", + transform=AlignLeft() + ), + name="block1", + transform=AlignLeft() + ) + + The actual program is stored in the reference table attached to the schedule. + + .. code-block:: + + print(pulse_prog.references) + + .. parsed-literal:: + + ReferenceManager: + - ('block0', '634b3b50bd684e26a673af1fbd2d6c81'): ScheduleBlock(Play(Gaussian(... + + In addition, you can call a parameterized target program with parameter assignment. + + .. code-block:: + + amp = circuit.Parameter("amp") + + with pulse.build() as subroutine: + pulse.play(pulse.Gaussian(160, amp, 40), pulse.DriveChannel(0)) + + with pulse.build() as pulse_prog: + pulse.call(subroutine, amp=0.1) + pulse.call(subroutine, amp=0.3) + + print(pulse_prog) + + .. parsed-literal:: + + ScheduleBlock( + ScheduleBlock( + Play( + Gaussian(duration=160, amp=(0.1+0j), sigma=40), + DriveChannel(0) + ), + name="block2", + transform=AlignLeft() + ), + ScheduleBlock( + Play( + Gaussian(duration=160, amp=(0.3+0j), sigma=40), + DriveChannel(0) + ), + name="block2", + transform=AlignLeft() + ), + name="block3", + transform=AlignLeft() + ) + + If there is a name collision between parameters, you can distinguish them by specifying + each parameter object in a python dictionary. For example, + + .. code-block:: + + amp1 = circuit.Parameter('amp') + amp2 = circuit.Parameter('amp') + + with pulse.build() as subroutine: + pulse.play(pulse.Gaussian(160, amp1, 40), pulse.DriveChannel(0)) + pulse.play(pulse.Gaussian(160, amp2, 40), pulse.DriveChannel(1)) + + with pulse.build() as pulse_prog: + pulse.call(subroutine, value_dict={amp1: 0.1, amp2: 0.3}) + + print(pulse_prog) + + .. parsed-literal:: + + ScheduleBlock( + ScheduleBlock( + Play(Gaussian(duration=160, amp=(0.1+0j), sigma=40), DriveChannel(0)), + Play(Gaussian(duration=160, amp=(0.3+0j), sigma=40), DriveChannel(1)), + name="block4", + transform=AlignLeft() + ), + name="block5", + transform=AlignLeft() + ) + + 2. Calling a schedule + + .. code-block:: + + x_sched = backend.instruction_schedule_map.get("x", (0,)) + + with pulse.build(backend) as pulse_prog: + pulse.call(x_sched) + + print(pulse_prog) + + .. parsed-literal:: + + ScheduleBlock( + Call( + Schedule( + ( + 0, + Play( + Drag( + duration=160, + amp=(0.18989731546729305+0j), + sigma=40, + beta=-1.201258305015517, + name='drag_86a8' + ), + DriveChannel(0), + name='drag_86a8' + ) + ), + name="x" + ), + name='x' + ), + name="block6", + transform=AlignLeft() + ) + + Currently, the backend calibrated gates are provided in the form of :class:`~.Schedule`. + The parameter assignment mechanism is available also for schedules. + However, the called schedule is not treated as a reference. + + + Args: + target: Target circuit or pulse schedule to call. + name: Optional. A unique name of subroutine if defined. When the name is explicitly + provided, one cannot call different schedule blocks with the same name. + value_dict: Optional. Parameters assigned to the ``target`` program. + If this dictionary is provided, the ``target`` program is copied and + then stored in the main built schedule and its parameters are assigned to the given values. + This dictionary is keyed on :class:`~.Parameter` objects, + allowing parameter name collision to be avoided. + kw_params: Alternative way to provide parameters. + Since this is keyed on the string parameter name, + the parameters having the same name are all updated together. + If you want to avoid name collision, use ``value_dict`` with :class:`~.Parameter` + objects instead. + """ + _active_builder().call_subroutine(target, name, value_dict, **kw_params) + + +def reference(name: str, *extra_keys: str): + """Refer to undefined subroutine by string keys. + + A :class:`~qiskit.pulse.instructions.Reference` instruction is implicitly created + and a schedule can be separately registered to the reference at a later stage. + + .. code-block:: python + + from qiskit import pulse + + with pulse.build() as main_prog: + pulse.reference("x_gate", "q0") + + with pulse.build() as subroutine: + pulse.play(pulse.Gaussian(160, 0.1, 40), pulse.DriveChannel(0)) + + main_prog.assign_references(subroutine_dict={("x_gate", "q0"): subroutine}) + + Args: + name: Name of subroutine. + extra_keys: Helper keys to uniquely specify the subroutine. + """ + _active_builder().append_reference(name, *extra_keys) + + +# Directives +def barrier(*channels_or_qubits: chans.Channel | int, name: str | None = None): + """Barrier directive for a set of channels and qubits. + + This directive prevents the compiler from moving instructions across + the barrier. Consider the case where we want to enforce that one pulse + happens after another on separate channels, this can be done with: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + with pulse.build(backend) as barrier_pulse_prog: + pulse.play(pulse.Constant(10, 1.0), d0) + pulse.barrier(d0, d1) + pulse.play(pulse.Constant(10, 1.0), d1) + + Of course this could have been accomplished with: + + .. code-block:: + + from qiskit.pulse import transforms + + with pulse.build(backend) as aligned_pulse_prog: + with pulse.align_sequential(): + pulse.play(pulse.Constant(10, 1.0), d0) + pulse.play(pulse.Constant(10, 1.0), d1) + + barrier_pulse_prog = transforms.target_qobj_transform(barrier_pulse_prog) + aligned_pulse_prog = transforms.target_qobj_transform(aligned_pulse_prog) + + assert barrier_pulse_prog == aligned_pulse_prog + + The barrier allows the pulse compiler to take care of more advanced + scheduling alignment operations across channels. For example + in the case where we are calling an outside circuit or schedule and + want to align a pulse at the end of one call: + + .. code-block:: + + import math + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + d0 = pulse.DriveChannel(0) + + with pulse.build(backend) as pulse_prog: + with pulse.align_right(): + pulse.call(backend.defaults().instruction_schedule_map.get('u1', (1,))) + # Barrier qubit 1 and d0. + pulse.barrier(1, d0) + # Due to barrier this will play before the gate on qubit 1. + pulse.play(pulse.Constant(10, 1.0), d0) + # This will end at the same time as the pulse above due to + # the barrier. + pulse.call(backend.defaults().instruction_schedule_map.get('u1', (1,))) + + .. note:: Requires the active builder context to have a backend set if + qubits are barriered on. + + Args: + channels_or_qubits: Channels or qubits to barrier. + name: Name for the barrier + """ + channels = _qubits_to_channels(*channels_or_qubits) + if len(channels) > 1: + append_instruction(directives.RelativeBarrier(*channels, name=name)) + + +# Macros +def macro(func: Callable): + """Wrap a Python function and activate the parent builder context at calling time. + + This enables embedding Python functions as builder macros. This generates a new + :class:`pulse.Schedule` that is embedded in the parent builder context with + every call of the decorated macro function. The decorated macro function will + behave as if the function code was embedded inline in the parent builder context + after parameter substitution. + + + Examples: + + .. plot:: + :include-source: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + @pulse.macro + def measure(qubit: int): + pulse.play(pulse.GaussianSquare(16384, 256, 15872), pulse.measure_channel(qubit)) + mem_slot = pulse.MemorySlot(qubit) + pulse.acquire(16384, pulse.acquire_channel(qubit), mem_slot) + + return mem_slot + + + backend = FakeOpenPulse2Q() + + with pulse.build(backend=backend) as sched: + mem_slot = measure(0) + print(f"Qubit measured into {mem_slot}") + + sched.draw() + + + Args: + func: The Python function to enable as a builder macro. There are no + requirements on the signature of the function, any calls to pulse + builder methods will be added to builder context the wrapped function + is called from. + + Returns: + Callable: The wrapped ``func``. + """ + func_name = getattr(func, "__name__", repr(func)) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + _builder = _active_builder() + # activate the pulse builder before calling the function + with build(backend=_builder.backend, name=func_name) as built: + output = func(*args, **kwargs) + + _builder.call_subroutine(built) + return output + + return wrapper + + +def measure( + qubits: list[int] | int, + registers: list[StorageLocation] | StorageLocation = None, +) -> list[StorageLocation] | StorageLocation: + """Measure a qubit within the currently active builder context. + + At the pulse level a measurement is composed of both a stimulus pulse and + an acquisition instruction which tells the systems measurement unit to + acquire data and process it. We provide this measurement macro to automate + the process for you, but if desired full control is still available with + :func:`acquire` and :func:`play`. + + To use the measurement it is as simple as specifying the qubit you wish to + measure: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + qubit = 0 + + with pulse.build(backend) as pulse_prog: + # Do something to the qubit. + qubit_drive_chan = pulse.drive_channel(0) + pulse.play(pulse.Constant(100, 1.0), qubit_drive_chan) + # Measure the qubit. + reg = pulse.measure(qubit) + + For now it is not possible to do much with the handle to ``reg`` but in the + future we will support using this handle to a result register to build + up ones program. It is also possible to supply this register: + + .. code-block:: + + with pulse.build(backend) as pulse_prog: + pulse.play(pulse.Constant(100, 1.0), qubit_drive_chan) + # Measure the qubit. + mem0 = pulse.MemorySlot(0) + reg = pulse.measure(qubit, mem0) + + assert reg == mem0 + + .. note:: Requires the active builder context to have a backend set. + + Args: + qubits: Physical qubit to measure. + registers: Register to store result in. If not selected the current + behavior is to return the :class:`MemorySlot` with the same + index as ``qubit``. This register will be returned. + Returns: + The ``register`` the qubit measurement result will be stored in. + """ + backend = active_backend() + + try: + qubits = list(qubits) + except TypeError: + qubits = [qubits] + + if registers is None: + registers = [chans.MemorySlot(qubit) for qubit in qubits] + else: + try: + registers = list(registers) + except TypeError: + registers = [registers] + measure_sched = macros.measure( + qubits=qubits, + backend=backend, + qubit_mem_slots={qubit: register.index for qubit, register in zip(qubits, registers)}, + ) + + # note this is not a subroutine. + # just a macro to automate combination of stimulus and acquisition. + # prepare unique reference name based on qubit and memory slot index. + qubits_repr = "&".join(map(str, qubits)) + mslots_repr = "&".join((str(r.index) for r in registers)) + _active_builder().call_subroutine(measure_sched, name=f"measure_{qubits_repr}..{mslots_repr}") + + if len(qubits) == 1: + return registers[0] + else: + return registers + + +def measure_all() -> list[chans.MemorySlot]: + r"""Measure all qubits within the currently active builder context. + + A simple macro function to measure all of the qubits in the device at the + same time. This is useful for handling device ``meas_map`` and single + measurement constraints. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse2Q + + backend = FakeOpenPulse2Q() + + with pulse.build(backend) as pulse_prog: + # Measure all qubits and return associated registers. + regs = pulse.measure_all() + + .. note:: + Requires the active builder context to have a backend set. + + Returns: + The ``register``\s the qubit measurement results will be stored in. + """ + backend = active_backend() + qubits = range(num_qubits()) + registers = [chans.MemorySlot(qubit) for qubit in qubits] + + measure_sched = macros.measure( + qubits=qubits, + backend=backend, + qubit_mem_slots={qubit: qubit for qubit in qubits}, + ) + + # note this is not a subroutine. + # just a macro to automate combination of stimulus and acquisition. + _active_builder().call_subroutine(measure_sched, name="measure_all") + + return registers + + +def delay_qubits(duration: int, *qubits: int): + r"""Insert delays on all the :class:`channels.Channel`\s that correspond + to the input ``qubits`` at the same time. + + Examples: + + .. code-block:: + + from qiskit import pulse + from qiskit.providers.fake_provider import FakeOpenPulse3Q + + backend = FakeOpenPulse3Q() + + with pulse.build(backend) as pulse_prog: + # Delay for 100 cycles on qubits 0, 1 and 2. + regs = pulse.delay_qubits(100, 0, 1, 2) + + .. note:: Requires the active builder context to have a backend set. + + Args: + duration: Duration to delay for. + qubits: Physical qubits to delay on. Delays will be inserted based on + the channels returned by :func:`pulse.qubit_channels`. + """ + qubit_chans = set(itertools.chain.from_iterable(qubit_channels(qubit) for qubit in qubits)) + with align_left(): + for chan in qubit_chans: + delay(duration, chan) diff --git a/qiskit_dynamics/pulse/calibration_entries.py b/qiskit_dynamics/pulse/calibration_entries.py new file mode 100644 index 000000000..490cb41fc --- /dev/null +++ b/qiskit_dynamics/pulse/calibration_entries.py @@ -0,0 +1,378 @@ +# 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. + +"""Internal format of calibration data in target.""" +from __future__ import annotations +import inspect +import warnings +from abc import ABCMeta, abstractmethod +from collections.abc import Sequence, Callable +from enum import IntEnum +from typing import Any + +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleBlock +from qiskit.qobj.converters import QobjToInstructionConverter +from qiskit.qobj.pulse_qobj import PulseQobjInstruction +from qiskit.exceptions import QiskitError + + +IncompletePulseQobj = object() +"""A None-like constant that represents the PulseQobj is incomplete.""" + + +class CalibrationPublisher(IntEnum): + """Defines who defined schedule entry.""" + + BACKEND_PROVIDER = 0 + QISKIT = 1 + EXPERIMENT_SERVICE = 2 + + +class CalibrationEntry(metaclass=ABCMeta): + """A metaclass of a calibration entry. + + This class defines a standard model of Qiskit pulse program that is + agnostic to the underlying in-memory representation. + + This entry distinguishes whether this is provided by end-users or a backend + by :attr:`.user_provided` attribute which may be provided when + the actual calibration data is provided to the entry with by :meth:`define`. + + Note that a custom entry provided by an end-user may appear in the wire-format + as an inline calibration, e.g. :code:`defcal` of the QASM3, + that may update the backend instruction set architecture for execution. + + .. note:: + + This and built-in subclasses are expected to be private without stable user-facing API. + The purpose of this class is to wrap different + in-memory pulse program representations in Qiskit, so that it can provide + the standard data model and API which are primarily used by the transpiler ecosystem. + It is assumed that end-users will never directly instantiate this class, + but :class:`.Target` or :class:`.InstructionScheduleMap` internally use this data model + to avoid implementing a complicated branching logic to + manage different calibration data formats. + + """ + + @abstractmethod + def define(self, definition: Any, user_provided: bool): + """Attach definition to the calibration entry. + + Args: + definition: Definition of this entry. + user_provided: If this entry is defined by user. + If the flag is set, this calibration may appear in the wire format + as an inline calibration, to override the backend instruction set architecture. + """ + pass + + @abstractmethod + def get_signature(self) -> inspect.Signature: + """Return signature object associated with entry definition. + + Returns: + Signature object. + """ + pass + + @abstractmethod + def get_schedule(self, *args, **kwargs) -> Schedule | ScheduleBlock: + """Generate schedule from entry definition. + + If the pulse program is templated with :class:`.Parameter` objects, + you can provide corresponding parameter values for this method + to get a particular pulse program with assigned parameters. + + Args: + args: Command parameters. + kwargs: Command keyword parameters. + + Returns: + Pulse schedule with assigned parameters. + """ + pass + + @property + @abstractmethod + def user_provided(self) -> bool: + """Return if this entry is user defined.""" + pass + + +class ScheduleDef(CalibrationEntry): + """In-memory Qiskit Pulse representation. + + A pulse schedule must provide signature with the .parameters attribute. + This entry can be parameterized by a Qiskit Parameter object. + The .get_schedule method returns a parameter-assigned pulse program. + + .. see_also:: + :class:`.CalibrationEntry` for the purpose of this class. + + """ + + def __init__(self, arguments: Sequence[str] | None = None): + """Define an empty entry. + + Args: + arguments: User provided argument names for this entry, if parameterized. + + Raises: + PulseError: When `arguments` is not a sequence of string. + """ + if arguments and not all(isinstance(arg, str) for arg in arguments): + raise PulseError(f"Arguments must be name of parameters. Not {arguments}.") + if arguments: + arguments = list(arguments) + self._user_arguments = arguments + + self._definition: Callable | Schedule | None = None + self._signature: inspect.Signature | None = None + self._user_provided: bool | None = None + + @property + def user_provided(self) -> bool: + return self._user_provided + + def _parse_argument(self): + """Generate signature from program and user provided argument names.""" + # This doesn't assume multiple parameters with the same name + # Parameters with the same name are treated identically + all_argnames = {x.name for x in self._definition.parameters} + + if self._user_arguments: + if set(self._user_arguments) != all_argnames: + raise PulseError( + "Specified arguments don't match with schedule parameters. " + f"{self._user_arguments} != {self._definition.parameters}." + ) + argnames = list(self._user_arguments) + else: + argnames = sorted(all_argnames) + + params = [] + for argname in argnames: + param = inspect.Parameter( + argname, + kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + params.append(param) + signature = inspect.Signature( + parameters=params, + return_annotation=type(self._definition), + ) + self._signature = signature + + def define( + self, + definition: Schedule | ScheduleBlock, + user_provided: bool = True, + ): + self._definition = definition + self._parse_argument() + self._user_provided = user_provided + + def get_signature(self) -> inspect.Signature: + return self._signature + + def get_schedule(self, *args, **kwargs) -> Schedule | ScheduleBlock: + if not args and not kwargs: + out = self._definition + else: + try: + to_bind = self.get_signature().bind_partial(*args, **kwargs) + except TypeError as ex: + raise PulseError( + "Assigned parameter doesn't match with schedule parameters." + ) from ex + value_dict = {} + for param in self._definition.parameters: + # Schedule allows partial bind. This results in parameterized Schedule. + try: + value_dict[param] = to_bind.arguments[param.name] + except KeyError: + pass + out = self._definition.assign_parameters(value_dict, inplace=False) + if "publisher" not in out.metadata: + if self.user_provided: + out.metadata["publisher"] = CalibrationPublisher.QISKIT + else: + out.metadata["publisher"] = CalibrationPublisher.BACKEND_PROVIDER + return out + + def __eq__(self, other): + # This delegates equality check to Schedule or ScheduleBlock. + if hasattr(other, "_definition"): + return self._definition == other._definition + return False + + def __str__(self): + out = f"Schedule {self._definition.name}" + params_str = ", ".join(self.get_signature().parameters.keys()) + if params_str: + out += f"({params_str})" + return out + + +class CallableDef(CalibrationEntry): + """Python callback function that generates Qiskit Pulse program. + + A callable is inspected by the python built-in inspection module and + provide the signature. This entry is parameterized by the function signature + and .get_schedule method returns a non-parameterized pulse program + by consuming the provided arguments and keyword arguments. + + .. see_also:: + :class:`.CalibrationEntry` for the purpose of this class. + + """ + + def __init__(self): + """Define an empty entry.""" + self._definition = None + self._signature = None + self._user_provided = None + + @property + def user_provided(self) -> bool: + return self._user_provided + + def define( + self, + definition: Callable, + user_provided: bool = True, + ): + self._definition = definition + self._signature = inspect.signature(definition) + self._user_provided = user_provided + + def get_signature(self) -> inspect.Signature: + return self._signature + + def get_schedule(self, *args, **kwargs) -> Schedule | ScheduleBlock: + try: + # Python function doesn't allow partial bind, but default value can exist. + to_bind = self._signature.bind(*args, **kwargs) + to_bind.apply_defaults() + except TypeError as ex: + raise PulseError("Assigned parameter doesn't match with function signature.") from ex + out = self._definition(**to_bind.arguments) + if "publisher" not in out.metadata: + if self.user_provided: + out.metadata["publisher"] = CalibrationPublisher.QISKIT + else: + out.metadata["publisher"] = CalibrationPublisher.BACKEND_PROVIDER + return out + + def __eq__(self, other): + # We cannot evaluate function equality without parsing python AST. + # This simply compares weather they are the same object. + if hasattr(other, "_definition"): + return self._definition == other._definition + return False + + def __str__(self): + params_str = ", ".join(self.get_signature().parameters.keys()) + return f"Callable {self._definition.__name__}({params_str})" + + +class PulseQobjDef(ScheduleDef): + """Qobj JSON serialized format instruction sequence. + + A JSON serialized program can be converted into Qiskit Pulse program with + the provided qobj converter. Because the Qobj JSON doesn't provide signature, + conversion process occurs when the signature is requested for the first time + and the generated pulse program is cached for performance. + + .. see_also:: + :class:`.CalibrationEntry` for the purpose of this class. + + """ + + def __init__( + self, + arguments: Sequence[str] | None = None, + converter: QobjToInstructionConverter | None = None, + name: str | None = None, + ): + """Define an empty entry. + + Args: + arguments: User provided argument names for this entry, if parameterized. + converter: Optional. Qobj to Qiskit converter. + name: Name of schedule. + """ + super().__init__(arguments=arguments) + + self._converter = converter or QobjToInstructionConverter(pulse_library=[]) + self._name = name + self._source: list[PulseQobjInstruction] | None = None + + def _build_schedule(self): + """Build pulse schedule from cmd-def sequence.""" + schedule = Schedule(name=self._name) + try: + for qobj_inst in self._source: + for qiskit_inst in self._converter._get_sequences(qobj_inst): + schedule.insert(qobj_inst.t0, qiskit_inst, inplace=True) + self._definition = schedule + self._parse_argument() + except QiskitError as ex: + # When the play waveform data is missing in pulse_lib we cannot build schedule. + # Instead of raising an error, get_schedule should return None. + warnings.warn( + f"Pulse calibration cannot be built and the entry is ignored: {ex.message}.", + UserWarning, + ) + self._definition = IncompletePulseQobj + + def define( + self, + definition: list[PulseQobjInstruction], + user_provided: bool = False, + ): + # This doesn't generate signature immediately, because of lazy schedule build. + self._source = definition + self._user_provided = user_provided + + def get_signature(self) -> inspect.Signature: + if self._definition is None: + self._build_schedule() + return super().get_signature() + + def get_schedule(self, *args, **kwargs) -> Schedule | ScheduleBlock | None: + if self._definition is None: + self._build_schedule() + if self._definition is IncompletePulseQobj: + return None + return super().get_schedule(*args, **kwargs) + + def __eq__(self, other): + if isinstance(other, PulseQobjDef): + # If both objects are Qobj just check Qobj equality. + return self._source == other._source + if isinstance(other, ScheduleDef) and self._definition is None: + # To compare with other schedule def, this also generates schedule object from qobj. + self._build_schedule() + if hasattr(other, "_definition"): + return self._definition == other._definition + return False + + def __str__(self): + if self._definition is None: + # Avoid parsing schedule for pretty print. + return "PulseQobj" + if self._definition is IncompletePulseQobj: + return "None" + return super().__str__() diff --git a/qiskit_dynamics/pulse/channels.py b/qiskit_dynamics/pulse/channels.py new file mode 100644 index 000000000..b6379170a --- /dev/null +++ b/qiskit_dynamics/pulse/channels.py @@ -0,0 +1,225 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +""" +.. _pulse-channels: + +======================================= +Channels (:mod:`qiskit.pulse.channels`) +======================================= + +Pulse is meant to be agnostic to the underlying hardware implementation, while still allowing +low-level control. Therefore, our signal channels are *virtual* hardware channels. The backend +which executes our programs is responsible for mapping these virtual channels to the proper +physical channel within the quantum control hardware. + +Channels are characterized by their type and their index. Channels include: + +* transmit channels, which should subclass ``PulseChannel`` +* receive channels, such as :class:`AcquireChannel` +* non-signal "channels" such as :class:`SnapshotChannel`, :class:`MemorySlot` and + :class:`RegisterChannel`. + +Novel channel types can often utilize the :class:`ControlChannel`, but if this is not sufficient, +new channel types can be created. Then, they must be supported in the PulseQobj schema and the +assembler. Channels are characterized by their type and their index. See each channel type below to +learn more. + +.. autosummary:: + :toctree: ../stubs/ + + DriveChannel + MeasureChannel + AcquireChannel + ControlChannel + RegisterSlot + MemorySlot + SnapshotChannel + +All channels are children of the same abstract base class: + +.. autoclass:: Channel +""" +from __future__ import annotations +from abc import ABCMeta +from typing import Any + +import numpy as np + +from qiskit.circuit import Parameter +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse.exceptions import PulseError + + +class Channel(metaclass=ABCMeta): + """Base class of channels. Channels provide a Qiskit-side label for typical quantum control + hardware signal channels. The final label -> physical channel mapping is the responsibility + of the hardware backend. For instance, ``DriveChannel(0)`` holds instructions which the backend + should map to the signal line driving gate operations on the qubit labeled (indexed) 0. + + When serialized channels are identified by their serialized name ````. + The type of the channel is interpreted from the prefix, + and the index often (but not always) maps to the qubit index. + All concrete channel classes must have a ``prefix`` class attribute + (and instances of that class have an index attribute). Base classes which have + ``prefix`` set to ``None`` are prevented from being instantiated. + + To implement a new channel inherit from :class:`Channel` and provide a unique string identifier + for the ``prefix`` class attribute. + """ + + prefix: str | None = None + """A shorthand string prefix for characterizing the channel type.""" + + # pylint: disable=unused-argument + def __new__(cls, *args, **kwargs): + if cls.prefix is None: + raise NotImplementedError( + "Cannot instantiate abstract channel. " + "See Channel documentation for more information." + ) + + return super().__new__(cls) + + def __init__(self, index: int): + """Channel class. + + Args: + index: Index of channel. + """ + self._validate_index(index) + self._index = index + + @property + def index(self) -> int | ParameterExpression: + """Return the index of this channel. The index is a label for a control signal line + typically mapped trivially to a qubit index. For instance, ``DriveChannel(0)`` labels + the signal line driving the qubit labeled with index 0. + """ + return self._index + + def _validate_index(self, index: Any) -> None: + """Raise a PulseError if the channel index is invalid, namely, if it's not a positive + integer. + + Raises: + PulseError: If ``index`` is not a nonnegative integer. + """ + if isinstance(index, ParameterExpression) and index.parameters: + # Parameters are unbound + return + elif isinstance(index, ParameterExpression): + index = float(index) + if index.is_integer(): + index = int(index) + + if not isinstance(index, (int, np.integer)) or index < 0: + raise PulseError("Channel index must be a nonnegative integer") + + @property + def parameters(self) -> set[Parameter]: + """Parameters which determine the channel index.""" + if isinstance(self.index, ParameterExpression): + return self.index.parameters + return set() + + def is_parameterized(self) -> bool: + """Return True iff the channel is parameterized.""" + return isinstance(self.index, ParameterExpression) + + @property + def name(self) -> str: + """Return the shorthand alias for this channel, which is based on its type and index.""" + return f"{self.__class__.prefix}{self._index}" + + def __repr__(self): + return f"{self.__class__.__name__}({self._index})" + + def __eq__(self, other: object) -> bool: + """Return True iff self and other are equal, specifically, iff they have the same type + and the same index. + + Args: + other: The channel to compare to this channel. + + Returns: + True iff equal. + """ + if not isinstance(other, Channel): + return NotImplemented + return type(self) is type(other) and self._index == other._index + + def __hash__(self): + return hash((type(self), self._index)) + + +class PulseChannel(Channel, metaclass=ABCMeta): + """Base class of transmit Channels. Pulses can be played on these channels.""" + + pass + + +class ClassicalIOChannel(Channel, metaclass=ABCMeta): + """Base class of classical IO channels. These cannot have instructions scheduled on them.""" + + pass + + +class DriveChannel(PulseChannel): + """Drive channels transmit signals to qubits which enact gate operations.""" + + prefix = "d" + + +class MeasureChannel(PulseChannel): + """Measure channels transmit measurement stimulus pulses for readout.""" + + prefix = "m" + + +class ControlChannel(PulseChannel): + """Control channels provide supplementary control over the qubit to the drive channel. + These are often associated with multi-qubit gate operations. They may not map trivially + to a particular qubit index. + """ + + prefix = "u" + + +class AcquireChannel(Channel): + """Acquire channels are used to collect data.""" + + prefix = "a" + + +class SnapshotChannel(ClassicalIOChannel): + """Snapshot channels are used to specify instructions for simulators.""" + + prefix = "s" + + def __init__(self): + """Create new snapshot channel.""" + super().__init__(0) + + +class MemorySlot(ClassicalIOChannel): + """Memory slot channels represent classical memory storage.""" + + prefix = "m" + + +class RegisterSlot(ClassicalIOChannel): + """Classical resister slot channels represent classical registers (low-latency classical + memory). + """ + + prefix = "c" diff --git a/qiskit_dynamics/pulse/configuration.py b/qiskit_dynamics/pulse/configuration.py new file mode 100644 index 000000000..1bfd1f13e --- /dev/null +++ b/qiskit_dynamics/pulse/configuration.py @@ -0,0 +1,245 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +""" +Configurations for pulse experiments. +""" +from __future__ import annotations +import numpy as np + +from .channels import DriveChannel, MeasureChannel +from .exceptions import PulseError + + +def _assert_nested_dict_equal(a: dict, b: dict): + if len(a) != len(b): + return False + for key in a: + if key in b: + if isinstance(a[key], dict): + if not _assert_nested_dict_equal(a[key], b[key]): + return False + elif isinstance(a[key], np.ndarray): + if not np.all(a[key] == b[key]): + return False + else: + if a[key] != b[key]: + return False + else: + return False + return True + + +class Kernel: + """Settings for this Kernel, which is responsible for integrating time series (raw) data + into IQ points. + """ + + def __init__(self, name: str | None = None, **params): + """Create new kernel. + + Args: + name: Name of kernel to be used + params: Any settings for kerneling. + """ + self.name = name + self.params = params + + def __repr__(self): + name_repr = "'" + self.name + "', " + params_repr = ", ".join(f"{str(k)}={str(v)}" for k, v in self.params.items()) + return f"{self.__class__.__name__}({name_repr}{params_repr})" + + def __eq__(self, other): + if isinstance(other, Kernel): + return _assert_nested_dict_equal(self.__dict__, other.__dict__) + return False + + +class Discriminator: + """Setting for this Discriminator, which is responsible for classifying kerneled IQ points + into 0/1 state results. + """ + + def __init__(self, name: str | None = None, **params): + """Create new discriminator. + + Args: + name: Name of discriminator to be used + params: Any settings for discrimination. + """ + self.name = name + self.params = params + + def __repr__(self): + name_repr = "'" + self.name + "', " or "" + params_repr = ", ".join(f"{str(k)}={str(v)}" for k, v in self.params.items()) + return f"{self.__class__.__name__}({name_repr}{params_repr})" + + def __eq__(self, other): + if isinstance(other, Discriminator): + return _assert_nested_dict_equal(self.__dict__, other.__dict__) + return False + + +class LoRange: + """Range of LO frequency.""" + + def __init__(self, lower_bound: float, upper_bound: float): + self._lb = lower_bound + self._ub = upper_bound + + def includes(self, lo_freq: complex) -> bool: + """Whether `lo_freq` is within the `LoRange`. + + Args: + lo_freq: LO frequency to be validated + + Returns: + bool: True if lo_freq is included in this range, otherwise False + """ + if self._lb <= abs(lo_freq) <= self._ub: + return True + return False + + @property + def lower_bound(self) -> float: + """Lower bound of the LO range""" + return self._lb + + @property + def upper_bound(self) -> float: + """Upper bound of the LO range""" + return self._ub + + def __repr__(self): + return f"{self.__class__.__name__}({self._lb:f}, {self._ub:f})" + + def __eq__(self, other): + """Two LO ranges are the same if they are of the same type, and + have the same frequency range + + Args: + other (LoRange): other LoRange + + Returns: + bool: are self and other equal. + """ + if type(self) is type(other) and self._ub == other._ub and self._lb == other._lb: + return True + return False + + +class LoConfig: + """Pulse channel LO frequency container.""" + + def __init__( + self, + channel_los: dict[DriveChannel | MeasureChannel, float] | None = None, + lo_ranges: dict[DriveChannel | MeasureChannel, LoRange | tuple[int, int]] | None = None, + ): + """Lo channel configuration data structure. + + Args: + channel_los: Dictionary of mappings from configurable channel to lo + lo_ranges: Dictionary of mappings to be enforced from configurable channel to `LoRange` + + Raises: + PulseError: If channel is not configurable or set lo is out of range. + + """ + self._q_lo_freq: dict[DriveChannel, float] = {} + self._m_lo_freq: dict[MeasureChannel, float] = {} + self._lo_ranges: dict[DriveChannel | MeasureChannel, LoRange] = {} + + lo_ranges = lo_ranges if lo_ranges else {} + for channel, freq in lo_ranges.items(): + self.add_lo_range(channel, freq) + + channel_los = channel_los if channel_los else {} + for channel, freq in channel_los.items(): + self.add_lo(channel, freq) + + def add_lo(self, channel: DriveChannel | MeasureChannel, freq: float): + """Add a lo mapping for a channel.""" + if isinstance(channel, DriveChannel): + # add qubit_lo_freq + self.check_lo(channel, freq) + self._q_lo_freq[channel] = freq + elif isinstance(channel, MeasureChannel): + # add meas_lo_freq + self.check_lo(channel, freq) + self._m_lo_freq[channel] = freq + else: + raise PulseError(f"Specified channel {channel.name} cannot be configured.") + + def add_lo_range( + self, channel: DriveChannel | MeasureChannel, lo_range: LoRange | tuple[int, int] + ): + """Add lo range to configuration. + + Args: + channel: Channel to add lo range for + lo_range: Lo range to add + + """ + if isinstance(lo_range, (list, tuple)): + lo_range = LoRange(*lo_range) + self._lo_ranges[channel] = lo_range + + def check_lo(self, channel: DriveChannel | MeasureChannel, freq: float) -> bool: + """Check that lo is valid for channel. + + Args: + channel: Channel to validate lo for + freq: lo frequency + Raises: + PulseError: If freq is outside of channels range + Returns: + True if lo is valid for channel + """ + lo_ranges = self._lo_ranges + if channel in lo_ranges: + lo_range = lo_ranges[channel] + if not lo_range.includes(freq): + raise PulseError(f"Specified LO freq {freq:f} is out of range {lo_range}") + return True + + def channel_lo(self, channel: DriveChannel | MeasureChannel) -> float: + """Return channel lo. + + Args: + channel: Channel to get lo for + Raises: + PulseError: If channel is not configured + Returns: + Lo of supplied channel if present + """ + if isinstance(channel, DriveChannel): + if channel in self.qubit_los: + return self.qubit_los[channel] + + if isinstance(channel, MeasureChannel): + if channel in self.meas_los: + return self.meas_los[channel] + + raise PulseError(f"Channel {channel} is not configured") + + @property + def qubit_los(self) -> dict[DriveChannel, float]: + """Returns dictionary mapping qubit channels (DriveChannel) to los.""" + return self._q_lo_freq + + @property + def meas_los(self) -> dict[MeasureChannel, float]: + """Returns dictionary mapping measure channels (MeasureChannel) to los.""" + return self._m_lo_freq diff --git a/qiskit_dynamics/pulse/exceptions.py b/qiskit_dynamics/pulse/exceptions.py new file mode 100644 index 000000000..21bda97ee --- /dev/null +++ b/qiskit_dynamics/pulse/exceptions.py @@ -0,0 +1,43 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +"""Exception for errors raised by the pulse module.""" +from qiskit.exceptions import QiskitError + + +class PulseError(QiskitError): + """Errors raised by the pulse module.""" + + def __init__(self, *message): + """Set the error message.""" + super().__init__(*message) + self.message = " ".join(message) + + def __str__(self): + """Return the message.""" + return repr(self.message) + + +class BackendNotSet(PulseError): + """Raised if the builder context does not have a backend.""" + + +class NoActiveBuilder(PulseError): + """Raised if no builder context is active.""" + + +class UnassignedDurationError(PulseError): + """Raised if instruction duration is unassigned.""" + + +class UnassignedReferenceError(PulseError): + """Raised if subroutine is unassigned.""" diff --git a/qiskit_dynamics/pulse/filters.py b/qiskit_dynamics/pulse/filters.py new file mode 100644 index 000000000..6c18e2b4c --- /dev/null +++ b/qiskit_dynamics/pulse/filters.py @@ -0,0 +1,309 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. + +"""A collection of functions that filter instructions in a pulse program.""" +from __future__ import annotations +import abc +from functools import singledispatch +from collections.abc import Iterable +from typing import Callable, Any, List + +import numpy as np + +from qiskit_dynamics.pulse import Schedule, ScheduleBlock, Instruction +from qiskit_dynamics.pulse.channels import Channel +from qiskit_dynamics.pulse.schedule import Interval +from qiskit_dynamics.pulse.exceptions import PulseError + + +@singledispatch +def filter_instructions( + sched, + filters: List[Callable[..., bool]], + negate: bool = False, + recurse_subroutines: bool = True, +): + """A catch-TypeError function which will only get called if none of the other decorated + functions, namely handle_schedule() and handle_scheduleblock(), handle the type passed. + """ + raise TypeError( + f"Type '{type(sched)}' is not valid data format as an input to filter_instructions." + ) + + +@filter_instructions.register +def handle_schedule( + sched: Schedule, + filters: List[Callable[..., bool]], + negate: bool = False, + recurse_subroutines: bool = True, +) -> Schedule: + """A filtering function that takes a schedule and returns a schedule consisting of + filtered instructions. + + Args: + sched: A pulse schedule to be filtered. + filters: List of callback functions that take an instruction and return boolean. + negate: Set `True` to accept an instruction if a filter function returns `False`. + Otherwise the instruction is accepted when the filter function returns `False`. + recurse_subroutines: Set `True` to individually filter instructions inside of a subroutine + defined by the :py:class:`~qiskit.pulse.instructions.Call` instruction. + + Returns: + Filtered pulse schedule. + """ + from qiskit.pulse.transforms import flatten, inline_subroutines + + target_sched = flatten(sched) + if recurse_subroutines: + target_sched = inline_subroutines(target_sched) + + time_inst_tuples = np.array(target_sched.instructions) + + valid_insts = np.ones(len(time_inst_tuples), dtype=bool) + for filt in filters: + valid_insts = np.logical_and(valid_insts, np.array(list(map(filt, time_inst_tuples)))) + + if negate and len(filters) > 0: + valid_insts = ~valid_insts + + filter_schedule = Schedule.initialize_from(sched) + for time, inst in time_inst_tuples[valid_insts]: + filter_schedule.insert(time, inst, inplace=True) + + return filter_schedule + + +@filter_instructions.register +def handle_scheduleblock( + sched_blk: ScheduleBlock, + filters: List[Callable[..., bool]], + negate: bool = False, + recurse_subroutines: bool = True, +) -> ScheduleBlock: + """A filtering function that takes a schedule_block and returns a schedule_block consisting of + filtered instructions. + + Args: + sched_blk: A pulse schedule_block to be filtered. + filters: List of callback functions that take an instruction and return boolean. + negate: Set `True` to accept an instruction if a filter function returns `False`. + Otherwise the instruction is accepted when the filter function returns `False`. + recurse_subroutines: Set `True` to individually filter instructions inside of a subroutine + defined by the :py:class:`~qiskit.pulse.instructions.Call` instruction. + + Returns: + Filtered pulse schedule_block. + """ + from qiskit.pulse.transforms import inline_subroutines + + target_sched_blk = sched_blk + if recurse_subroutines: + target_sched_blk = inline_subroutines(target_sched_blk) + + def apply_filters_to_insts_in_scheblk(blk: ScheduleBlock) -> ScheduleBlock: + blk_new = ScheduleBlock.initialize_from(blk) + for element in blk.blocks: + if isinstance(element, ScheduleBlock): + inner_blk = apply_filters_to_insts_in_scheblk(element) + if len(inner_blk) > 0: + blk_new.append(inner_blk) + + elif isinstance(element, Instruction): + valid_inst = all(filt(element) for filt in filters) + if negate: + valid_inst ^= True + if valid_inst: + blk_new.append(element) + + else: + raise PulseError( + f"An unexpected element '{element}' is included in ScheduleBlock.blocks." + ) + return blk_new + + filter_sched_blk = apply_filters_to_insts_in_scheblk(target_sched_blk) + return filter_sched_blk + + +def composite_filter( + channels: Iterable[Channel] | Channel | None = None, + instruction_types: Iterable[abc.ABCMeta] | abc.ABCMeta | None = None, + time_ranges: Iterable[tuple[int, int]] | None = None, + intervals: Iterable[Interval] | None = None, +) -> list[Callable]: + """A helper function to generate a list of filter functions based on + typical elements to be filtered. + + Args: + channels: For example, ``[DriveChannel(0), AcquireChannel(0)]``. + instruction_types (Optional[Iterable[Type[qiskit.pulse.Instruction]]]): For example, + ``[PulseInstruction, AcquireInstruction]``. + time_ranges: For example, ``[(0, 5), (6, 10)]``. + intervals: For example, ``[(0, 5), (6, 10)]``. + + Returns: + List of filtering functions. + """ + filters = [] + + # An empty list is also valid input for filter generators. + # See unittest `test.python.pulse.test_schedule.TestScheduleFilter.test_empty_filters`. + if channels is not None: + filters.append(with_channels(channels)) + if instruction_types is not None: + filters.append(with_instruction_types(instruction_types)) + if time_ranges is not None: + filters.append(with_intervals(time_ranges)) + if intervals is not None: + filters.append(with_intervals(intervals)) + + return filters + + +def with_channels(channels: Iterable[Channel] | Channel) -> Callable: + """Channel filter generator. + + Args: + channels: List of channels to filter. + + Returns: + A callback function to filter channels. + """ + channels = _if_scalar_cast_to_list(channels) + + @singledispatch + def channel_filter(time_inst): + """A catch-TypeError function which will only get called if none of the other decorated + functions, namely handle_numpyndarray() and handle_instruction(), handle the type passed. + """ + raise TypeError( + f"Type '{type(time_inst)}' is not valid data format as an input to channel_filter." + ) + + @channel_filter.register + def handle_numpyndarray(time_inst: np.ndarray) -> bool: + """Filter channel. + + Args: + time_inst (numpy.ndarray([int, Instruction])): Time + + Returns: + If instruction matches with condition. + """ + return any(chan in channels for chan in time_inst[1].channels) + + @channel_filter.register + def handle_instruction(inst: Instruction) -> bool: + """Filter channel. + + Args: + inst: Instruction + + Returns: + If instruction matches with condition. + """ + return any(chan in channels for chan in inst.channels) + + return channel_filter + + +def with_instruction_types(types: Iterable[abc.ABCMeta] | abc.ABCMeta) -> Callable: + """Instruction type filter generator. + + Args: + types: List of instruction types to filter. + + Returns: + A callback function to filter instructions. + """ + types = _if_scalar_cast_to_list(types) + + @singledispatch + def instruction_filter(time_inst) -> bool: + """A catch-TypeError function which will only get called if none of the other decorated + functions, namely handle_numpyndarray() and handle_instruction(), handle the type passed. + """ + raise TypeError( + f"Type '{type(time_inst)}' is not valid data format as an input to instruction_filter." + ) + + @instruction_filter.register + def handle_numpyndarray(time_inst: np.ndarray) -> bool: + """Filter instruction. + + Args: + time_inst (numpy.ndarray([int, Instruction])): Time + + Returns: + If instruction matches with condition. + """ + return isinstance(time_inst[1], tuple(types)) + + @instruction_filter.register + def handle_instruction(inst: Instruction) -> bool: + """Filter instruction. + + Args: + inst: Instruction + + Returns: + If instruction matches with condition. + """ + return isinstance(inst, tuple(types)) + + return instruction_filter + + +def with_intervals(ranges: Iterable[Interval] | Interval) -> Callable: + """Interval filter generator. + + Args: + ranges: List of intervals ``[t0, t1]`` to filter. + + Returns: + A callback function to filter intervals. + """ + ranges = _if_scalar_cast_to_list(ranges) + + def interval_filter(time_inst) -> bool: + """Filter interval. + Args: + time_inst (Tuple[int, Instruction]): Time + + Returns: + If instruction matches with condition. + """ + for t0, t1 in ranges: + inst_start = time_inst[0] + inst_stop = inst_start + time_inst[1].duration + if t0 <= inst_start and inst_stop <= t1: + return True + return False + + return interval_filter + + +def _if_scalar_cast_to_list(to_list: Any) -> list[Any]: + """A helper function to create python list of input arguments. + + Args: + to_list: Arbitrary object can be converted into a python list. + + Returns: + Python list of input object. + """ + try: + iter(to_list) + except TypeError: + to_list = [to_list] + return to_list diff --git a/qiskit_dynamics/pulse/instruction_schedule_map.py b/qiskit_dynamics/pulse/instruction_schedule_map.py new file mode 100644 index 000000000..81b66b5f3 --- /dev/null +++ b/qiskit_dynamics/pulse/instruction_schedule_map.py @@ -0,0 +1,409 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +# pylint: disable=unused-import + +""" +A convenient way to track reusable subschedules by name and qubit. + +This can be used for scheduling circuits with custom definitions, for instance:: + + inst_map = InstructionScheduleMap() + inst_map.add('new_inst', 0, qubit_0_new_inst_schedule) + + sched = schedule(quantum_circuit, backend, inst_map) + +An instance of this class is instantiated by Pulse-enabled backends and populated with defaults +(if available):: + + inst_map = backend.defaults().instruction_schedule_map + +""" +from __future__ import annotations +import functools +import warnings +from collections import defaultdict +from collections.abc import Iterable, Callable + +from qiskit import circuit +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse.calibration_entries import ( + CalibrationEntry, + ScheduleDef, + CallableDef, + # for backward compatibility + PulseQobjDef, + CalibrationPublisher, +) +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleBlock + + +class InstructionScheduleMap: + """Mapping from :py:class:`~qiskit.circuit.QuantumCircuit` + :py:class:`qiskit.circuit.Instruction` names and qubits to + :py:class:`~qiskit.pulse.Schedule` s. In particular, the mapping is formatted as type:: + + Dict[str, Dict[Tuple[int], Schedule]] + + where the first key is the name of a circuit instruction (e.g. ``'u1'``, ``'measure'``), the + second key is a tuple of qubit indices, and the final value is a Schedule implementing the + requested instruction. + + These can usually be seen as gate calibrations. + """ + + def __init__(self): + """Initialize a circuit instruction to schedule mapper instance.""" + # The processed and reformatted circuit instruction definitions + + # Do not use lambda function for nested defaultdict, i.e. lambda: defaultdict(CalibrationEntry). + # This crashes qiskit parallel. Note that parallel framework passes args as + # pickled object, however lambda function cannot be pickled. + self._map: dict[str | circuit.instruction.Instruction, dict[tuple, CalibrationEntry]] = ( + defaultdict(functools.partial(defaultdict, CalibrationEntry)) + ) + + # A backwards mapping from qubit to supported instructions + self._qubit_instructions: dict[tuple[int, ...], set] = defaultdict(set) + + def has_custom_gate(self) -> bool: + """Return ``True`` if the map has user provided instruction.""" + for qubit_inst in self._map.values(): + for entry in qubit_inst.values(): + if entry.user_provided: + return True + return False + + @property + def instructions(self) -> list[str]: + """Return all instructions which have definitions. + + By default, these are typically the basis gates along with other instructions such as + measure and reset. + + Returns: + The names of all the circuit instructions which have Schedule definitions in this. + """ + return list(self._map.keys()) + + def qubits_with_instruction( + self, instruction: str | circuit.instruction.Instruction + ) -> list[int | tuple[int, ...]]: + """Return a list of the qubits for which the given instruction is defined. Single qubit + instructions return a flat list, and multiqubit instructions return a list of ordered + tuples. + + Args: + instruction: The name of the circuit instruction. + + Returns: + Qubit indices which have the given instruction defined. This is a list of tuples if the + instruction has an arity greater than 1, or a flat list of ints otherwise. + + Raises: + PulseError: If the instruction is not found. + """ + instruction = _get_instruction_string(instruction) + if instruction not in self._map: + return [] + return [ + qubits[0] if len(qubits) == 1 else qubits + for qubits in sorted(self._map[instruction].keys()) + ] + + def qubit_instructions(self, qubits: int | Iterable[int]) -> list[str]: + """Return a list of the instruction names that are defined by the backend for the given + qubit or qubits. + + Args: + qubits: A qubit index, or a list or tuple of indices. + + Returns: + All the instructions which are defined on the qubits. + + For 1 qubit, all the 1Q instructions defined. For multiple qubits, all the instructions + which apply to that whole set of qubits (e.g. ``qubits=[0, 1]`` may return ``['cx']``). + """ + if _to_tuple(qubits) in self._qubit_instructions: + return list(self._qubit_instructions[_to_tuple(qubits)]) + return [] + + def has( + self, instruction: str | circuit.instruction.Instruction, qubits: int | Iterable[int] + ) -> bool: + """Is the instruction defined for the given qubits? + + Args: + instruction: The instruction for which to look. + qubits: The specific qubits for the instruction. + + Returns: + True iff the instruction is defined. + """ + instruction = _get_instruction_string(instruction) + return instruction in self._map and _to_tuple(qubits) in self._map[instruction] + + def assert_has( + self, instruction: str | circuit.instruction.Instruction, qubits: int | Iterable[int] + ) -> None: + """Error if the given instruction is not defined. + + Args: + instruction: The instruction for which to look. + qubits: The specific qubits for the instruction. + + Raises: + PulseError: If the instruction is not defined on the qubits. + """ + instruction = _get_instruction_string(instruction) + if not self.has(instruction, _to_tuple(qubits)): + if instruction in self._map: + raise PulseError( + f"Operation '{instruction}' exists, but is only defined for qubits " + f"{self.qubits_with_instruction(instruction)}." + ) + raise PulseError(f"Operation '{instruction}' is not defined for this system.") + + def get( + self, + instruction: str | circuit.instruction.Instruction, + qubits: int | Iterable[int], + *params: complex | ParameterExpression, + **kwparams: complex | ParameterExpression, + ) -> Schedule | ScheduleBlock: + """Return the defined :py:class:`~qiskit.pulse.Schedule` or + :py:class:`~qiskit.pulse.ScheduleBlock` for the given instruction on the given qubits. + + If all keys are not specified this method returns schedule with unbound parameters. + + Args: + instruction: Name of the instruction or the instruction itself. + qubits: The qubits for the instruction. + *params: Command parameters for generating the output schedule. + **kwparams: Keyworded command parameters for generating the schedule. + + Returns: + The Schedule defined for the input. + """ + return self._get_calibration_entry(instruction, qubits).get_schedule(*params, **kwparams) + + def _get_calibration_entry( + self, + instruction: str | circuit.instruction.Instruction, + qubits: int | Iterable[int], + ) -> CalibrationEntry: + """Return the :class:`.CalibrationEntry` without generating schedule. + + When calibration entry is un-parsed Pulse Qobj, this returns calibration + without parsing it. :meth:`CalibrationEntry.get_schedule` method + must be manually called with assigned parameters to get corresponding pulse schedule. + + This method is expected be directly used internally by the V2 backend converter + for faster loading of the backend calibrations. + + Args: + instruction: Name of the instruction or the instruction itself. + qubits: The qubits for the instruction. + + Returns: + The calibration entry. + """ + instruction = _get_instruction_string(instruction) + self.assert_has(instruction, qubits) + + return self._map[instruction][_to_tuple(qubits)] + + def add( + self, + instruction: str | circuit.instruction.Instruction, + qubits: int | Iterable[int], + schedule: Schedule | ScheduleBlock | Callable[..., Schedule | ScheduleBlock], + arguments: list[str] | None = None, + ) -> None: + """Add a new known instruction for the given qubits and its mapping to a pulse schedule. + + Args: + instruction: The name of the instruction to add. + qubits: The qubits which the instruction applies to. + schedule: The Schedule that implements the given instruction. + arguments: List of parameter names to create a parameter-bound schedule from the + associated gate instruction. If :py:meth:`get` is called with arguments rather + than keyword arguments, this parameter list is used to map the input arguments to + parameter objects stored in the target schedule. + + Raises: + PulseError: If the qubits are provided as an empty iterable. + """ + instruction = _get_instruction_string(instruction) + + # validation of target qubit + qubits = _to_tuple(qubits) + if not qubits: + raise PulseError(f"Cannot add definition {instruction} with no target qubits.") + + # generate signature + if isinstance(schedule, (Schedule, ScheduleBlock)): + entry: CalibrationEntry = ScheduleDef(arguments) + elif callable(schedule): + if arguments: + warnings.warn( + "Arguments are overruled by the callback function signature. " + "Input `arguments` are ignored.", + UserWarning, + ) + entry = CallableDef() + else: + raise PulseError( + "Supplied schedule must be one of the Schedule, ScheduleBlock or a " + "callable that outputs a schedule." + ) + entry.define(schedule, user_provided=True) + self._add(instruction, qubits, entry) + + def _add( + self, + instruction_name: str, + qubits: tuple[int, ...], + entry: CalibrationEntry, + ): + """A method to resister calibration entry. + + .. note:: + + This is internal fast-path function, and caller must ensure + the entry is properly formatted. This function may be used by other programs + that load backend calibrations to create Qiskit representation of it. + + Args: + instruction_name: Name of instruction. + qubits: List of qubits that this calibration is applied. + entry: Calibration entry to register. + + :meta public: + """ + self._map[instruction_name][qubits] = entry + self._qubit_instructions[qubits].add(instruction_name) + + def remove( + self, instruction: str | circuit.instruction.Instruction, qubits: int | Iterable[int] + ) -> None: + """Remove the given instruction from the listing of instructions defined in self. + + Args: + instruction: The name of the instruction to add. + qubits: The qubits which the instruction applies to. + """ + instruction = _get_instruction_string(instruction) + qubits = _to_tuple(qubits) + self.assert_has(instruction, qubits) + + del self._map[instruction][qubits] + if not self._map[instruction]: + del self._map[instruction] + + self._qubit_instructions[qubits].remove(instruction) + if not self._qubit_instructions[qubits]: + del self._qubit_instructions[qubits] + + def pop( + self, + instruction: str | circuit.instruction.Instruction, + qubits: int | Iterable[int], + *params: complex | ParameterExpression, + **kwparams: complex | ParameterExpression, + ) -> Schedule | ScheduleBlock: + """Remove and return the defined schedule for the given instruction on the given + qubits. + + Args: + instruction: Name of the instruction. + qubits: The qubits for the instruction. + *params: Command parameters for generating the output schedule. + **kwparams: Keyworded command parameters for generating the schedule. + + Returns: + The Schedule defined for the input. + """ + instruction = _get_instruction_string(instruction) + schedule = self.get(instruction, qubits, *params, **kwparams) + self.remove(instruction, qubits) + return schedule + + def get_parameters( + self, instruction: str | circuit.instruction.Instruction, qubits: int | Iterable[int] + ) -> tuple[str, ...]: + """Return the list of parameters taken by the given instruction on the given qubits. + + Args: + instruction: Name of the instruction. + qubits: The qubits for the instruction. + + Returns: + The names of the parameters required by the instruction. + """ + instruction = _get_instruction_string(instruction) + + self.assert_has(instruction, qubits) + signature = self._map[instruction][_to_tuple(qubits)].get_signature() + return tuple(signature.parameters.keys()) + + def __str__(self): + single_q_insts = "1Q instructions:\n" + multi_q_insts = "Multi qubit instructions:\n" + for qubits, insts in self._qubit_instructions.items(): + if len(qubits) == 1: + single_q_insts += f" q{qubits[0]}: {insts}\n" + else: + multi_q_insts += f" {qubits}: {insts}\n" + instructions = single_q_insts + multi_q_insts + return f"<{self.__class__.__name__}({instructions})>" + + def __eq__(self, other): + if not isinstance(other, InstructionScheduleMap): + return False + + for inst in self.instructions: + for qinds in self.qubits_with_instruction(inst): + try: + if self._map[inst][_to_tuple(qinds)] != other._map[inst][_to_tuple(qinds)]: + return False + except KeyError: + return False + return True + + +def _to_tuple(values: int | Iterable[int]) -> tuple[int, ...]: + """Return the input as a tuple. + + Args: + values: An integer, or iterable of integers. + + Returns: + The input values as a sorted tuple. + """ + try: + return tuple(values) + except TypeError: + return (values,) + + +def _get_instruction_string(inst: str | circuit.instruction.Instruction) -> str: + if isinstance(inst, str): + return inst + else: + try: + return inst.name + except AttributeError as ex: + raise PulseError( + 'Input "inst" has no attribute "name". This should be a circuit "Instruction".' + ) from ex diff --git a/qiskit_dynamics/pulse/instructions/__init__.py b/qiskit_dynamics/pulse/instructions/__init__.py new file mode 100644 index 000000000..e97ac27fc --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/__init__.py @@ -0,0 +1,67 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +r""" +.. _pulse-insts: + +=============================================== +Instructions (:mod:`qiskit.pulse.instructions`) +=============================================== + +The ``instructions`` module holds the various :obj:`Instruction`\ s which are supported by +Qiskit Pulse. Instructions have operands, which typically include at least one +:py:class:`~qiskit.pulse.channels.Channel` specifying where the instruction will be applied. + +Every instruction has a duration, whether explicitly included as an operand or implicitly defined. +For instance, a :py:class:`~qiskit.pulse.instructions.ShiftPhase` instruction can be instantiated +with operands *phase* and *channel*, for some float ``phase`` and a +:py:class:`~qiskit.pulse.channels.Channel` ``channel``:: + + ShiftPhase(phase, channel) + +The duration of this instruction is implicitly zero. On the other hand, the +:py:class:`~qiskit.pulse.instructions.Delay` instruction takes an explicit duration:: + + Delay(duration, channel) + +An instruction can be added to a :py:class:`~qiskit.pulse.Schedule`, which is a +sequence of scheduled Pulse ``Instruction`` s over many channels. ``Instruction`` s and +``Schedule`` s implement the same interface. + +.. autosummary:: + :toctree: ../stubs/ + + Acquire + Reference + Delay + Play + RelativeBarrier + SetFrequency + ShiftFrequency + SetPhase + ShiftPhase + Snapshot + TimeBlockade + +These are all instances of the same base class: + +.. autoclass:: Instruction +""" +from .acquire import Acquire +from .delay import Delay +from .directives import Directive, RelativeBarrier, TimeBlockade +from .instruction import Instruction +from .frequency import SetFrequency, ShiftFrequency +from .phase import ShiftPhase, SetPhase +from .play import Play +from .snapshot import Snapshot +from .reference import Reference diff --git a/qiskit_dynamics/pulse/instructions/acquire.py b/qiskit_dynamics/pulse/instructions/acquire.py new file mode 100644 index 000000000..d00462400 --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/acquire.py @@ -0,0 +1,148 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""The Acquire instruction is used to trigger the qubit measurement unit and provide +some metadata for the acquisition process, for example, where to store classified readout data. +""" +from __future__ import annotations +from qiskit.circuit import ParameterExpression +from qiskit_dynamics.pulse.channels import MemorySlot, RegisterSlot, AcquireChannel +from qiskit_dynamics.pulse.configuration import Kernel, Discriminator +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.instructions.instruction import Instruction + + +class Acquire(Instruction): + """The Acquire instruction is used to trigger the ADC associated with a particular qubit; + e.g. instantiated with AcquireChannel(0), the Acquire command will trigger data collection + for the channel associated with qubit 0 readout. This instruction also provides acquisition + metadata: + + * the number of cycles during which to acquire (in terms of dt), + + * the register slot to store classified, intermediary readout results, + + * the memory slot to return classified results, + + * the kernel to integrate raw data for each shot, and + + * the discriminator to classify kerneled IQ points. + """ + + def __init__( + self, + duration: int | ParameterExpression, + channel: AcquireChannel, + mem_slot: MemorySlot | None = None, + reg_slot: RegisterSlot | None = None, + kernel: Kernel | None = None, + discriminator: Discriminator | None = None, + name: str | None = None, + ): + """Create a new Acquire instruction. + + Args: + duration: Length of time to acquire data in terms of dt. + channel: The channel that will acquire data. + mem_slot: The classical memory slot in which to store the classified readout result. + reg_slot: The fast-access register slot in which to store the classified readout + result for fast feedback. + kernel: A ``Kernel`` for integrating raw data. + discriminator: A ``Discriminator`` for discriminating kerneled IQ data into 0/1 + results. + name: Name of the instruction for display purposes. + """ + super().__init__( + operands=(duration, channel, mem_slot, reg_slot, kernel, discriminator), + name=name, + ) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``channel`` is not type :class:`AcquireChannel`. + PulseError: If the input ``mem_slot`` is not type :class:`MemorySlot`. + PulseError: If the input ``reg_slot`` is not type :class:`RegisterSlot`. + PulseError: When memory slot and register slot are both empty. + """ + if not isinstance(self.channel, AcquireChannel): + raise PulseError(f"Expected an acquire channel, got {self.channel} instead.") + + if self.mem_slot and not isinstance(self.mem_slot, MemorySlot): + raise PulseError(f"Expected a memory slot, got {self.mem_slot} instead.") + + if self.reg_slot and not isinstance(self.reg_slot, RegisterSlot): + raise PulseError(f"Expected a register slot, got {self.reg_slot} instead.") + + if self.mem_slot is None and self.reg_slot is None: + raise PulseError("Neither MemorySlots nor RegisterSlots were supplied.") + + @property + def channel(self) -> AcquireChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> tuple[AcquireChannel | MemorySlot | RegisterSlot, ...]: + """Returns the channels that this schedule uses.""" + return tuple(self.operands[ind] for ind in (1, 2, 3) if self.operands[ind] is not None) + + @property + def duration(self) -> int | ParameterExpression: + """Duration of this instruction.""" + return self.operands[0] + + @property + def kernel(self) -> Kernel: + """Return kernel settings.""" + return self._operands[4] + + @property + def discriminator(self) -> Discriminator: + """Return discrimination settings.""" + return self._operands[5] + + @property + def acquire(self) -> AcquireChannel: + """Acquire channel to acquire data. The ``AcquireChannel`` index maps trivially to + qubit index. + """ + return self.channel + + @property + def mem_slot(self) -> MemorySlot: + """The classical memory slot which will store the classified readout result.""" + return self.operands[2] + + @property + def reg_slot(self) -> RegisterSlot: + """The fast-access register slot which will store the classified readout result for + fast-feedback computation. + """ + return self.operands[3] + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return isinstance(self.duration, ParameterExpression) or super().is_parameterized() + + def __repr__(self) -> str: + mem_slot_repr = str(self.mem_slot) if self.mem_slot else "" + reg_slot_repr = str(self.reg_slot) if self.reg_slot else "" + kernel_repr = str(self.kernel) if self.kernel else "" + discriminator_repr = str(self.discriminator) if self.discriminator else "" + return ( + f"{self.__class__.__name__}({self.duration}, {str(self.channel)}, " + f"{mem_slot_repr}, {reg_slot_repr}, {kernel_repr}, {discriminator_repr})" + ) diff --git a/qiskit_dynamics/pulse/instructions/delay.py b/qiskit_dynamics/pulse/instructions/delay.py new file mode 100644 index 000000000..af692a165 --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/delay.py @@ -0,0 +1,69 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""An instruction for blocking time on a channel; useful for scheduling alignment.""" +from __future__ import annotations + +from qiskit.circuit import ParameterExpression +from qiskit_dynamics.pulse.channels import Channel +from qiskit_dynamics.pulse.instructions.instruction import Instruction + + +class Delay(Instruction): + """A blocking instruction with no other effect. The delay is used for aligning and scheduling + other instructions. + + Example: + + To schedule an instruction at time = 10, on a channel assigned to the variable ``channel``, + the following could be used:: + + sched = Schedule(name="Delay instruction example") + sched += Delay(10, channel) + sched += Gaussian(duration, amp, sigma, channel) + + The ``channel`` will output no signal from time=0 up until time=10. + """ + + def __init__( + self, + duration: int | ParameterExpression, + channel: Channel, + name: str | None = None, + ): + """Create a new delay instruction. + + No other instruction may be scheduled within a ``Delay``. + + Args: + duration: Length of time of the delay in terms of dt. + channel: The channel that will have the delay. + name: Name of the delay for display purposes. + """ + super().__init__(operands=(duration, channel), name=name) + + @property + def channel(self) -> Channel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> tuple[Channel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int | ParameterExpression: + """Duration of this instruction.""" + return self.operands[0] diff --git a/qiskit_dynamics/pulse/instructions/directives.py b/qiskit_dynamics/pulse/instructions/directives.py new file mode 100644 index 000000000..a25365199 --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/directives.py @@ -0,0 +1,151 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Directives are hints to the pulse compiler for how to process its input programs.""" +from __future__ import annotations + +from abc import ABC + +from qiskit_dynamics.pulse import channels as chans +from qiskit_dynamics.pulse.instructions import instruction +from qiskit_dynamics.pulse.exceptions import PulseError + + +class Directive(instruction.Instruction, ABC): + """A compiler directive. + + This is a hint to the pulse compiler and is not loaded into hardware. + """ + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return 0 + + +class RelativeBarrier(Directive): + """Pulse ``RelativeBarrier`` directive.""" + + def __init__(self, *channels: chans.Channel, name: str | None = None): + """Create a relative barrier directive. + + The barrier directive blocks instructions within the same schedule + as the barrier on channels contained within this barrier from moving + through the barrier in time. + + Args: + channels: The channel that the barrier applies to. + name: Name of the directive for display purposes. + """ + super().__init__(operands=tuple(channels), name=name) + + @property + def channels(self) -> tuple[chans.Channel, ...]: + """Returns the channels that this schedule uses.""" + return self.operands + + def __eq__(self, other: object) -> bool: + """Verify two barriers are equivalent.""" + return isinstance(other, type(self)) and set(self.channels) == set(other.channels) + + +class TimeBlockade(Directive): + """Pulse ``TimeBlockade`` directive. + + This instruction is intended to be used internally within the pulse builder, + to convert :class:`.Schedule` into :class:`.ScheduleBlock`. + Because :class:`.ScheduleBlock` cannot take an absolute instruction time interval, + this directive helps the block representation to find the starting time of an instruction. + + Example: + + This schedule plays constant pulse at t0 = 120. + + .. code-block:: python + + from qiskit.pulse import Schedule, Play, Constant, DriveChannel + + schedule = Schedule() + schedule.insert(120, Play(Constant(10, 0.1), DriveChannel(0))) + + This schedule block is expected to be identical to above at a time of execution. + + .. code-block:: python + + from qiskit.pulse import ScheduleBlock, Play, Constant, DriveChannel + from qiskit.pulse.instructions import TimeBlockade + + block = ScheduleBlock() + block.append(TimeBlockade(120, DriveChannel(0))) + block.append(Play(Constant(10, 0.1), DriveChannel(0))) + + Such conversion may be done by + + .. code-block:: python + + from qiskit.pulse.transforms import block_to_schedule, remove_directives + + schedule = remove_directives(block_to_schedule(block)) + + + .. note:: + + The TimeBlockade instruction behaves almost identically + to :class:`~qiskit.pulse.instructions.Delay` instruction. + However, the TimeBlockade is just a compiler directive and must be removed before execution. + This may be done by :func:`~qiskit.pulse.transforms.remove_directives` transform. + Once these directives are removed, occupied timeslots are released and + user can insert another instruction without timing overlap. + """ + + def __init__( + self, + duration: int, + channel: chans.Channel, + name: str | None = None, + ): + """Create a time blockade directive. + + Args: + duration: Length of time of the occupation in terms of dt. + channel: The channel that will be the occupied. + name: Name of the time blockade for display purposes. + """ + super().__init__(operands=(duration, channel), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``duration`` is not integer value. + """ + if not isinstance(self.duration, int): + raise PulseError( + "TimeBlockade duration cannot be parameterized. Specify an integer duration value." + ) + + @property + def channel(self) -> chans.Channel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> tuple[chans.Channel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return self.operands[0] diff --git a/qiskit_dynamics/pulse/instructions/frequency.py b/qiskit_dynamics/pulse/instructions/frequency.py new file mode 100644 index 000000000..1a045a31e --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/frequency.py @@ -0,0 +1,132 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Frequency instructions module. These instructions allow the user to manipulate +the frequency of a channel. +""" +from typing import Optional, Union, Tuple + +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse.channels import PulseChannel +from qiskit_dynamics.pulse.instructions.instruction import Instruction +from qiskit_dynamics.pulse.exceptions import PulseError + + +class SetFrequency(Instruction): + r"""Set the channel frequency. This instruction operates on ``PulseChannel`` s. + A ``PulseChannel`` creates pulses of the form + + .. math:: + Re[\exp(i 2\pi f jdt + \phi) d_j]. + + Here, :math:`f` is the frequency of the channel. The instruction ``SetFrequency`` allows + the user to set the value of :math:`f`. All pulses that are played on a channel + after SetFrequency has been called will have the corresponding frequency. + + The duration of SetFrequency is 0. + """ + + def __init__( + self, + frequency: Union[float, ParameterExpression], + channel: PulseChannel, + name: Optional[str] = None, + ): + """Creates a new set channel frequency instruction. + + Args: + frequency: New frequency of the channel in Hz. + channel: The channel this instruction operates on. + name: Name of this set channel frequency instruction. + """ + super().__init__(operands=(frequency, channel), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``channel`` is not type :class:`PulseChannel`. + """ + if not isinstance(self.channel, PulseChannel): + raise PulseError(f"Expected a pulse channel, got {self.channel} instead.") + + @property + def frequency(self) -> Union[float, ParameterExpression]: + """New frequency.""" + return self.operands[0] + + @property + def channel(self) -> PulseChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> Tuple[PulseChannel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return 0 + + +class ShiftFrequency(Instruction): + """Shift the channel frequency away from the current frequency.""" + + def __init__( + self, + frequency: Union[float, ParameterExpression], + channel: PulseChannel, + name: Optional[str] = None, + ): + """Creates a new shift frequency instruction. + + Args: + frequency: Frequency shift of the channel in Hz. + channel: The channel this instruction operates on. + name: Name of this set channel frequency instruction. + """ + super().__init__(operands=(frequency, channel), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``channel`` is not type :class:`PulseChannel`. + """ + if not isinstance(self.channel, PulseChannel): + raise PulseError(f"Expected a pulse channel, got {self.channel} instead.") + + @property + def frequency(self) -> Union[float, ParameterExpression]: + """Frequency shift from the set frequency.""" + return self.operands[0] + + @property + def channel(self) -> PulseChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> Tuple[PulseChannel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return 0 diff --git a/qiskit_dynamics/pulse/instructions/instruction.py b/qiskit_dynamics/pulse/instructions/instruction.py new file mode 100644 index 000000000..6d3f5d946 --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/instruction.py @@ -0,0 +1,268 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +""" +``Instruction`` s are single operations within a :py:class:`~qiskit.pulse.Schedule`, and can be +used the same way as :py:class:`~qiskit.pulse.Schedule` s. + +For example:: + + duration = 10 + channel = DriveChannel(0) + sched = Schedule() + sched += Delay(duration, channel) # Delay is a specific subclass of Instruction +""" +from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Iterable + +from qiskit.circuit import Parameter, ParameterExpression +from qiskit_dynamics.pulse.channels import Channel +from qiskit_dynamics.pulse.exceptions import PulseError + + +# pylint: disable=bad-docstring-quotes + + +class Instruction(ABC): + """The smallest schedulable unit: a single instruction. It has a fixed duration and specified + channels. + """ + + def __init__( + self, + operands: tuple, + name: str | None = None, + ): + """Instruction initializer. + + Args: + operands: The argument list. + name: Optional display name for this instruction. + """ + self._operands = operands + self._name = name + self._validate() + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``channels`` are not all of type :class:`Channel`. + """ + for channel in self.channels: + if not isinstance(channel, Channel): + raise PulseError(f"Expected a channel, got {channel} instead.") + + @property + def name(self) -> str: + """Name of this instruction.""" + return self._name + + @property + def id(self) -> int: # pylint: disable=invalid-name + """Unique identifier for this instruction.""" + return id(self) + + @property + def operands(self) -> tuple: + """Return instruction operands.""" + return self._operands + + @property + @abstractmethod + def channels(self) -> tuple[Channel, ...]: + """Returns the channels that this schedule uses.""" + raise NotImplementedError + + @property + def start_time(self) -> int: + """Relative begin time of this instruction.""" + return 0 + + @property + def stop_time(self) -> int: + """Relative end time of this instruction.""" + return self.duration + + @property + def duration(self) -> int | ParameterExpression: + """Duration of this instruction.""" + raise NotImplementedError + + @property + def _children(self) -> tuple["Instruction", ...]: + """Instruction has no child nodes.""" + return () + + @property + def instructions(self) -> tuple[tuple[int, "Instruction"], ...]: + """Iterable for getting instructions from Schedule tree.""" + return tuple(self._instructions()) + + def ch_duration(self, *channels: Channel) -> int: + """Return duration of the supplied channels in this Instruction. + + Args: + *channels: Supplied channels + """ + return self.ch_stop_time(*channels) + + def ch_start_time(self, *channels: Channel) -> int: + # pylint: disable=unused-argument + """Return minimum start time for supplied channels. + + Args: + *channels: Supplied channels + """ + return 0 + + def ch_stop_time(self, *channels: Channel) -> int: + """Return maximum start time for supplied channels. + + Args: + *channels: Supplied channels + """ + if any(chan in self.channels for chan in channels): + return self.duration + return 0 + + def _instructions(self, time: int = 0) -> Iterable[tuple[int, "Instruction"]]: + """Iterable for flattening Schedule tree. + + Args: + time: Shifted time of this node due to parent + + Yields: + Tuple[int, Union['Schedule, 'Instruction']]: Tuple of the form + (start_time, instruction). + """ + yield (time, self) + + def shift(self, time: int, name: str | None = None): + """Return a new schedule shifted forward by `time`. + + Args: + time: Time to shift by + name: Name of the new schedule. Defaults to name of self + + Returns: + Schedule: The shifted schedule. + """ + from qiskit.pulse.schedule import Schedule + + if name is None: + name = self.name + return Schedule((time, self), name=name) + + def insert(self, start_time: int, schedule, name: str | None = None): + """Return a new :class:`~qiskit.pulse.Schedule` with ``schedule`` inserted within + ``self`` at ``start_time``. + + Args: + start_time: Time to insert the schedule schedule + schedule (Union['Schedule', 'Instruction']): Schedule or instruction to insert + name: Name of the new schedule. Defaults to name of self + + Returns: + Schedule: A new schedule with ``schedule`` inserted with this instruction at t=0. + """ + from qiskit.pulse.schedule import Schedule + + if name is None: + name = self.name + return Schedule(self, (start_time, schedule), name=name) + + def append(self, schedule, name: str | None = None): + """Return a new :class:`~qiskit.pulse.Schedule` with ``schedule`` inserted at the + maximum time over all channels shared between ``self`` and ``schedule``. + + Args: + schedule (Union['Schedule', 'Instruction']): Schedule or instruction to be appended + name: Name of the new schedule. Defaults to name of self + + Returns: + Schedule: A new schedule with ``schedule`` a this instruction at t=0. + """ + common_channels = set(self.channels) & set(schedule.channels) + time = self.ch_stop_time(*common_channels) + return self.insert(time, schedule, name=name) + + @property + def parameters(self) -> set: + """Parameters which determine the instruction behavior.""" + + def _get_parameters_recursive(obj): + params = set() + if hasattr(obj, "parameters"): + for param in obj.parameters: + if isinstance(param, Parameter): + params.add(param) + else: + params |= _get_parameters_recursive(param) + return params + + parameters = set() + for op in self.operands: + parameters |= _get_parameters_recursive(op) + return parameters + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return any(self.parameters) + + def __eq__(self, other: object) -> bool: + """Check if this Instruction is equal to the `other` instruction. + + Equality is determined by the instruction sharing the same operands and channels. + """ + if not isinstance(other, Instruction): + return NotImplemented + return isinstance(other, type(self)) and self.operands == other.operands + + def __hash__(self) -> int: + return hash((type(self), self.operands, self.name)) + + def __add__(self, other): + """Return a new schedule with `other` inserted within `self` at `start_time`. + + Args: + other (Union['Schedule', 'Instruction']): Schedule or instruction to be appended + + Returns: + Schedule: A new schedule with ``schedule`` appended after this instruction at t=0. + """ + return self.append(other) + + def __or__(self, other): + """Return a new schedule which is the union of `self` and `other`. + + Args: + other (Union['Schedule', 'Instruction']): Schedule or instruction to union with + + Returns: + Schedule: A new schedule with ``schedule`` inserted with this instruction at t=0 + """ + return self.insert(0, other) + + def __lshift__(self, time: int): + """Return a new schedule which is shifted forward by `time`. + + Returns: + Schedule: The shifted schedule + """ + return self.shift(time) + + def __repr__(self) -> str: + operands = ", ".join(str(op) for op in self.operands) + name_repr = f", name='{self.name}'" if self.name else "" + return f"{self.__class__.__name__}({operands}{name_repr})" diff --git a/qiskit_dynamics/pulse/instructions/phase.py b/qiskit_dynamics/pulse/instructions/phase.py new file mode 100644 index 000000000..2c3d6b38b --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/phase.py @@ -0,0 +1,149 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""The phase instructions update the modulation phase of pulses played on a channel. +This includes ``SetPhase`` instructions which lock the modulation to a particular phase +at that moment, and ``ShiftPhase`` instructions which increase the existing phase by a +relative amount. +""" +from typing import Optional, Union, Tuple + +from qiskit.circuit import ParameterExpression +from qiskit_dynamics.pulse.channels import PulseChannel +from qiskit_dynamics.pulse.instructions.instruction import Instruction +from qiskit_dynamics.pulse.exceptions import PulseError + + +class ShiftPhase(Instruction): + r"""The shift phase instruction updates the modulation phase of proceeding pulses played on the + same :py:class:`~qiskit.pulse.channels.Channel`. It is a relative increase in phase determined + by the ``phase`` operand. + + In particular, a PulseChannel creates pulses of the form + + .. math:: + Re[\exp(i 2\pi f jdt + \phi) d_j]. + + The ``ShiftPhase`` instruction causes :math:`\phi` to be increased by the instruction's + ``phase`` operand. This will affect all pulses following on the same channel. + + The qubit phase is tracked in software, enabling instantaneous, nearly error-free Z-rotations + by using a ShiftPhase to update the frame tracking the qubit state. + """ + + def __init__( + self, + phase: Union[complex, ParameterExpression], + channel: PulseChannel, + name: Optional[str] = None, + ): + """Instantiate a shift phase instruction, increasing the output signal phase on ``channel`` + by ``phase`` [radians]. + + Args: + phase: The rotation angle in radians. + channel: The channel this instruction operates on. + name: Display name for this instruction. + """ + super().__init__(operands=(phase, channel), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``channel`` is not type :class:`PulseChannel`. + """ + if not isinstance(self.channel, PulseChannel): + raise PulseError(f"Expected a pulse channel, got {self.channel} instead.") + + @property + def phase(self) -> Union[complex, ParameterExpression]: + """Return the rotation angle enacted by this instruction in radians.""" + return self.operands[0] + + @property + def channel(self) -> PulseChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> Tuple[PulseChannel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return 0 + + +class SetPhase(Instruction): + r"""The set phase instruction sets the phase of the proceeding pulses on that channel + to ``phase`` radians. + + In particular, a PulseChannel creates pulses of the form + + .. math:: + + Re[\exp(i 2\pi f jdt + \phi) d_j] + + The ``SetPhase`` instruction sets :math:`\phi` to the instruction's ``phase`` operand. + """ + + def __init__( + self, + phase: Union[complex, ParameterExpression], + channel: PulseChannel, + name: Optional[str] = None, + ): + """Instantiate a set phase instruction, setting the output signal phase on ``channel`` + to ``phase`` [radians]. + + Args: + phase: The rotation angle in radians. + channel: The channel this instruction operates on. + name: Display name for this instruction. + """ + super().__init__(operands=(phase, channel), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If the input ``channel`` is not type :class:`PulseChannel`. + """ + if not isinstance(self.channel, PulseChannel): + raise PulseError(f"Expected a pulse channel, got {self.channel} instead.") + + @property + def phase(self) -> Union[complex, ParameterExpression]: + """Return the rotation angle enacted by this instruction in radians.""" + return self.operands[0] + + @property + def channel(self) -> PulseChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> Tuple[PulseChannel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return 0 diff --git a/qiskit_dynamics/pulse/instructions/play.py b/qiskit_dynamics/pulse/instructions/play.py new file mode 100644 index 000000000..eeec925ad --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/play.py @@ -0,0 +1,97 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""An instruction to transmit a given pulse on a ``PulseChannel`` (i.e., those which support +transmitted pulses, such as ``DriveChannel``). +""" +from __future__ import annotations + +from qiskit.circuit import Parameter +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse.channels import PulseChannel +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.instructions.instruction import Instruction +from qiskit_dynamics.pulse.library.pulse import Pulse + + +class Play(Instruction): + """This instruction is responsible for applying a pulse on a channel. + + The pulse specifies the exact time dynamics of the output signal envelope for a limited + time. The output is modulated by a phase and frequency which are controlled by separate + instructions. The pulse duration must be fixed, and is implicitly given in terms of the + cycle time, dt, of the backend. + """ + + def __init__(self, pulse: Pulse, channel: PulseChannel, name: str | None = None): + """Create a new pulse instruction. + + Args: + pulse: A pulse waveform description, such as + :py:class:`~qiskit.pulse.library.Waveform`. + channel: The channel to which the pulse is applied. + name: Name of the instruction for display purposes. Defaults to ``pulse.name``. + """ + if name is None: + name = pulse.name + super().__init__(operands=(pulse, channel), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If pulse is not a Pulse type. + PulseError: If the input ``channel`` is not type :class:`PulseChannel`. + """ + if not isinstance(self.pulse, Pulse): + raise PulseError("The `pulse` argument to `Play` must be of type `library.Pulse`.") + + if not isinstance(self.channel, PulseChannel): + raise PulseError(f"Expected a pulse channel, got {self.channel} instead.") + + @property + def pulse(self) -> Pulse: + """A description of the samples that will be played.""" + return self.operands[0] + + @property + def channel(self) -> PulseChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on. + """ + return self.operands[1] + + @property + def channels(self) -> tuple[PulseChannel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int | ParameterExpression: + """Duration of this instruction.""" + return self.pulse.duration + + @property + def parameters(self) -> set[Parameter]: + """Parameters which determine the instruction behavior.""" + parameters: set[Parameter] = set() + + # Note that Pulse.parameters returns dict rather than set for convention. + # We need special handling for Play instruction. + for pulse_param_expr in self.pulse.parameters.values(): + if isinstance(pulse_param_expr, ParameterExpression): + parameters = parameters | pulse_param_expr.parameters + + if self.channel.is_parameterized(): + parameters = parameters | self.channel.parameters + + return parameters diff --git a/qiskit_dynamics/pulse/instructions/reference.py b/qiskit_dynamics/pulse/instructions/reference.py new file mode 100644 index 000000000..8dc1f68a0 --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/reference.py @@ -0,0 +1,98 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Reference instruction that is a placeholder for subroutine.""" +from __future__ import annotations + +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse.channels import Channel +from qiskit_dynamics.pulse.exceptions import PulseError, UnassignedReferenceError +from qiskit_dynamics.pulse.instructions import instruction + + +class Reference(instruction.Instruction): + """Pulse compiler directive that refers to a subroutine. + + If a pulse program uses the same subset of instructions multiple times, then + using the :class:`~.Reference` class may significantly reduce the memory footprint of + the program. This instruction only stores the set of strings to identify the subroutine. + + The actual pulse program can be stored in the :attr:`ScheduleBlock.references` of the + :class:`.ScheduleBlock` that this reference instruction belongs to. + + You can later assign schedules with the :meth:`ScheduleBlock.assign_references` method. + This allows you to build the main program without knowing the actual subroutine, + that is supplied at a later time. + """ + + # Delimiter for representing nested scope. + scope_delimiter = "::" + + # Delimiter for tuple keys. + key_delimiter = "," + + def __init__(self, name: str, *extra_keys: str): + """Create new reference. + + Args: + name: Name of subroutine. + extra_keys: Optional. A set of string keys that may be necessary to + refer to a particular subroutine. For example, when we use + "sx" as a name to refer to the subroutine of an sx pulse, + this name might be used among schedules for different qubits. + In this example, you may specify "q0" in the extra keys + to distinguish the sx schedule for qubit 0 from others. + The user can use an arbitrary number of extra string keys to + uniquely determine the subroutine. + """ + # Run validation + ref_keys = (name,) + tuple(extra_keys) + super().__init__(operands=ref_keys, name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: When a key is not a string. + PulseError: When a key in ``ref_keys`` contains the scope delimiter. + """ + for key in self.ref_keys: + if not isinstance(key, str): + raise PulseError(f"Keys must be strings. '{repr(key)}' is not a valid object.") + if self.scope_delimiter in key or self.key_delimiter in key: + raise PulseError( + f"'{self.scope_delimiter}' and '{self.key_delimiter}' are reserved. " + f"'{key}' is not a valid key string." + ) + + @property + def ref_keys(self) -> tuple[str, ...]: + """Returns unique key of the subroutine.""" + return self.operands + + @property + def duration(self) -> int | ParameterExpression: + """Duration of this instruction.""" + raise UnassignedReferenceError(f"Subroutine is not assigned to {self.ref_keys}.") + + @property + def channels(self) -> tuple[Channel, ...]: + """Returns the channels that this schedule uses.""" + raise UnassignedReferenceError(f"Subroutine is not assigned to {self.ref_keys}.") + + @property + def parameters(self) -> set: + """Parameters which determine the instruction behavior.""" + return set() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.key_delimiter.join(self.ref_keys)})" diff --git a/qiskit_dynamics/pulse/instructions/snapshot.py b/qiskit_dynamics/pulse/instructions/snapshot.py new file mode 100644 index 000000000..d29668627 --- /dev/null +++ b/qiskit_dynamics/pulse/instructions/snapshot.py @@ -0,0 +1,80 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +"""A simulator instruction to capture output within a simulation. The types of snapshot +instructions available are determined by the simulator being used. +""" +from typing import Optional, Tuple + +from qiskit_dynamics.pulse.channels import SnapshotChannel +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.instructions.instruction import Instruction + + +class Snapshot(Instruction): + """An instruction targeted for simulators, to capture a moment in the simulation.""" + + def __init__(self, label: str, snapshot_type: str = "statevector", name: Optional[str] = None): + """Create new snapshot. + + Args: + label: Snapshot label which is used to identify the snapshot in the output. + snapshot_type: Type of snapshot, e.g., “state” (take a snapshot of the quantum state). + The types of snapshots offered are defined by the simulator used. + name: Snapshot name which defaults to ``label``. This parameter is only for display + purposes and is not taken into account during comparison. + """ + self._channel = SnapshotChannel() + + if name is None: + name = label + super().__init__(operands=(label, snapshot_type), name=name) + + def _validate(self): + """Called after initialization to validate instruction data. + + Raises: + PulseError: If snapshot label is invalid. + """ + if not isinstance(self.label, str): + raise PulseError("Snapshot label must be a string.") + + @property + def label(self) -> str: + """Label of snapshot.""" + return self.operands[0] + + @property + def type(self) -> str: + """Type of snapshot.""" + return self.operands[1] + + @property + def channel(self) -> SnapshotChannel: + """Return the :py:class:`~qiskit.pulse.channels.Channel` that this instruction is + scheduled on; trivially, a ``SnapshotChannel``. + """ + return self._channel + + @property + def channels(self) -> Tuple[SnapshotChannel]: + """Returns the channels that this schedule uses.""" + return (self.channel,) + + @property + def duration(self) -> int: + """Duration of this instruction.""" + return 0 + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return False diff --git a/qiskit_dynamics/pulse/library/__init__.py b/qiskit_dynamics/pulse/library/__init__.py new file mode 100644 index 000000000..99d382e63 --- /dev/null +++ b/qiskit_dynamics/pulse/library/__init__.py @@ -0,0 +1,97 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +""" +=========================================== +Pulse Library (:mod:`qiskit.pulse.library`) +=========================================== + +This library provides Pulse users with convenient methods to build Pulse waveforms. + +A pulse programmer can choose from one of several :ref:`pulse_models` such as +:class:`~Waveform` and :class:`~SymbolicPulse` to create a pulse program. +The :class:`~Waveform` model directly stores the waveform data points in each class instance. +This model provides the most flexibility to express arbitrary waveforms and allows +a rapid prototyping of new control techniques. However, this model is typically memory +inefficient and might be hard to scale to large-size quantum processors. +A user can directly instantiate the :class:`~Waveform` class with ``samples`` argument +which is usually a complex numpy array or any kind of array-like data. + +In contrast, the :class:`~SymbolicPulse` model only stores the function and its parameters +that generate the waveform in a class instance. +It thus provides greater memory efficiency at the price of less flexibility in the waveform. +This model also defines a small set of pulse subclasses in :ref:`symbolic_pulses` +which are commonly used in superconducting quantum processors. +An instance of these subclasses can be serialized in the :ref:`qpy_format` +while keeping the memory-efficient parametric representation of waveforms. +Note that :class:`~Waveform` object can be generated from an instance of +a :class:`~SymbolicPulse` which will set values for the parameters and +sample the parametric expression to create the :class:`~Waveform`. + + +.. _pulse_models: + +Pulse Models +============ + +.. autosummary:: + :toctree: ../stubs/ + + Waveform + SymbolicPulse + + +.. _symbolic_pulses: + +Parametric Pulse Representation +=============================== + +.. autosummary:: + :toctree: ../stubs/ + + Constant + Drag + Gaussian + GaussianSquare + GaussianSquareDrag + gaussian_square_echo + GaussianDeriv + Sin + Cos + Sawtooth + Triangle + Square + Sech + SechDeriv + +""" + +from .symbolic_pulses import ( + SymbolicPulse, + ScalableSymbolicPulse, + Gaussian, + GaussianSquare, + GaussianSquareDrag, + gaussian_square_echo, + GaussianDeriv, + Drag, + Constant, + Sin, + Cos, + Sawtooth, + Triangle, + Square, + Sech, + SechDeriv, +) +from .pulse import Pulse +from .waveform import Waveform diff --git a/qiskit_dynamics/pulse/library/continuous.py b/qiskit_dynamics/pulse/library/continuous.py new file mode 100644 index 000000000..beaad0cb4 --- /dev/null +++ b/qiskit_dynamics/pulse/library/continuous.py @@ -0,0 +1,430 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +# pylint: disable=invalid-unary-operand-type + +"""Module for builtin continuous pulse functions.""" +from __future__ import annotations + +import functools + +import numpy as np +from qiskit_dynamics.pulse.exceptions import PulseError + + +def constant(times: np.ndarray, amp: complex) -> np.ndarray: + """Continuous constant pulse. + + Args: + times: Times to output pulse for. + amp: Complex pulse amplitude. + """ + return np.full(len(times), amp, dtype=np.complex128) + + +def zero(times: np.ndarray) -> np.ndarray: + """Continuous zero pulse. + + Args: + times: Times to output pulse for. + """ + return constant(times, 0) + + +def square(times: np.ndarray, amp: complex, freq: float, phase: float = 0) -> np.ndarray: + """Continuous square wave. + + Args: + times: Times to output wave for. + amp: Pulse amplitude. Wave range is [-amp, amp]. + freq: Pulse frequency. units of 1/dt. + phase: Pulse phase. + """ + x = times * freq + phase / np.pi + return amp * (2 * (2 * np.floor(x) - np.floor(2 * x)) + 1).astype(np.complex128) + + +def sawtooth(times: np.ndarray, amp: complex, freq: float, phase: float = 0) -> np.ndarray: + """Continuous sawtooth wave. + + Args: + times: Times to output wave for. + amp: Pulse amplitude. Wave range is [-amp, amp]. + freq: Pulse frequency. units of 1/dt. + phase: Pulse phase. + """ + x = times * freq + phase / np.pi + return amp * 2 * (x - np.floor(1 / 2 + x)).astype(np.complex128) + + +def triangle(times: np.ndarray, amp: complex, freq: float, phase: float = 0) -> np.ndarray: + """Continuous triangle wave. + + Args: + times: Times to output wave for. + amp: Pulse amplitude. Wave range is [-amp, amp]. + freq: Pulse frequency. units of 1/dt. + phase: Pulse phase. + """ + return amp * (-2 * np.abs(sawtooth(times, 1, freq, phase=(phase - np.pi / 2) / 2)) + 1).astype( + np.complex128 + ) + + +def cos(times: np.ndarray, amp: complex, freq: float, phase: float = 0) -> np.ndarray: + """Continuous cosine wave. + + Args: + times: Times to output wave for. + amp: Pulse amplitude. + freq: Pulse frequency, units of 1/dt. + phase: Pulse phase. + """ + return amp * np.cos(2 * np.pi * freq * times + phase).astype(np.complex128) + + +def sin(times: np.ndarray, amp: complex, freq: float, phase: float = 0) -> np.ndarray: + """Continuous cosine wave. + + Args: + times: Times to output wave for. + amp: Pulse amplitude. + freq: Pulse frequency, units of 1/dt. + phase: Pulse phase. + """ + return amp * np.sin(2 * np.pi * freq * times + phase).astype(np.complex128) + + +def _fix_gaussian_width( + gaussian_samples: np.ndarray, + amp: complex, + center: float, + sigma: float, + zeroed_width: float | None = None, + rescale_amp: bool = False, + ret_scale_factor: bool = False, +) -> np.ndarray | tuple[np.ndarray, float]: + r"""Enforce that the supplied gaussian pulse is zeroed at a specific width. + + This is achieved by subtracting $\Omega_g(center \pm zeroed_width/2)$ from all samples. + + amp: Pulse amplitude at `center`. + center: Center (mean) of pulse. + sigma: Standard deviation of pulse. + zeroed_width: Subtract baseline from gaussian pulses to make sure + $\Omega_g(center \pm zeroed_width/2)=0$ is satisfied. This is used to avoid + large discontinuities at the start of a gaussian pulse. If unsupplied, + defaults to $2*(center + 1)$ such that $\Omega_g(-1)=0$ and $\Omega_g(2*(center + 1))=0$. + rescale_amp: If True the pulse will be rescaled so that $\Omega_g(center)=amp$. + ret_scale_factor: Return amplitude scale factor. + """ + if zeroed_width is None: + zeroed_width = 2 * (center + 1) + + zero_offset = gaussian(np.array([zeroed_width / 2]), amp, 0, sigma) + gaussian_samples -= zero_offset + amp_scale_factor: complex | float | np.ndarray = 1.0 + if rescale_amp: + amp_scale_factor = amp / (amp - zero_offset) if amp - zero_offset != 0 else 1.0 + gaussian_samples *= amp_scale_factor + + if ret_scale_factor: + return gaussian_samples, amp_scale_factor + return gaussian_samples + + +def gaussian( + times: np.ndarray, + amp: complex, + center: float, + sigma: float, + zeroed_width: float | None = None, + rescale_amp: bool = False, + ret_x: bool = False, +) -> np.ndarray | tuple[np.ndarray, np.ndarray]: + r"""Continuous unnormalized gaussian pulse. + + Integrated area under curve is $\Omega_g(amp, sigma) = amp \times np.sqrt(2\pi \sigma^2)$ + + Args: + times: Times to output pulse for. + amp: Pulse amplitude at `center`. If `zeroed_width` is set pulse amplitude at center + will be $amp-\Omega_g(center \pm zeroed_width/2)$ unless `rescale_amp` is set, + in which case all samples will be rescaled such that the center + amplitude will be `amp`. + center: Center (mean) of pulse. + sigma: Width (standard deviation) of pulse. + zeroed_width: Subtract baseline from gaussian pulses to make sure + $\Omega_g(center \pm zeroed_width/2)=0$ is satisfied. This is used to avoid + large discontinuities at the start of a gaussian pulse. + rescale_amp: If `zeroed_width` is not `None` and `rescale_amp=True` the pulse will + be rescaled so that $\Omega_g(center)=amp$. + ret_x: Return centered and standard deviation normalized pulse location. + $x=(times-center)/sigma. + """ + times = np.asarray(times, dtype=np.complex128) + x = (times - center) / sigma + gauss = amp * np.exp(-(x**2) / 2).astype(np.complex128) + + if zeroed_width is not None: + gauss = _fix_gaussian_width( + gauss, + amp=amp, + center=center, + sigma=sigma, + zeroed_width=zeroed_width, + rescale_amp=rescale_amp, + ) + + if ret_x: + return gauss, x + return gauss + + +def gaussian_deriv( + times: np.ndarray, + amp: complex, + center: float, + sigma: float, + ret_gaussian: bool = False, + zeroed_width: float | None = None, + rescale_amp: bool = False, +) -> np.ndarray | tuple[np.ndarray, np.ndarray]: + r"""Continuous unnormalized gaussian derivative pulse. + + Args: + times: Times to output pulse for. + amp: Pulse amplitude at `center`. + center: Center (mean) of pulse. + sigma: Width (standard deviation) of pulse. + ret_gaussian: Return gaussian with which derivative was taken with. + zeroed_width: Subtract baseline of pulse to make sure + $\Omega_g(center \pm zeroed_width/2)=0$ is satisfied. This is used to avoid + large discontinuities at the start of a pulse. + rescale_amp: If `zeroed_width` is not `None` and `rescale_amp=True` the pulse will + be rescaled so that $\Omega_g(center)=amp$. + """ + gauss, x = gaussian( + times, + amp=amp, + center=center, + sigma=sigma, + zeroed_width=zeroed_width, + rescale_amp=rescale_amp, + ret_x=True, + ) + gauss_deriv = -x / sigma * gauss # Note that x is shifted and normalized by sigma + if ret_gaussian: + return gauss_deriv, gauss + return gauss_deriv + + +def _fix_sech_width( + sech_samples: np.ndarray, + amp: complex, + center: float, + sigma: float, + zeroed_width: float | None = None, + rescale_amp: bool = False, + ret_scale_factor: bool = False, +) -> np.ndarray | tuple[np.ndarray, float]: + r"""Enforce that the supplied sech pulse is zeroed at a specific width. + + This is achieved by subtracting $\Omega_g(center \pm zeroed_width/2)$ from all samples. + + amp: Pulse amplitude at `center`. + center: Center (mean) of pulse. + sigma: Standard deviation of pulse. + zeroed_width: Subtract baseline from sech pulses to make sure + $\Omega_g(center \pm zeroed_width/2)=0$ is satisfied. This is used to avoid + large discontinuities at the start of a sech pulse. If unsupplied, + defaults to $2*(center + 1)$ such that $\Omega_g(-1)=0$ and $\Omega_g(2*(center + 1))=0$. + rescale_amp: If True the pulse will be rescaled so that $\Omega_g(center)=amp$. + ret_scale_factor: Return amplitude scale factor. + """ + if zeroed_width is None: + zeroed_width = 2 * (center + 1) + + zero_offset = sech(np.array([zeroed_width / 2]), amp, 0, sigma) + sech_samples -= zero_offset + amp_scale_factor: complex | float | np.ndarray = 1.0 + if rescale_amp: + amp_scale_factor = amp / (amp - zero_offset) if amp - zero_offset != 0 else 1.0 + sech_samples *= amp_scale_factor + + if ret_scale_factor: + return sech_samples, amp_scale_factor + return sech_samples + + +def sech_fn(x, *args, **kwargs): + r"""Hyperbolic secant function""" + return 1.0 / np.cosh(x, *args, **kwargs) + + +def sech( + times: np.ndarray, + amp: complex, + center: float, + sigma: float, + zeroed_width: float | None = None, + rescale_amp: bool = False, + ret_x: bool = False, +) -> np.ndarray | tuple[np.ndarray, np.ndarray]: + r"""Continuous unnormalized sech pulse. + + Args: + times: Times to output pulse for. + amp: Pulse amplitude at `center`. + center: Center (mean) of pulse. + sigma: Width (standard deviation) of pulse. + zeroed_width: Subtract baseline from pulse to make sure + $\Omega_g(center \pm zeroed_width/2)=0$ is satisfied. This is used to avoid + large discontinuities at the start and end of the pulse. + rescale_amp: If `zeroed_width` is not `None` and `rescale_amp=True` the pulse will + be rescaled so that $\Omega_g(center)=amp$. + ret_x: Return centered and standard deviation normalized pulse location. + $x=(times-center)/sigma$. + """ + times = np.asarray(times, dtype=np.complex128) + x = (times - center) / sigma + sech_out = amp * sech_fn(x).astype(np.complex128) + + if zeroed_width is not None: + sech_out = _fix_sech_width( + sech_out, + amp=amp, + center=center, + sigma=sigma, + zeroed_width=zeroed_width, + rescale_amp=rescale_amp, + ) + + if ret_x: + return sech_out, x + return sech_out + + +def sech_deriv( + times: np.ndarray, amp: complex, center: float, sigma: float, ret_sech: bool = False +) -> np.ndarray | tuple[np.ndarray, np.ndarray]: + """Continuous unnormalized sech derivative pulse. + + Args: + times: Times to output pulse for. + amp: Pulse amplitude at `center`. + center: Center (mean) of pulse. + sigma: Width (standard deviation) of pulse. + ret_sech: Return sech with which derivative was taken with. + """ + sech_out, x = sech(times, amp=amp, center=center, sigma=sigma, ret_x=True) + sech_out_deriv = -sech_out * np.tanh(x) / sigma + if ret_sech: + return sech_out_deriv, sech_out + return sech_out_deriv + + +def gaussian_square( + times: np.ndarray, + amp: complex, + center: float, + square_width: float, + sigma: float, + zeroed_width: float | None = None, +) -> np.ndarray: + r"""Continuous gaussian square pulse. + + Args: + times: Times to output pulse for. + amp: Pulse amplitude. + center: Center of the square pulse component. + square_width: Width of the square pulse component. + sigma: Standard deviation of Gaussian rise/fall portion of the pulse. + zeroed_width: Subtract baseline of gaussian square pulse + to enforce $\OmegaSquare(center \pm zeroed_width/2)=0$. + + Raises: + PulseError: if zeroed_width is not compatible with square_width. + """ + square_start = center - square_width / 2 + square_stop = center + square_width / 2 + if zeroed_width: + if zeroed_width < square_width: + raise PulseError("zeroed_width cannot be smaller than square_width.") + gaussian_zeroed_width = zeroed_width - square_width + else: + gaussian_zeroed_width = None + + funclist = [ + functools.partial( + gaussian, + amp=amp, + center=square_start, + sigma=sigma, + zeroed_width=gaussian_zeroed_width, + rescale_amp=True, + ), + functools.partial( + gaussian, + amp=amp, + center=square_stop, + sigma=sigma, + zeroed_width=gaussian_zeroed_width, + rescale_amp=True, + ), + functools.partial(constant, amp=amp), + ] + condlist = [times <= square_start, times >= square_stop] + return np.piecewise(times.astype(np.complex128), condlist, funclist) + + +def drag( + times: np.ndarray, + amp: complex, + center: float, + sigma: float, + beta: float, + zeroed_width: float | None = None, + rescale_amp: bool = False, +) -> np.ndarray: + r"""Continuous Y-only correction DRAG pulse for standard nonlinear oscillator (SNO) [1]. + + [1] Gambetta, J. M., Motzoi, F., Merkel, S. T. & Wilhelm, F. K. + Analytic control methods for high-fidelity unitary operations + in a weakly nonlinear oscillator. Phys. Rev. A 83, 012308 (2011). + + Args: + times: Times to output pulse for. + amp: Pulse amplitude at `center`. + center: Center (mean) of pulse. + sigma: Width (standard deviation) of pulse. + beta: Y correction amplitude. For the SNO this is $\beta=-\frac{\lambda_1^2}{4\Delta_2}$. + Where $\lambds_1$ is the relative coupling strength between the first excited and second + excited states and $\Delta_2$ is the detuning between the respective excited states. + zeroed_width: Subtract baseline of drag pulse to make sure + $\Omega_g(center \pm zeroed_width/2)=0$ is satisfied. This is used to avoid + large discontinuities at the start of a drag pulse. + rescale_amp: If `zeroed_width` is not `None` and `rescale_amp=True` the pulse will + be rescaled so that $\Omega_g(center)=amp$. + + """ + gauss_deriv, gauss = gaussian_deriv( + times, + amp=amp, + center=center, + sigma=sigma, + ret_gaussian=True, + zeroed_width=zeroed_width, + rescale_amp=rescale_amp, + ) + + return gauss + 1j * beta * gauss_deriv diff --git a/qiskit_dynamics/pulse/library/pulse.py b/qiskit_dynamics/pulse/library/pulse.py new file mode 100644 index 000000000..3cbced2e8 --- /dev/null +++ b/qiskit_dynamics/pulse/library/pulse.py @@ -0,0 +1,146 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Pulses are descriptions of waveform envelopes. They can be transmitted by control electronics +to the device. +""" +from __future__ import annotations + +import typing +from abc import ABC, abstractmethod +from typing import Any + +from qiskit.circuit.parameterexpression import ParameterExpression + + +if typing.TYPE_CHECKING: + from qiskit.providers import Backend # pylint: disable=cyclic-import + + +class Pulse(ABC): + """The abstract superclass for pulses. Pulses are complex-valued waveform envelopes. The + modulation phase and frequency are specified separately from ``Pulse``s. + """ + + __slots__ = ("duration", "name", "_limit_amplitude") + + limit_amplitude = True + + @abstractmethod + def __init__( + self, + duration: int | ParameterExpression, + name: str | None = None, + limit_amplitude: bool | None = None, + ): + """Abstract base class for pulses + Args: + duration: Duration of the pulse + name: Optional name for the pulse + limit_amplitude: If ``True``, then limit the amplitude of the waveform to 1. + The default value of ``None`` causes the flag value to be + derived from :py:attr:`~limit_amplitude` which is ``True`` + by default but may be set by the user to disable amplitude + checks globally. + """ + if limit_amplitude is None: + limit_amplitude = self.__class__.limit_amplitude + + self.duration = duration + self.name = name + self._limit_amplitude = limit_amplitude + + @property + def id(self) -> int: # pylint: disable=invalid-name + """Unique identifier for this pulse.""" + return id(self) + + @property + @abstractmethod + def parameters(self) -> dict[str, typing.Any]: + """Return a dictionary containing the pulse's parameters.""" + pass + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + raise NotImplementedError + + def draw( + self, + style: dict[str, Any] | None = None, + backend: Backend | None = None, + time_range: tuple[int, int] | None = None, + time_unit: str = "dt", + show_waveform_info: bool = True, + plotter: str = "mpl2d", + axis: Any | None = None, + ): + """Plot the interpolated envelope of pulse. + + Args: + style: Stylesheet options. This can be dictionary or preset stylesheet classes. See + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXStandard`, + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXSimple`, and + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXDebugging` for details of + preset stylesheets. + backend (Optional[BaseBackend]): Backend object to play the input pulse program. + If provided, the plotter may use to make the visualization hardware aware. + time_range: Set horizontal axis limit. Tuple ``(tmin, tmax)``. + time_unit: The unit of specified time range either ``dt`` or ``ns``. + The unit of ``ns`` is available only when ``backend`` object is provided. + show_waveform_info: Show waveform annotations, i.e. name, of waveforms. + Set ``True`` to show additional information about waveforms. + plotter: Name of plotter API to generate an output image. + One of following APIs should be specified:: + + mpl2d: Matplotlib API for 2D image generation. + Matplotlib API to generate 2D image. Charts are placed along y axis with + vertical offset. This API takes matplotlib.axes.Axes as `axis` input. + + `axis` and `style` kwargs may depend on the plotter. + axis: Arbitrary object passed to the plotter. If this object is provided, + the plotters use a given ``axis`` instead of internally initializing + a figure object. This object format depends on the plotter. + See plotter argument for details. + + Returns: + Visualization output data. + The returned data type depends on the ``plotter``. + If matplotlib family is specified, this will be a ``matplotlib.pyplot.Figure`` data. + """ + # pylint: disable=cyclic-import + from qiskit_dynamics.pulse.visualization import draw as pulse_drawer + + return pulse_drawer( + program=self, + style=style, + backend=backend, + time_range=time_range, + time_unit=time_unit, + show_waveform_info=show_waveform_info, + plotter=plotter, + axis=axis, + ) + + @abstractmethod + def __eq__(self, other: object) -> bool: + if not isinstance(other, Pulse): + return NotImplemented + return isinstance(other, type(self)) + + @abstractmethod + def __hash__(self) -> int: + raise NotImplementedError + + @abstractmethod + def __repr__(self) -> str: + raise NotImplementedError diff --git a/qiskit_dynamics/pulse/library/samplers/__init__.py b/qiskit_dynamics/pulse/library/samplers/__init__.py new file mode 100644 index 000000000..ea5e2dd5d --- /dev/null +++ b/qiskit_dynamics/pulse/library/samplers/__init__.py @@ -0,0 +1,15 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +"""Module for methods which sample continuous functions.""" + +from .decorators import left, right, midpoint diff --git a/qiskit_dynamics/pulse/library/samplers/decorators.py b/qiskit_dynamics/pulse/library/samplers/decorators.py new file mode 100644 index 000000000..db6aabd7b --- /dev/null +++ b/qiskit_dynamics/pulse/library/samplers/decorators.py @@ -0,0 +1,295 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + +"""Sampler decorator module for sampling of continuous pulses to discrete pulses to be +exposed to user. + +Some atypical boilerplate has been added to solve the problem of decorators not preserving +their wrapped function signatures. Below we explain the problem that samplers solve and how +we implement this. + +A sampler is a function that takes an continuous pulse function with signature: + ```python + def f(times: np.ndarray, *args, **kwargs) -> np.ndarray: + ... + ``` +and returns a new function: + def f(duration: int, *args, **kwargs) -> Waveform: + ... + +Samplers are used to build up pulse waveforms from continuous pulse functions. + +In Python the creation of a dynamic function that wraps another function will cause +the underlying signature and documentation of the underlying function to be overwritten. +In order to circumvent this issue the Python standard library provides the decorator +`functools.wraps` which allows the programmer to expose the names and signature of the +wrapped function as those of the dynamic function. + +Samplers are implemented by creating a function with signature + @sampler + def left(continuous_pulse: Callable, duration: int, *args, **kwargs) + ... + +This will create a sampler function for `left`. Since it is a dynamic function it would not +have the docstring of `left` available too `help`. This could be fixed by wrapping with +`functools.wraps` in the `sampler`, but this would then cause the signature to be that of the +sampler function which is called on the continuous pulse, below: + `(continuous_pulse: Callable, duration: int, *args, **kwargs)`` +This is not correct for the sampler as the output sampled functions accept only a function. +For the standard sampler we get around this by not using `functools.wraps` and +explicitly defining our samplers such as `left`, `right` and `midpoint` and +calling `sampler` internally on the function that implements the sampling schemes such as +`left_sample`, `right_sample` and `midpoint_sample` respectively. See `left` for an example of this. + + +In this way our standard samplers will expose the proper help signature, but a user can +still create their own sampler with + @sampler + def custom_sampler(time, *args, **kwargs): + ... +However, in this case it will be missing documentation of the underlying sampling methods. +We believe that the definition of custom samplers will be rather infrequent. + +However, users will frequently apply sampler instances too continuous pulses. Therefore, a different +approach was required for sampled continuous functions (the output of an continuous pulse function +decorated by a sampler instance). + +A sampler instance is a decorator that may be used to wrap continuous pulse functions such as +linear below: +```python + @left + def linear(times: np.ndarray, m: float, b: float) -> np.ndarray: + ```Linear test function + Args: + times: Input times. + m: Slope. + b: Intercept + Returns: + np.ndarray + ``` + return m*times+b +``` +Which after decoration may be called with a duration rather than an array of times + ```python + duration = 10 + pulse_envelope = linear(10, 0.1, 0.1) + ``` +If one calls help on `linear` they will find + ``` + linear(duration:int, *args, **kwargs) -> numpy.ndarray + Discretized continuous pulse function: `linear` using + sampler: `_left`. + + The first argument (time) of the continuous pulse function has been replaced with + a discretized `duration` of type (int). + + Args: + duration (int) + *args: Remaining arguments of continuous pulse function. + See continuous pulse function documentation below. + **kwargs: Remaining kwargs of continuous pulse function. + See continuous pulse function documentation below. + + Sampled continuous function: + + function linear in module test.python.pulse.test_samplers + linear(x:numpy.ndarray, m:float, b:float) -> numpy.ndarray + Linear test function + Args: + x: Input times. + m: Slope. + b: Intercept + Returns: + np.ndarray + ``` +This is partly because `functools.wraps` has been used on the underlying function. +This in itself is not sufficient as the signature of the sampled function has +`duration`, whereas the signature of the continuous function is `time`. + +This is achieved by removing `__wrapped__` set by `functools.wraps` in order to preserve +the correct signature and also applying `_update_annotations` and `_update_docstring` +to the generated function which corrects the function annotations and adds an informative +docstring respectively. + +The user therefore has access to the correct sampled function docstring in its entirety, while +still seeing the signature for the continuous pulse function and all of its arguments. +""" +from __future__ import annotations +import functools +import textwrap +import pydoc +from collections.abc import Callable + +import numpy as np + +from ...exceptions import PulseError +from ..waveform import Waveform +from . import strategies + + +def functional_pulse(func: Callable) -> Callable: + """A decorator for generating Waveform from python callable. + + Args: + func: A function describing pulse envelope. + + Raises: + PulseError: when invalid function is specified. + """ + + @functools.wraps(func) + def to_pulse(duration, *args, name=None, **kwargs): + """Return Waveform.""" + if isinstance(duration, (int, np.integer)) and duration > 0: + samples = func(duration, *args, **kwargs) + samples = np.asarray(samples, dtype=np.complex128) + return Waveform(samples=samples, name=name) + raise PulseError("The first argument must be an integer value representing duration.") + + return to_pulse + + +def _update_annotations(discretized_pulse: Callable) -> Callable: + """Update annotations of discretized continuous pulse function with duration. + + Args: + discretized_pulse: Discretized decorated continuous pulse. + """ + undecorated_annotations = list(discretized_pulse.__annotations__.items()) + decorated_annotations = undecorated_annotations[1:] + decorated_annotations.insert(0, ("duration", int)) + discretized_pulse.__annotations__ = dict(decorated_annotations) + return discretized_pulse + + +def _update_docstring(discretized_pulse: Callable, sampler_inst: Callable) -> Callable: + """Update annotations of discretized continuous pulse function. + + Args: + discretized_pulse: Discretized decorated continuous pulse. + sampler_inst: Applied sampler. + """ + wrapped_docstring = pydoc.render_doc(discretized_pulse, "%s") + header, body = wrapped_docstring.split("\n", 1) + body = textwrap.indent(body, " ") + wrapped_docstring = header + body + updated_ds = f""" + Discretized continuous pulse function: `{discretized_pulse.__name__}` using + sampler: `{sampler_inst.__name__}`. + + The first argument (time) of the continuous pulse function has been replaced with + a discretized `duration` of type (int). + + Args: + duration (int) + *args: Remaining arguments of continuous pulse function. + See continuous pulse function documentation below. + **kwargs: Remaining kwargs of continuous pulse function. + See continuous pulse function documentation below. + + Sampled continuous function: + + {wrapped_docstring} + """ + + discretized_pulse.__doc__ = updated_ds + return discretized_pulse + + +def sampler(sample_function: Callable) -> Callable: + """Sampler decorator base method. + + Samplers are used for converting an continuous function to a discretized pulse. + + They operate on a function with the signature: + `def f(times: np.ndarray, *args, **kwargs) -> np.ndarray` + Where `times` is a numpy array of floats with length n_times and the output array + is a complex numpy array with length n_times. The output of the decorator is an + instance of `FunctionalPulse` with signature: + `def g(duration: int, *args, **kwargs) -> Waveform` + + Note if your continuous pulse function outputs a `complex` scalar rather than a + `np.ndarray`, you should first vectorize it before applying a sampler. + + + This class implements the sampler boilerplate for the sampler. + + Args: + sample_function: A sampler function to be decorated. + """ + + def generate_sampler(continuous_pulse: Callable) -> Callable: + """Return a decorated sampler function.""" + + @functools.wraps(continuous_pulse) + def call_sampler(duration: int, *args, **kwargs) -> np.ndarray: + """Replace the call to the continuous function with a call to the sampler applied + to the analytic pulse function.""" + sampled_pulse = sample_function(continuous_pulse, duration, *args, **kwargs) + return np.asarray(sampled_pulse, dtype=np.complex128) + + # Update type annotations for wrapped continuous function to be discrete + call_sampler = _update_annotations(call_sampler) + # Update docstring with that of the sampler and include sampled function documentation. + call_sampler = _update_docstring(call_sampler, sample_function) + # Unset wrapped to return base sampler signature + # but still get rest of benefits of wraps + # such as __name__, __qualname__ + call_sampler.__dict__.pop("__wrapped__") + # wrap with functional pulse + return functional_pulse(call_sampler) + + return generate_sampler + + +def left(continuous_pulse: Callable) -> Callable: + r"""Left sampling strategy decorator. + + See `pulse.samplers.sampler` for more information. + + For `duration`, return: + $$\{f(t) \in \mathbb{C} | t \in \mathbb{Z} \wedge 0<=t<\texttt{duration}\}$$ + + Args: + continuous_pulse: To sample. + """ + + return sampler(strategies.left_sample)(continuous_pulse) + + +def right(continuous_pulse: Callable) -> Callable: + r"""Right sampling strategy decorator. + + See `pulse.samplers.sampler` for more information. + + For `duration`, return: + $$\{f(t) \in \mathbb{C} | t \in \mathbb{Z} \wedge 0 Callable: + r"""Midpoint sampling strategy decorator. + + See `pulse.samplers.sampler` for more information. + + For `duration`, return: + $$\{f(t+0.5) \in \mathbb{C} | t \in \mathbb{Z} \wedge 0<=t<\texttt{duration}\}$$ + + Args: + continuous_pulse: To sample. + """ + return sampler(strategies.midpoint_sample)(continuous_pulse) diff --git a/qiskit_dynamics/pulse/library/samplers/strategies.py b/qiskit_dynamics/pulse/library/samplers/strategies.py new file mode 100644 index 000000000..c0886138d --- /dev/null +++ b/qiskit_dynamics/pulse/library/samplers/strategies.py @@ -0,0 +1,71 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + + +"""Sampler strategy module for sampler functions. + +Sampler functions have signature. + ```python + def sampler_function(continuous_pulse: Callable, duration: int, *args, **kwargs) -> np.ndarray: + ... + ``` +where the supplied `continuous_pulse` is a function with signature: + ```python + def f(times: np.ndarray, *args, **kwargs) -> np.ndarray: + ... + ``` +The sampler will call the `continuous_pulse` function with a set of times it will decide +according to the sampling strategy it implements along with the passed `args` and `kwargs`. +""" + +from typing import Callable + +import numpy as np + + +def left_sample(continuous_pulse: Callable, duration: int, *args, **kwargs) -> np.ndarray: + """Left sample a continuous function. + + Args: + continuous_pulse: Continuous pulse function to sample. + duration: Duration to sample for. + *args: Continuous pulse function args. + **kwargs: Continuous pulse function kwargs. + """ + times = np.arange(duration) + return continuous_pulse(times, *args, **kwargs) + + +def right_sample(continuous_pulse: Callable, duration: int, *args, **kwargs) -> np.ndarray: + """Sampling strategy for decorator. + + Args: + continuous_pulse: Continuous pulse function to sample. + duration: Duration to sample for. + *args: Continuous pulse function args. + **kwargs: Continuous pulse function kwargs. + """ + times = np.arange(1, duration + 1) + return continuous_pulse(times, *args, **kwargs) + + +def midpoint_sample(continuous_pulse: Callable, duration: int, *args, **kwargs) -> np.ndarray: + """Sampling strategy for decorator. + + Args: + continuous_pulse: Continuous pulse function to sample. + duration: Duration to sample for. + *args: Continuous pulse function args. + **kwargs: Continuous pulse function kwargs. + """ + times = np.arange(1 / 2, duration + 1 / 2) + return continuous_pulse(times, *args, **kwargs) diff --git a/qiskit_dynamics/pulse/library/symbolic_pulses.py b/qiskit_dynamics/pulse/library/symbolic_pulses.py new file mode 100644 index 000000000..e1fd2198c --- /dev/null +++ b/qiskit_dynamics/pulse/library/symbolic_pulses.py @@ -0,0 +1,1960 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=invalid-name + +"""Symbolic waveform module. + +These are pulses which are described by symbolic equations for their envelopes and for their +parameter constraints. +""" +from __future__ import annotations +import functools +import warnings +from collections.abc import Mapping, Callable +from copy import deepcopy +from typing import Any + +import numpy as np +import symengine as sym + +from qiskit.circuit.parameterexpression import ParameterExpression, ParameterValueType +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.library.pulse import Pulse +from qiskit_dynamics.pulse.library.waveform import Waveform + + +def _lifted_gaussian( + t: sym.Symbol, + center: sym.Symbol | sym.Expr | complex, + t_zero: sym.Symbol | sym.Expr | complex, + sigma: sym.Symbol | sym.Expr | complex, +) -> sym.Expr: + r"""Helper function that returns a lifted Gaussian symbolic equation. + + For :math:`\sigma=` ``sigma`` the symbolic equation will be + + .. math:: + + f(x) = \exp\left(-\frac12 \left(\frac{x - \mu}{\sigma}\right)^2 \right), + + with the center :math:`\mu=` ``duration/2``. + Then, each output sample :math:`y` is modified according to: + + .. math:: + + y \mapsto \frac{y-y^*}{1.0-y^*}, + + where :math:`y^*` is the value of the un-normalized Gaussian at the endpoints of the pulse. + This sets the endpoints to :math:`0` while preserving the amplitude at the center, + i.e. :math:`y` is set to :math:`1.0`. + + Args: + t: Symbol object representing time. + center: Symbol or expression representing the middle point of the samples. + t_zero: The value of t at which the pulse is lowered to 0. + sigma: Symbol or expression representing Gaussian sigma. + + Returns: + Symbolic equation. + """ + # Sympy automatically does expand. + # This causes expression inconsistency after qpy round-trip serializing through sympy. + # See issue for details: https://github.com/symengine/symengine.py/issues/409 + t_shifted = (t - center).expand() + t_offset = (t_zero - center).expand() + + gauss = sym.exp(-((t_shifted / sigma) ** 2) / 2) + offset = sym.exp(-((t_offset / sigma) ** 2) / 2) + + return (gauss - offset) / (1 - offset) + + +@functools.lru_cache(maxsize=None) +def _is_amplitude_valid( + envelope_lam: Callable, time: tuple[float, ...], *fargs: float +) -> bool | np.bool_: + """A helper function to validate maximum amplitude limit. + + Result is cached for better performance. + + Args: + envelope_lam: The SymbolicPulse's lambdified envelope_lam expression. + time: The SymbolicPulse's time array, given as a tuple for hashability. + fargs: The arguments for the lambdified envelope_lam, as given by `_get_expression_args`, + except for the time array. + + Returns: + Return True if no sample point exceeds 1.0 in absolute value. + """ + + time = np.asarray(time, dtype=float) + samples_norm = np.abs(envelope_lam(time, *fargs)) + epsilon = 1e-7 # The value of epsilon mimics that of Waveform._clip() + return np.all(samples_norm < 1.0 + epsilon) + + +def _get_expression_args(expr: sym.Expr, params: dict[str, float]) -> list[np.ndarray | float]: + """A helper function to get argument to evaluate expression. + + Args: + expr: Symbolic expression to evaluate. + params: Dictionary of parameter, which is a superset of expression arguments. + + Returns: + Arguments passed to the lambdified expression. + + Raises: + PulseError: When a free symbol value is not defined in the pulse instance parameters. + """ + args: list[np.ndarray | float] = [] + for symbol in sorted(expr.free_symbols, key=lambda s: s.name): + if symbol.name == "t": + # 't' is a special parameter to represent time vector. + # This should be place at first to broadcast other parameters + # in symengine lambdify function. + times = np.arange(0, params["duration"]) + 1 / 2 + args.insert(0, times) + continue + try: + args.append(params[symbol.name]) + except KeyError as ex: + raise PulseError( + f"Pulse parameter '{symbol.name}' is not defined for this instance. " + "Please check your waveform expression is correct." + ) from ex + return args + + +class LambdifiedExpression: + """Descriptor to lambdify symbolic expression with cache. + + When a new symbolic expression is assigned for the first time, :class:`.LambdifiedExpression` + will internally lambdify the expressions and store the resulting callbacks in its cache. + The next time it encounters the same expression it will return the cached callbacks + thereby increasing the code's speed. + + Note that this class is a python `Descriptor`_, and thus not intended to be + directly called by end-users. This class is designed to be attached to the + :class:`.SymbolicPulse` as attributes for symbolic expressions. + + _`Descriptor`: https://docs.python.org/3/reference/datamodel.html#descriptors + """ + + def __init__(self, attribute: str): + """Create new descriptor. + + Args: + attribute: Name of attribute of :class:`.SymbolicPulse` that returns + the target expression to evaluate. + """ + self.attribute = attribute + self.lambda_funcs: dict[int, Callable] = {} + + def __get__(self, instance, owner) -> Callable: + expr = getattr(instance, self.attribute, None) + if expr is None: + raise PulseError(f"'{self.attribute}' of '{instance.pulse_type}' is not assigned.") + key = hash(expr) + if key not in self.lambda_funcs: + self.__set__(instance, expr) + + return self.lambda_funcs[key] + + def __set__(self, instance, value): + key = hash(value) + if key not in self.lambda_funcs: + params: list[Any] = [] + for p in sorted(value.free_symbols, key=lambda s: s.name): + if p.name == "t": + # Argument "t" must be placed at first. This is a vector. + params.insert(0, p) + continue + params.append(p) + + try: + lamb = sym.lambdify(params, [value], real=False) + + def _wrapped_lamb(*args): + if isinstance(args[0], np.ndarray): + # When the args[0] is a vector ("t"), tile other arguments args[1:] + # to prevent evaluation from looping over each element in t. + t = args[0] + args = np.hstack( + ( + t.reshape(t.size, 1), + np.tile(args[1:], t.size).reshape(t.size, len(args) - 1), + ) + ) + return lamb(args) + + func = _wrapped_lamb + except RuntimeError: + # Currently symengine doesn't support complex_double version for + # several functions such as comparison operator and piecewise. + # If expression contains these function, it fall back to sympy lambdify. + # See https://github.com/symengine/symengine.py/issues/406 for details. + import sympy + + func = sympy.lambdify(params, value) + + self.lambda_funcs[key] = func + + +class SymbolicPulse(Pulse): + r"""The pulse representation model with parameters and symbolic expressions. + + A symbolic pulse instance can be defined with an envelope and parameter constraints. + Envelope and parameter constraints should be provided as symbolic expressions. + Rather than creating a subclass, different pulse shapes can be distinguished by + the instance attributes :attr:`SymbolicPulse.envelope` and :attr:`SymbolicPulse.pulse_type`. + + The symbolic expressions must be defined either with SymPy_ or Symengine_. + Usually Symengine-based expression is much more performant for instantiation + of the :class:`SymbolicPulse`, however, it doesn't support every functions available in SymPy. + You may need to choose proper library depending on how you define your pulses. + Symengine works in the most envelopes and constraints, and thus it is recommended to use + this library especially when your program contains a lot of pulses. + Also note that Symengine has the limited platform support and may not be available + for your local system. Symengine is a required dependency for Qiskit on platforms + that support it will always be installed along with Qiskit on macOS ``x86_64`` and ``arm64``, + and Linux ``x86_64``, ``aarch64``, and ``ppc64le``. + For 64-bit Windows users they will need to manual install it. + For 32-bit platforms such as ``i686`` and ``armv7`` Linux, and on Linux ``s390x`` + there are no pre-compiled packages available and to use symengine you'll need to + compile it from source. If Symengine is not available in your environment SymPy will be used. + + .. _SymPy: https://www.sympy.org/en/index.html + .. _Symengine: https://symengine.org + + .. _symbolic_pulse_envelope: + + .. rubric:: Envelope function + + The waveform at time :math:`t` is generated by the :meth:`get_waveform` according to + + .. math:: + + F(t, \Theta) = \times F(t, {\rm duration}, \overline{\rm params}) + + where :math:`\Theta` is the set of full pulse parameters in the :attr:`SymbolicPulse.parameters` + dictionary which must include the :math:`\rm duration`. + Note that the :math:`F` is an envelope of the waveform, and a programmer must provide this + as a symbolic expression. :math:`\overline{\rm params}` can be arbitrary complex values + as long as they pass :meth:`.validate_parameters` and your quantum backend can accept. + The time :math:`t` and :math:`\rm duration` are in units of dt, i.e. sample time resolution, + and this function is sampled with a discrete time vector in :math:`[0, {\rm duration}]` + sampling the pulse envelope at every 0.5 dt (middle sampling strategy) when + the :meth:`SymbolicPulse.get_waveform` method is called. + The sample data is not generated until this method is called + thus a symbolic pulse instance only stores parameter values and waveform shape, + which greatly reduces memory footprint during the program generation. + + + .. _symbolic_pulse_validation: + + .. rubric:: Pulse validation + + When a symbolic pulse is instantiated, the method :meth:`.validate_parameters` is called, + and performs validation of the pulse. The validation process involves testing the constraint + functions and the maximal amplitude of the pulse (see below). While the validation process + will improve code stability, it will reduce performance and might create + compatibility issues (particularly with JAX). Therefore, it is possible to disable the + validation by setting the class attribute :attr:`.disable_validation` to ``True``. + + .. _symbolic_pulse_constraints: + + .. rubric:: Constraint functions + + Constraints on the parameters are defined with an instance attribute + :attr:`SymbolicPulse.constraints` which can be provided through the constructor. + The constraints value must be a symbolic expression, which is a + function of parameters to be validated and must return a boolean value + being ``True`` when parameters are valid. + If there are multiple conditions to be evaluated, these conditions can be + concatenated with logical expressions such as ``And`` and ``Or`` in SymPy or Symengine. + The symbolic pulse instance can be played only when the constraint function returns ``True``. + The constraint is evaluated when :meth:`.validate_parameters` is called. + + + .. _symbolic_pulse_eval_condition: + + .. rubric:: Maximum amplitude validation + + When you play a pulse in a quantum backend, you might face the restriction on the power + that your waveform generator can handle. Usually, the pulse amplitude is normalized + by this maximum power, namely :math:`\max |F| \leq 1`. This condition is + evaluated along with above constraints when you set ``limit_amplitude = True`` in the constructor. + To evaluate maximum amplitude of the waveform, we need to call :meth:`get_waveform`. + However, this introduces a significant overhead in the validation, and this cannot be ignored + when you repeatedly instantiate symbolic pulse instances. + :attr:`SymbolicPulse.valid_amp_conditions` provides a condition to skip this waveform validation, + and the waveform is not generated as long as this condition returns ``True``, + so that `healthy` symbolic pulses are created very quick. + For example, for a simple pulse shape like ``amp * cos(f * t)``, we know that + pulse amplitude is valid as long as ``amp`` remains less than magnitude 1.0. + So ``abs(amp) <= 1`` could be passed as :attr:`SymbolicPulse.valid_amp_conditions` to skip + doing a full waveform evaluation for amplitude validation. + This expression is provided through the constructor. If this is not provided, + the waveform is generated everytime when :meth:`.validate_parameters` is called. + + + .. rubric:: Examples + + This is how a user can instantiate a symbolic pulse instance. + In this example, we instantiate a custom `Sawtooth` envelope. + + .. code-block:: + + from qiskit.pulse.library import SymbolicPulse + + my_pulse = SymbolicPulse( + pulse_type="Sawtooth", + duration=100, + parameters={"amp": 0.1, "freq": 0.05}, + name="pulse1", + ) + + Note that :class:`SymbolicPulse` can be instantiated without providing + the envelope and constraints. However, this instance cannot generate waveforms + without knowing the envelope definition. Now you need to provide the envelope. + + .. plot:: + :include-source: + + import sympy + from qiskit.pulse.library import SymbolicPulse + + t, amp, freq = sympy.symbols("t, amp, freq") + envelope = 2 * amp * (freq * t - sympy.floor(1 / 2 + freq * t)) + + my_pulse = SymbolicPulse( + pulse_type="Sawtooth", + duration=100, + parameters={"amp": 0.1, "freq": 0.05}, + envelope=envelope, + name="pulse1", + ) + + my_pulse.draw() + + Likewise, you can define :attr:`SymbolicPulse.constraints` for ``my_pulse``. + After providing the envelope definition, you can generate the waveform data. + Note that it would be convenient to define a factory function that automatically + accomplishes this procedure. + + .. code-block:: python + + def Sawtooth(duration, amp, freq, name): + t, amp, freq = sympy.symbols("t, amp, freq") + + instance = SymbolicPulse( + pulse_type="Sawtooth", + duration=duration, + parameters={"amp": amp, "freq": freq}, + envelope=2 * amp * (freq * t - sympy.floor(1 / 2 + freq * t)), + name=name, + ) + + return instance + + You can also provide a :class:`Parameter` object in the ``parameters`` dictionary, + or define ``duration`` with a :class:`Parameter` object when you instantiate + the symbolic pulse instance. + A waveform cannot be generated until you assign all unbounded parameters. + Note that parameters will be assigned through the schedule playing the pulse. + + + .. _symbolic_pulse_serialize: + + .. rubric:: Serialization + + The :class:`~SymbolicPulse` subclass can be serialized along with the + symbolic expressions through :mod:`qiskit.qpy`. + A user can therefore create a custom pulse subclass with a novel envelope and constraints, + and then one can instantiate the class with certain parameters to run on a backend. + This pulse instance can be saved in the QPY binary, which can be loaded afterwards + even within the environment not having original class definition loaded. + This mechanism also allows us to easily share a pulse program including + custom pulse instructions with collaborators. + """ + + __slots__ = ( + "_pulse_type", + "_params", + "_envelope", + "_constraints", + "_valid_amp_conditions", + ) + + disable_validation = False + + # Lambdify caches keyed on sympy expressions. Returns the corresponding callable. + _envelope_lam = LambdifiedExpression("_envelope") + _constraints_lam = LambdifiedExpression("_constraints") + _valid_amp_conditions_lam = LambdifiedExpression("_valid_amp_conditions") + + def __init__( + self, + pulse_type: str, + duration: ParameterExpression | int, + parameters: Mapping[str, ParameterExpression | complex] | None = None, + name: str | None = None, + limit_amplitude: bool | None = None, + envelope: sym.Expr | None = None, + constraints: sym.Expr | None = None, + valid_amp_conditions: sym.Expr | None = None, + ): + """Create a parametric pulse. + + Args: + pulse_type: Display name of this pulse shape. + duration: Duration of pulse. + parameters: Dictionary of pulse parameters that defines the pulse envelope. + name: Display name for this particular pulse envelope. + limit_amplitude: If ``True``, then limit the absolute value of the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + envelope: Pulse envelope expression. + constraints: Pulse parameter constraint expression. + valid_amp_conditions: Extra conditions to skip a full-waveform check for the + amplitude limit. If this condition is not met, then the validation routine + will investigate the full-waveform and raise an error when the amplitude norm + of any data point exceeds 1.0. If not provided, the validation always + creates a full-waveform. + + Raises: + PulseError: When not all parameters are listed in the attribute :attr:`PARAM_DEF`. + """ + super().__init__( + duration=duration, + name=name, + limit_amplitude=limit_amplitude, + ) + if parameters is None: + parameters = {} + + self._pulse_type = pulse_type + self._params = parameters + + self._envelope = envelope + self._constraints = constraints + self._valid_amp_conditions = valid_amp_conditions + if not self.__class__.disable_validation: + self.validate_parameters() + + def __getattr__(self, item): + # Get pulse parameters with attribute-like access. + params = object.__getattribute__(self, "_params") + if item not in params: + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'") + return params[item] + + @property + def pulse_type(self) -> str: + """Return display name of the pulse shape.""" + return self._pulse_type + + @property + def envelope(self) -> sym.Expr: + """Return symbolic expression for the pulse envelope.""" + return self._envelope + + @property + def constraints(self) -> sym.Expr: + """Return symbolic expression for the pulse parameter constraints.""" + return self._constraints + + @property + def valid_amp_conditions(self) -> sym.Expr: + """Return symbolic expression for the pulse amplitude constraints.""" + return self._valid_amp_conditions + + def get_waveform(self) -> Waveform: + r"""Return a Waveform with samples filled according to the formula that the pulse + represents and the parameter values it contains. + + Since the returned array is a discretized time series of the continuous function, + this method uses a midpoint sampler. For ``duration``, return: + + .. math:: + + \{f(t+0.5) \in \mathbb{C} | t \in \mathbb{Z} \wedge 0<=t<\texttt{duration}\} + + Returns: + A waveform representation of this pulse. + + Raises: + PulseError: When parameters are not assigned. + PulseError: When expression for pulse envelope is not assigned. + """ + if self.is_parameterized(): + raise PulseError("Unassigned parameter exists. All parameters must be assigned.") + + if self._envelope is None: + raise PulseError("Pulse envelope expression is not assigned.") + + fargs = _get_expression_args(self._envelope, self.parameters) + return Waveform(samples=self._envelope_lam(*fargs), name=self.name) + + def validate_parameters(self) -> None: + """Validate parameters. + + Raises: + PulseError: If the parameters passed are not valid. + """ + if self.is_parameterized(): + return + + if self._constraints is not None: + fargs = _get_expression_args(self._constraints, self.parameters) + if not bool(self._constraints_lam(*fargs)): + param_repr = ", ".join(f"{p}={v}" for p, v in self.parameters.items()) + const_repr = str(self._constraints) + raise PulseError( + f"Assigned parameters {param_repr} violate following constraint: {const_repr}." + ) + + if self._limit_amplitude: + if self._valid_amp_conditions is not None: + fargs = _get_expression_args(self._valid_amp_conditions, self.parameters) + check_full_waveform = not bool(self._valid_amp_conditions_lam(*fargs)) + else: + check_full_waveform = True + + if check_full_waveform: + # Check full waveform only when the condition is satisified or + # evaluation condition is not provided. + # This operation is slower due to overhead of 'get_waveform'. + fargs = _get_expression_args(self._envelope, self.parameters) + + if not _is_amplitude_valid(self._envelope_lam, tuple(fargs.pop(0)), *fargs): + param_repr = ", ".join(f"{p}={v}" for p, v in self.parameters.items()) + raise PulseError( + f"Maximum pulse amplitude norm exceeds 1.0 with parameters {param_repr}." + "This can be overruled by setting Pulse.limit_amplitude." + ) + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return any(isinstance(val, ParameterExpression) for val in self.parameters.values()) + + @property + def parameters(self) -> dict[str, Any]: + params: dict[str, ParameterExpression | complex | int] = {"duration": self.duration} + params.update(self._params) + return params + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SymbolicPulse): + return NotImplemented + + if self._pulse_type != other._pulse_type: + return False + + if self._envelope != other._envelope: + return False + + if self.parameters != other.parameters: + return False + + return True + + def __repr__(self) -> str: + param_repr = ", ".join(f"{p}={v}" for p, v in self.parameters.items()) + name_repr = f", name='{self.name}'" if self.name is not None else "" + return f"{self._pulse_type}({param_repr}{name_repr})" + + __hash__ = None + + +class ScalableSymbolicPulse(SymbolicPulse): + r"""Subclass of :class:`SymbolicPulse` for pulses with scalable envelope. + + Instance of :class:`ScalableSymbolicPulse` behaves the same as an instance of + :class:`SymbolicPulse`, but its envelope is assumed to have a scalable form + :math:`\text{amp}\times\exp\left(i\times\text{angle}\right)\times\text{F} + \left(t,\text{parameters}\right)`, + where :math:`\text{F}` is some function describing the rest of the envelope, + and both `amp` and `angle` are real (float). Note that both `amp` and `angle` are + stored in the :attr:`parameters` dictionary of the :class:`ScalableSymbolicPulse` + instance. + + When two :class:`ScalableSymbolicPulse` objects are equated, instead of comparing + `amp` and `angle` individually, only the complex amplitude + :math:'\text{amp}\times\exp\left(i\times\text{angle}\right)' is compared. + """ + + def __init__( + self, + pulse_type: str, + duration: ParameterExpression | int, + amp: ParameterValueType, + angle: ParameterValueType, + parameters: dict[str, ParameterExpression | complex] | None = None, + name: str | None = None, + limit_amplitude: bool | None = None, + envelope: sym.Expr | None = None, + constraints: sym.Expr | None = None, + valid_amp_conditions: sym.Expr | None = None, + ): + """Create a scalable symbolic pulse. + + Args: + pulse_type: Display name of this pulse shape. + duration: Duration of pulse. + amp: The magnitude of the complex amplitude of the pulse. + angle: The phase of the complex amplitude of the pulse. + parameters: Dictionary of pulse parameters that defines the pulse envelope. + name: Display name for this particular pulse envelope. + limit_amplitude: If ``True``, then limit the absolute value of the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + envelope: Pulse envelope expression. + constraints: Pulse parameter constraint expression. + valid_amp_conditions: Extra conditions to skip a full-waveform check for the + amplitude limit. If this condition is not met, then the validation routine + will investigate the full-waveform and raise an error when the amplitude norm + of any data point exceeds 1.0. If not provided, the validation always + creates a full-waveform. + + Raises: + PulseError: If ``amp`` is complex. + """ + if isinstance(amp, complex): + raise PulseError( + "amp represents the magnitude of the complex amplitude and can't be complex" + ) + + if not isinstance(parameters, dict): + parameters = {"amp": amp, "angle": angle} + else: + parameters = deepcopy(parameters) + parameters["amp"] = amp + parameters["angle"] = angle + + super().__init__( + pulse_type=pulse_type, + duration=duration, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope, + constraints=constraints, + valid_amp_conditions=valid_amp_conditions, + ) + + # pylint: disable=too-many-return-statements + def __eq__(self, other: object) -> bool: + if not isinstance(other, ScalableSymbolicPulse): + return NotImplemented + + if self._pulse_type != other._pulse_type: + return False + + if self._envelope != other._envelope: + return False + + complex_amp1 = self.amp * np.exp(1j * self.angle) + complex_amp2 = other.amp * np.exp(1j * other.angle) + + if isinstance(complex_amp1, ParameterExpression) or isinstance( + complex_amp2, ParameterExpression + ): + if complex_amp1 != complex_amp2: + return False + else: + if not np.isclose(complex_amp1, complex_amp2): + return False + + for key, value in self.parameters.items(): + if key not in ["amp", "angle"] and value != other.parameters[key]: + return False + + return True + + +class _PulseType(type): + """Metaclass to warn at isinstance check.""" + + def __instancecheck__(cls, instance): + cls_alias = getattr(cls, "alias", None) + + # TODO promote this to Deprecation warning in future. + # Once type information usage is removed from user code, + # we will convert pulse classes into functions. + warnings.warn( + "Typechecking with the symbolic pulse subclass will be deprecated. " + f"'{cls_alias}' subclass instance is turned into SymbolicPulse instance. " + f"Use self.pulse_type == '{cls_alias}' instead.", + PendingDeprecationWarning, + ) + + if not isinstance(instance, SymbolicPulse): + return False + return instance.pulse_type == cls_alias + + def __getattr__(cls, item): + # For pylint. A SymbolicPulse subclass must implement several methods + # such as .get_waveform and .validate_parameters. + # In addition, they conventionally offer attribute-like access to the pulse parameters, + # for example, instance.amp returns instance._params["amp"]. + # If pulse classes are directly instantiated, pylint yells no-member + # since the pulse class itself implements nothing. These classes just + # behave like a factory by internally instantiating the SymbolicPulse and return it. + # It is not realistic to write disable=no-member across qiskit packages. + return NotImplemented + + +class Gaussian(metaclass=_PulseType): + r"""A lifted and truncated pulse envelope shaped according to the Gaussian function whose + mean is centered at the center of the pulse (duration / 2): + + .. math:: + + \begin{aligned} + f'(x) &= \exp\Bigl( -\frac12 \frac{{(x - \text{duration}/2)}^2}{\text{sigma}^2} \Bigr)\\ + f(x) &= \text{A} \times \frac{f'(x) - f'(-1)}{1-f'(-1)}, \quad 0 \le x < \text{duration} + \end{aligned} + + where :math:`f'(x)` is the gaussian waveform without lifting or amplitude scaling, and + :math:`\text{A} = \text{amp} \times \exp\left(i\times\text{angle}\right)`. + """ + + alias = "Gaussian" + + def __new__( + cls, + duration: int | ParameterValueType, + amp: ParameterValueType, + sigma: ParameterValueType, + angle: ParameterValueType = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, + ) -> ScalableSymbolicPulse: + """Create new pulse instance. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the Gaussian envelope. + sigma: A measure of how wide or narrow the Gaussian peak is; described mathematically + in the class docstring. + angle: The angle of the complex amplitude of the Gaussian envelope. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + parameters = {"sigma": sigma} + + # Prepare symbolic expressions + _t, _duration, _amp, _sigma, _angle = sym.symbols("t, duration, amp, sigma, angle") + _center = _duration / 2 + + envelope_expr = ( + _amp * sym.exp(sym.I * _angle) * _lifted_gaussian(_t, _center, _duration + 1, _sigma) + ) + + consts_expr = _sigma > 0 + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type=cls.alias, + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +class GaussianSquare(metaclass=_PulseType): + """A square pulse with a Gaussian shaped risefall on both sides lifted such that + its first sample is zero. + + Exactly one of the ``risefall_sigma_ratio`` and ``width`` parameters has to be specified. + + If ``risefall_sigma_ratio`` is not None and ``width`` is None: + + .. math:: + + \\begin{aligned} + \\text{risefall} &= \\text{risefall\\_sigma\\_ratio} \\times \\text{sigma}\\\\ + \\text{width} &= \\text{duration} - 2 \\times \\text{risefall} + \\end{aligned} + + If ``width`` is not None and ``risefall_sigma_ratio`` is None: + + .. math:: \\text{risefall} = \\frac{\\text{duration} - \\text{width}}{2} + + In both cases, the lifted gaussian square pulse :math:`f'(x)` is defined as: + + .. math:: + + \\begin{aligned} + f'(x) &= \\begin{cases}\ + \\exp\\biggl(-\\frac12 \\frac{(x - \\text{risefall})^2}{\\text{sigma}^2}\\biggr)\ + & x < \\text{risefall}\\\\ + 1\ + & \\text{risefall} \\le x < \\text{risefall} + \\text{width}\\\\ + \\exp\\biggl(-\\frac12\ + \\frac{{\\bigl(x - (\\text{risefall} + \\text{width})\\bigr)}^2}\ + {\\text{sigma}^2}\ + \\biggr)\ + & \\text{risefall} + \\text{width} \\le x\ + \\end{cases}\\\\ + f(x) &= \\text{A} \\times \\frac{f'(x) - f'(-1)}{1-f'(-1)},\ + \\quad 0 \\le x < \\text{duration} + \\end{aligned} + + where :math:`f'(x)` is the gaussian square waveform without lifting or amplitude scaling, and + :math:`\\text{A} = \\text{amp} \\times \\exp\\left(i\\times\\text{angle}\\right)`. + """ + + alias = "GaussianSquare" + + def __new__( + cls, + duration: int | ParameterValueType, + amp: ParameterValueType, + sigma: ParameterValueType, + width: ParameterValueType | None = None, + angle: ParameterValueType = 0.0, + risefall_sigma_ratio: ParameterValueType | None = None, + name: str | None = None, + limit_amplitude: bool | None = None, + ) -> ScalableSymbolicPulse: + """Create new pulse instance. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the Gaussian and square pulse. + sigma: A measure of how wide or narrow the Gaussian risefall is; see the class + docstring for more details. + width: The duration of the embedded square pulse. + angle: The angle of the complex amplitude of the pulse. Default value 0. + risefall_sigma_ratio: The ratio of each risefall duration to sigma. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + + Raises: + PulseError: When width and risefall_sigma_ratio are both empty or both non-empty. + """ + # Convert risefall_sigma_ratio into width which is defined in OpenPulse spec + if width is None and risefall_sigma_ratio is None: + raise PulseError( + "Either the pulse width or the risefall_sigma_ratio parameter must be specified." + ) + if width is not None and risefall_sigma_ratio is not None: + raise PulseError( + "Either the pulse width or the risefall_sigma_ratio parameter can be specified" + " but not both." + ) + if width is None and risefall_sigma_ratio is not None: + width = duration - 2.0 * risefall_sigma_ratio * sigma + + parameters = {"sigma": sigma, "width": width} + + # Prepare symbolic expressions + _t, _duration, _amp, _sigma, _width, _angle = sym.symbols( + "t, duration, amp, sigma, width, angle" + ) + _center = _duration / 2 + + _sq_t0 = _center - _width / 2 + _sq_t1 = _center + _width / 2 + + _gaussian_ledge = _lifted_gaussian(_t, _sq_t0, -1, _sigma) + _gaussian_redge = _lifted_gaussian(_t, _sq_t1, _duration + 1, _sigma) + + envelope_expr = ( + _amp + * sym.exp(sym.I * _angle) + * sym.Piecewise( + (_gaussian_ledge, _t <= _sq_t0), (_gaussian_redge, _t >= _sq_t1), (1, True) + ) + ) + + consts_expr = sym.And(_sigma > 0, _width >= 0, _duration >= _width) + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type=cls.alias, + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def GaussianSquareDrag( + duration: int | ParameterExpression, + amp: float | ParameterExpression, + sigma: float | ParameterExpression, + beta: float | ParameterExpression, + width: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + risefall_sigma_ratio: float | ParameterExpression | None = None, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """A square pulse with a Drag shaped rise and fall + + This pulse shape is similar to :class:`~.GaussianSquare` but uses + :class:`~.Drag` for its rise and fall instead of :class:`~.Gaussian`. The + addition of the DRAG component of the rise and fall is sometimes helpful in + suppressing the spectral content of the pulse at frequencies near to, but + slightly offset from, the fundamental frequency of the drive. When there is + a spectator qubit close in frequency to the fundamental frequency, + suppressing the drive at the spectator's frequency can help avoid unwanted + excitation of the spectator. + + Exactly one of the ``risefall_sigma_ratio`` and ``width`` parameters has to be specified. + + If ``risefall_sigma_ratio`` is not ``None`` and ``width`` is ``None``: + + .. math:: + + \\begin{aligned} + \\text{risefall} &= \\text{risefall\\_sigma\\_ratio} \\times \\text{sigma}\\\\ + \\text{width} &= \\text{duration} - 2 \\times \\text{risefall} + \\end{aligned} + + If ``width`` is not None and ``risefall_sigma_ratio`` is None: + + .. math:: \\text{risefall} = \\frac{\\text{duration} - \\text{width}}{2} + + Gaussian :math:`g(x, c, σ)` and lifted gaussian :math:`g'(x, c, σ)` curves + can be written as: + + .. math:: + + \\begin{aligned} + g(x, c, σ) &= \\exp\\Bigl(-\\frac12 \\frac{(x - c)^2}{σ^2}\\Bigr)\\\\ + g'(x, c, σ) &= \\frac{g(x, c, σ)-g(-1, c, σ)}{1-g(-1, c, σ)} + \\end{aligned} + + From these, the lifted DRAG curve :math:`d'(x, c, σ, β)` can be written as + + .. math:: + + d'(x, c, σ, β) = g'(x, c, σ) \\times \\Bigl(1 + 1j \\times β \\times\ + \\Bigl(-\\frac{x - c}{σ^2}\\Bigr)\\Bigr) + + The lifted gaussian square drag pulse :math:`f'(x)` is defined as: + + .. math:: + + \\begin{aligned} + f'(x) &= \\begin{cases}\ + \\text{A} \\times d'(x, \\text{risefall}, \\text{sigma}, \\text{beta})\ + & x < \\text{risefall}\\\\ + \\text{A}\ + & \\text{risefall} \\le x < \\text{risefall} + \\text{width}\\\\ + \\text{A} \\times \\times d'(\ + x - (\\text{risefall} + \\text{width}),\ + \\text{risefall},\ + \\text{sigma},\ + \\text{beta}\ + )\ + & \\text{risefall} + \\text{width} \\le x\ + \\end{cases}\\\\ + \\end{aligned} + + where :math:`\\text{A} = \\text{amp} \\times + \\exp\\left(i\\times\\text{angle}\\right)`. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The amplitude of the DRAG rise and fall and of the square pulse. + sigma: A measure of how wide or narrow the DRAG risefall is; see the class + docstring for more details. + beta: The DRAG correction amplitude. + width: The duration of the embedded square pulse. + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + risefall_sigma_ratio: The ratio of each risefall duration to sigma. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + + Raises: + PulseError: When width and risefall_sigma_ratio are both empty or both non-empty. + """ + # Convert risefall_sigma_ratio into width which is defined in OpenPulse spec + if width is None and risefall_sigma_ratio is None: + raise PulseError( + "Either the pulse width or the risefall_sigma_ratio parameter must be specified." + ) + if width is not None and risefall_sigma_ratio is not None: + raise PulseError( + "Either the pulse width or the risefall_sigma_ratio parameter can be specified" + " but not both." + ) + if width is None and risefall_sigma_ratio is not None: + width = duration - 2.0 * risefall_sigma_ratio * sigma + + parameters = {"sigma": sigma, "width": width, "beta": beta} + + # Prepare symbolic expressions + _t, _duration, _amp, _sigma, _beta, _width, _angle = sym.symbols( + "t, duration, amp, sigma, beta, width, angle" + ) + _center = _duration / 2 + + _sq_t0 = _center - _width / 2 + _sq_t1 = _center + _width / 2 + + _gaussian_ledge = _lifted_gaussian(_t, _sq_t0, -1, _sigma) + _gaussian_redge = _lifted_gaussian(_t, _sq_t1, _duration + 1, _sigma) + _deriv_ledge = -(_t - _sq_t0) / (_sigma**2) * _gaussian_ledge + _deriv_redge = -(_t - _sq_t1) / (_sigma**2) * _gaussian_redge + + envelope_expr = ( + _amp + * sym.exp(sym.I * _angle) + * sym.Piecewise( + (_gaussian_ledge + sym.I * _beta * _deriv_ledge, _t <= _sq_t0), + (_gaussian_redge + sym.I * _beta * _deriv_redge, _t >= _sq_t1), + (1, True), + ) + ) + consts_expr = sym.And(_sigma > 0, _width >= 0, _duration >= _width) + valid_amp_conditions_expr = sym.And(sym.Abs(_amp) <= 1.0, sym.Abs(_beta) < _sigma) + + return ScalableSymbolicPulse( + pulse_type="GaussianSquareDrag", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def gaussian_square_echo( + duration: int | ParameterValueType, + amp: float | ParameterExpression, + sigma: float | ParameterExpression, + width: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + active_amp: float | ParameterExpression | None = 0.0, + active_angle: float | ParameterExpression | None = 0.0, + risefall_sigma_ratio: float | ParameterExpression | None = None, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> SymbolicPulse: + """An echoed Gaussian square pulse with an active tone overlaid on it. + + The Gaussian Square Echo pulse is composed of three pulses. First, a Gaussian Square pulse + :math:`f_{echo}(x)` with amplitude ``amp`` and phase ``angle`` playing for half duration, + followed by a second Gaussian Square pulse :math:`-f_{echo}(x)` with opposite amplitude + and same phase playing for the rest of the duration. Third a Gaussian Square pulse + :math:`f_{active}(x)` with amplitude ``active_amp`` and phase ``active_angle`` + playing for the entire duration. The Gaussian Square Echo pulse :math:`g_e()` + can be written as: + + .. math:: + + \\begin{aligned} + g_e(x) &= \\begin{cases}\ + f_{\\text{active}} + f_{\\text{echo}}(x)\ + & x < \\frac{\\text{duration}}{2}\\\\ + f_{\\text{active}} - f_{\\text{echo}}(x)\ + & \\frac{\\text{duration}}{2} < x\ + \\end{cases}\\\\ + \\end{aligned} + + One case where this pulse can be used is when implementing a direct CNOT gate with + a cross-resonance superconducting qubit architecture. When applying this pulse to + the target qubit, the active portion can be used to cancel IX terms from the + cross-resonance drive while the echo portion can reduce the impact of a static ZZ coupling. + + Exactly one of the ``risefall_sigma_ratio`` and ``width`` parameters has to be specified. + + If ``risefall_sigma_ratio`` is not ``None`` and ``width`` is ``None``: + + .. math:: + + \\begin{aligned} + \\text{risefall} &= \\text{risefall\\_sigma\\_ratio} \\times \\text{sigma}\\\\ + \\text{width} &= \\text{duration} - 2 \\times \\text{risefall} + \\end{aligned} + + If ``width`` is not None and ``risefall_sigma_ratio`` is None: + + .. math:: \\text{risefall} = \\frac{\\text{duration} - \\text{width}}{2} + + References: + 1. |citation1|_ + + .. _citation1: https://iopscience.iop.org/article/10.1088/2058-9565/abe519 + + .. |citation1| replace:: *Jurcevic, P., Javadi-Abhari, A., Bishop, L. S., + Lauer, I., Bogorin, D. F., Brink, M., Capelluto, L., G{\"u}nl{\"u}k, O., + Itoko, T., Kanazawa, N. & others + Demonstration of quantum volume 64 on a superconducting quantum + computing system. (Section V)* + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The amplitude of the rise and fall and of the echoed pulse. + sigma: A measure of how wide or narrow the risefall is; see the class + docstring for more details. + width: The duration of the embedded square pulse. + angle: The angle in radians of the complex phase factor uniformly + scaling the echoed pulse. Default value 0. + active_amp: The amplitude of the active pulse. + active_angle: The angle in radian of the complex phase factor uniformly + scaling the active pulse. Default value 0. + risefall_sigma_ratio: The ratio of each risefall duration to sigma. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + Raises: + PulseError: When width and risefall_sigma_ratio are both empty or both non-empty. + """ + # Convert risefall_sigma_ratio into width which is defined in OpenPulse spec + if width is None and risefall_sigma_ratio is None: + raise PulseError( + "Either the pulse width or the risefall_sigma_ratio parameter must be specified." + ) + if width is not None and risefall_sigma_ratio is not None: + raise PulseError( + "Either the pulse width or the risefall_sigma_ratio parameter can be specified" + " but not both." + ) + + if width is None and risefall_sigma_ratio is not None: + width = duration - 2.0 * risefall_sigma_ratio * sigma + + parameters = { + "amp": amp, + "angle": angle, + "sigma": sigma, + "width": width, + "active_amp": active_amp, + "active_angle": active_angle, + } + + # Prepare symbolic expressions + ( + _t, + _duration, + _amp, + _sigma, + _active_amp, + _width, + _angle, + _active_angle, + ) = sym.symbols("t, duration, amp, sigma, active_amp, width, angle, active_angle") + + # gaussian square echo for rotary tone + _center = _duration / 4 + + _width_echo = (_duration - 2 * (_duration - _width)) / 2 + + _sq_t0 = _center - _width_echo / 2 + _sq_t1 = _center + _width_echo / 2 + + _gaussian_ledge = _lifted_gaussian(_t, _sq_t0, -1, _sigma) + _gaussian_redge = _lifted_gaussian(_t, _sq_t1, _duration / 2 + 1, _sigma) + + envelope_expr_p = ( + _amp + * sym.exp(sym.I * _angle) + * sym.Piecewise( + (_gaussian_ledge, _t <= _sq_t0), + (_gaussian_redge, _t >= _sq_t1), + (1, True), + ) + ) + + _center_echo = _duration / 2 + _duration / 4 + + _sq_t0_echo = _center_echo - _width_echo / 2 + _sq_t1_echo = _center_echo + _width_echo / 2 + + _gaussian_ledge_echo = _lifted_gaussian(_t, _sq_t0_echo, _duration / 2 - 1, _sigma) + _gaussian_redge_echo = _lifted_gaussian(_t, _sq_t1_echo, _duration + 1, _sigma) + + envelope_expr_echo = ( + -1 + * _amp + * sym.exp(sym.I * _angle) + * sym.Piecewise( + (_gaussian_ledge_echo, _t <= _sq_t0_echo), + (_gaussian_redge_echo, _t >= _sq_t1_echo), + (1, True), + ) + ) + + envelope_expr = sym.Piecewise( + (envelope_expr_p, _t <= _duration / 2), (envelope_expr_echo, _t >= _duration / 2), (0, True) + ) + + # gaussian square for active cancellation tone + _center_active = _duration / 2 + + _sq_t0_active = _center_active - _width / 2 + _sq_t1_active = _center_active + _width / 2 + + _gaussian_ledge_active = _lifted_gaussian(_t, _sq_t0_active, -1, _sigma) + _gaussian_redge_active = _lifted_gaussian(_t, _sq_t1_active, _duration + 1, _sigma) + + envelope_expr_active = ( + _active_amp + * sym.exp(sym.I * _active_angle) + * sym.Piecewise( + (_gaussian_ledge_active, _t <= _sq_t0_active), + (_gaussian_redge_active, _t >= _sq_t1_active), + (1, True), + ) + ) + + envelop_expr_total = envelope_expr + envelope_expr_active + + consts_expr = sym.And( + _sigma > 0, _width >= 0, _duration >= _width, _duration / 2 >= _width_echo + ) + + # Check validity of amplitudes + valid_amp_conditions_expr = sym.And(sym.Abs(_amp) + sym.Abs(_active_amp) <= 1.0) + + return SymbolicPulse( + pulse_type="gaussian_square_echo", + duration=duration, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelop_expr_total, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def GaussianDeriv( + duration: int | ParameterValueType, + amp: float | ParameterExpression, + sigma: float | ParameterExpression, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """An unnormalized Gaussian derivative pulse. + + The Gaussian function is centered around the halfway point of the pulse, + and the envelope of the pulse is given by: + + .. math:: + + f(x) = -\\text{A}\\frac{x-\\mu}{\\text{sigma}^{2}}\\exp + \\left[-\\left(\\frac{x-\\mu}{2\\text{sigma}}\\right)^{2}\\right] , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`, + and :math:`\\mu=\\text{duration}/2`. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the pulse + (the value of the corresponding Gaussian at the midpoint `duration`/2). + sigma: A measure of how wide or narrow the corresponding Gaussian peak is in terms of `dt`; + described mathematically in the class docstring. + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + parameters = {"sigma": sigma} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _sigma = sym.symbols("t, duration, amp, angle, sigma") + envelope_expr = ( + -_amp + * sym.exp(sym.I * _angle) + * ((_t - (_duration / 2)) / _sigma**2) + * sym.exp(-(1 / 2) * ((_t - (_duration / 2)) / _sigma) ** 2) + ) + consts_expr = _sigma > 0 + valid_amp_conditions_expr = sym.Abs(_amp / _sigma) <= sym.exp(1 / 2) + + return ScalableSymbolicPulse( + pulse_type="GaussianDeriv", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +class Drag(metaclass=_PulseType): + """The Derivative Removal by Adiabatic Gate (DRAG) pulse is a standard Gaussian pulse + with an additional Gaussian derivative component and lifting applied. + + It can be calibrated either to reduce the phase error due to virtual population of the + :math:`|2\\rangle` state during the pulse or to reduce the frequency spectrum of a + standard Gaussian pulse near the :math:`|1\\rangle\\leftrightarrow|2\\rangle` transition, + reducing the chance of leakage to the :math:`|2\\rangle` state. + + .. math:: + + \\begin{aligned} + g(x) &= \\exp\\Bigl(-\\frac12 \\frac{(x - \\text{duration}/2)^2}{\\text{sigma}^2}\\Bigr)\\\\ + g'(x) &= \\text{A}\\times\\frac{g(x)-g(-1)}{1-g(-1)}\\\\ + f(x) &= g'(x) \\times \\Bigl(1 + 1j \\times \\text{beta} \\times\ + \\Bigl(-\\frac{x - \\text{duration}/2}{\\text{sigma}^2}\\Bigr) \\Bigr), + \\quad 0 \\le x < \\text{duration} + \\end{aligned} + + where :math:`g(x)` is a standard unlifted Gaussian waveform, :math:`g'(x)` is the lifted + :class:`~qiskit.pulse.library.Gaussian` waveform, and + :math:`\\text{A} = \\text{amp} \\times \\exp\\left(i\\times\\text{angle}\\right)`. + + References: + 1. |citation1|_ + + .. _citation1: https://link.aps.org/doi/10.1103/PhysRevA.83.012308 + + .. |citation1| replace:: *Gambetta, J. M., Motzoi, F., Merkel, S. T. & Wilhelm, F. K. + Analytic control methods for high-fidelity unitary operations + in a weakly nonlinear oscillator. Phys. Rev. A 83, 012308 (2011).* + + 2. |citation2|_ + + .. _citation2: https://link.aps.org/doi/10.1103/PhysRevLett.103.110501 + + .. |citation2| replace:: *F. Motzoi, J. M. Gambetta, P. Rebentrost, and F. K. Wilhelm + Phys. Rev. Lett. 103, 110501 – Published 8 September 2009.* + """ + + alias = "Drag" + + def __new__( + cls, + duration: int | ParameterValueType, + amp: ParameterValueType, + sigma: ParameterValueType, + beta: ParameterValueType, + angle: ParameterValueType = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, + ) -> ScalableSymbolicPulse: + """Create new pulse instance. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the DRAG envelope. + sigma: A measure of how wide or narrow the Gaussian peak is; described mathematically + in the class docstring. + beta: The correction amplitude. + angle: The angle of the complex amplitude of the DRAG envelope. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + parameters = {"sigma": sigma, "beta": beta} + + # Prepare symbolic expressions + _t, _duration, _amp, _sigma, _beta, _angle = sym.symbols( + "t, duration, amp, sigma, beta, angle" + ) + _center = _duration / 2 + + _gauss = _lifted_gaussian(_t, _center, _duration + 1, _sigma) + _deriv = -(_t - _center) / (_sigma**2) * _gauss + + envelope_expr = _amp * sym.exp(sym.I * _angle) * (_gauss + sym.I * _beta * _deriv) + + consts_expr = _sigma > 0 + valid_amp_conditions_expr = sym.And(sym.Abs(_amp) <= 1.0, sym.Abs(_beta) < _sigma) + + return ScalableSymbolicPulse( + pulse_type="Drag", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +class Constant(metaclass=_PulseType): + """A simple constant pulse, with an amplitude value and a duration: + + .. math:: + + f(x) = \\text{amp}\\times\\exp\\left(i\\text{angle}\\right) , 0 <= x < duration + f(x) = 0 , elsewhere + """ + + alias = "Constant" + + def __new__( + cls, + duration: int | ParameterValueType, + amp: ParameterValueType, + angle: ParameterValueType = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, + ) -> ScalableSymbolicPulse: + """Create new pulse instance. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the square envelope. + angle: The angle of the complex amplitude of the square envelope. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + # Prepare symbolic expressions + _t, _amp, _duration, _angle = sym.symbols("t, amp, duration, angle") + + # Note this is implemented using Piecewise instead of just returning amp + # directly because otherwise the expression has no t dependence and sympy's + # lambdify will produce a function f that for an array t returns amp + # instead of amp * np.ones(t.shape). + # + # See: https://github.com/sympy/sympy/issues/5642 + envelope_expr = ( + _amp + * sym.exp(sym.I * _angle) + * sym.Piecewise((1, sym.And(_t >= 0, _t <= _duration)), (0, True)) + ) + + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Constant", + duration=duration, + amp=amp, + angle=angle, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def Sin( + duration: int | ParameterExpression, + amp: float | ParameterExpression, + phase: float | ParameterExpression, + freq: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """A sinusoidal pulse. + + The envelope of the pulse is given by: + + .. math:: + + f(x) = \\text{A}\\sin\\left(2\\pi\\text{freq}x+\\text{phase}\\right) , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the sinusoidal wave. Wave range is [-`amp`,`amp`]. + phase: The phase of the sinusoidal wave (note that this is not equivalent to the angle of + the complex amplitude) + freq: The frequency of the sinusoidal wave, in terms of 1 over sampling period. + If not provided defaults to a single cycle (i.e :math:'\\frac{1}{\\text{duration}}'). + The frequency is limited to the range :math:`\\left(0,0.5\\right]` (the Nyquist frequency). + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + if freq is None: + freq = 1 / duration + parameters = {"freq": freq, "phase": phase} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _freq, _phase = sym.symbols("t, duration, amp, angle, freq, phase") + + envelope_expr = _amp * sym.exp(sym.I * _angle) * sym.sin(2 * sym.pi * _freq * _t + _phase) + + consts_expr = sym.And(_freq > 0, _freq < 0.5) + + # This might fail for waves shorter than a single cycle + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Sin", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def Cos( + duration: int | ParameterExpression, + amp: float | ParameterExpression, + phase: float | ParameterExpression, + freq: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """A cosine pulse. + + The envelope of the pulse is given by: + + .. math:: + + f(x) = \\text{A}\\cos\\left(2\\pi\\text{freq}x+\\text{phase}\\right) , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the cosine wave. Wave range is [-`amp`,`amp`]. + phase: The phase of the cosine wave (note that this is not equivalent to the angle + of the complex amplitude). + freq: The frequency of the cosine wave, in terms of 1 over sampling period. + If not provided defaults to a single cycle (i.e :math:'\\frac{1}{\\text{duration}}'). + The frequency is limited to the range :math:`\\left(0,0.5\\right]` (the Nyquist frequency). + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + if freq is None: + freq = 1 / duration + parameters = {"freq": freq, "phase": phase} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _freq, _phase = sym.symbols("t, duration, amp, angle, freq, phase") + + envelope_expr = _amp * sym.exp(sym.I * _angle) * sym.cos(2 * sym.pi * _freq * _t + _phase) + + consts_expr = sym.And(_freq > 0, _freq < 0.5) + + # This might fail for waves shorter than a single cycle + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Cos", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def Sawtooth( + duration: int | ParameterExpression, + amp: float | ParameterExpression, + phase: float | ParameterExpression, + freq: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """A sawtooth pulse. + + The envelope of the pulse is given by: + + .. math:: + + f(x) = 2\\text{A}\\left[g\\left(x\\right)- + \\lfloor g\\left(x\\right)+\\frac{1}{2}\\rfloor\\right] + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`, + :math:`g\\left(x\\right)=x\\times\\text{freq}+\\frac{\\text{phase}}{2\\pi}`, + and :math:`\\lfloor ...\\rfloor` is the floor operation. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the sawtooth wave. Wave range is [-`amp`,`amp`]. + phase: The phase of the sawtooth wave (note that this is not equivalent to the angle + of the complex amplitude) + freq: The frequency of the sawtooth wave, in terms of 1 over sampling period. + If not provided defaults to a single cycle (i.e :math:'\\frac{1}{\\text{duration}}'). + The frequency is limited to the range :math:`\\left(0,0.5\\right]` (the Nyquist frequency). + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + if freq is None: + freq = 1 / duration + parameters = {"freq": freq, "phase": phase} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _freq, _phase = sym.symbols("t, duration, amp, angle, freq, phase") + lin_expr = _t * _freq + _phase / (2 * sym.pi) + + envelope_expr = 2 * _amp * sym.exp(sym.I * _angle) * (lin_expr - sym.floor(lin_expr + 1 / 2)) + + consts_expr = sym.And(_freq > 0, _freq < 0.5) + + # This might fail for waves shorter than a single cycle + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Sawtooth", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def Triangle( + duration: int | ParameterExpression, + amp: float | ParameterExpression, + phase: float | ParameterExpression, + freq: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """A triangle wave pulse. + + The envelope of the pulse is given by: + + .. math:: + + f(x) = \\text{A}\\left[\\text{sawtooth}\\left(x\\right)\\right] , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`, + and :math:`\\text{sawtooth}\\left(x\\right)` is a sawtooth wave with the same frequency + as the triangle wave, but a phase shifted by :math:`\\frac{\\pi}{2}`. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the triangle wave. Wave range is [-`amp`,`amp`]. + phase: The phase of the triangle wave (note that this is not equivalent to the angle + of the complex amplitude) + freq: The frequency of the triangle wave, in terms of 1 over sampling period. + If not provided defaults to a single cycle (i.e :math:'\\frac{1}{\\text{duration}}'). + The frequency is limited to the range :math:`\\left(0,0.5\\right]` (the Nyquist frequency). + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + if freq is None: + freq = 1 / duration + parameters = {"freq": freq, "phase": phase} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _freq, _phase = sym.symbols("t, duration, amp, angle, freq, phase") + lin_expr = _t * _freq + _phase / (2 * sym.pi) - 0.25 + sawtooth_expr = 2 * (lin_expr - sym.floor(lin_expr + 1 / 2)) + + envelope_expr = _amp * sym.exp(sym.I * _angle) * (-2 * sym.Abs(sawtooth_expr) + 1) + + consts_expr = sym.And(_freq > 0, _freq < 0.5) + + # This might fail for waves shorter than a single cycle + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Triangle", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def Square( + duration: int | ParameterValueType, + amp: float | ParameterExpression, + phase: float | ParameterExpression, + freq: float | ParameterExpression | None = None, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """A square wave pulse. + + The envelope of the pulse is given by: + + .. math:: + + f(x) = \\text{A}\\text{sign}\\left[\\sin + \\left(2\\pi x\\times\\text{freq}+\\text{phase}\\right)\\right] , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`, + and :math:`\\text{sign}` + is the sign function with the convention :math:`\\text{sign}\\left(0\\right)=1`. + + Args: + duration: Pulse length in terms of the sampling period ``dt``. + amp: The magnitude of the amplitude of the square wave. Wave range is + :math:`\\left[-\\texttt{amp},\\texttt{amp}\\right]`. + phase: The phase of the square wave (note that this is not equivalent to the angle of + the complex amplitude). + freq: The frequency of the square wave, in terms of 1 over sampling period. + If not provided defaults to a single cycle (i.e :math:`\\frac{1}{\\text{duration}}`). + The frequency is limited to the range :math:`\\left(0,0.5\\right]` (the Nyquist frequency). + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + if freq is None: + freq = 1 / duration + parameters = {"freq": freq, "phase": phase} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _freq, _phase = sym.symbols("t, duration, amp, angle, freq, phase") + _x = _freq * _t + _phase / (2 * sym.pi) + + envelope_expr = ( + _amp * sym.exp(sym.I * _angle) * (2 * (2 * sym.floor(_x) - sym.floor(2 * _x)) + 1) + ) + + consts_expr = sym.And(_freq > 0, _freq < 0.5) + + # This might fail for waves shorter than a single cycle + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Square", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def Sech( + duration: int | ParameterValueType, + amp: float | ParameterExpression, + sigma: float | ParameterExpression, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + zero_ends: bool | None = True, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """An unnormalized sech pulse. + + The sech function is centered around the halfway point of the pulse, + and the envelope of the pulse is given by: + + .. math:: + + f(x) = \\text{A}\\text{sech}\\left( + \\frac{x-\\mu}{\\text{sigma}}\\right) , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`, + and :math:`\\mu=\\text{duration}/2`. + + If `zero_ends` is set to `True`, the output `y` is modified: + .. math:: + + y\\left(x\\right) \\mapsto \\text{A}\\frac{y-y^{*}}{\\text{A}-y^{*}}, + + where :math:`y^{*}` is the value of :math:`y` at the endpoints (at :math:`x=-1 + and :math:`x=\\text{duration}+1`). This shifts the endpoints value to zero, while also + rescaling to preserve the amplitude at `:math:`\\text{duration}/2``. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the pulse (the value at the midpoint `duration`/2). + sigma: A measure of how wide or narrow the sech peak is in terms of `dt`; + described mathematically in the class docstring. + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + zero_ends: If True, zeros the ends at x = -1, x = `duration` + 1, + but rescales to preserve `amp`. Default value True. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + parameters = {"sigma": sigma} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _sigma = sym.symbols("t, duration, amp, angle, sigma") + complex_amp = _amp * sym.exp(sym.I * _angle) + envelope_expr = complex_amp * sym.sech((_t - (_duration / 2)) / _sigma) + + if zero_ends: + shift_val = complex_amp * sym.sech((-1 - (_duration / 2)) / _sigma) + envelope_expr = complex_amp * (envelope_expr - shift_val) / (complex_amp - shift_val) + + consts_expr = _sigma > 0 + + valid_amp_conditions_expr = sym.Abs(_amp) <= 1.0 + + return ScalableSymbolicPulse( + pulse_type="Sech", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) + + +def SechDeriv( + duration: int | ParameterValueType, + amp: float | ParameterExpression, + sigma: float | ParameterExpression, + angle: float | ParameterExpression | None = 0.0, + name: str | None = None, + limit_amplitude: bool | None = None, +) -> ScalableSymbolicPulse: + """An unnormalized sech derivative pulse. + + The sech function is centered around the halfway point of the pulse, and the envelope of the + pulse is given by: + + .. math:: + + f(x) = \\text{A}\\frac{d}{dx}\\left[\\text{sech} + \\left(\\frac{x-\\mu}{\\text{sigma}}\\right)\\right] , 0 <= x < duration + + where :math:`\\text{A} = \\text{amp} \\times\\exp\\left(i\\times\\text{angle}\\right)`, + :math:`\\mu=\\text{duration}/2`, and :math:`d/dx` is a derivative with respect to `x`. + + Args: + duration: Pulse length in terms of the sampling period `dt`. + amp: The magnitude of the amplitude of the pulse (the value of the corresponding sech + function at the midpoint `duration`/2). + sigma: A measure of how wide or narrow the corresponding sech peak is, in terms of `dt`; + described mathematically in the class docstring. + angle: The angle in radians of the complex phase factor uniformly + scaling the pulse. Default value 0. + name: Display name for this pulse envelope. + limit_amplitude: If ``True``, then limit the amplitude of the + waveform to 1. The default is ``True`` and the amplitude is constrained to 1. + + Returns: + ScalableSymbolicPulse instance. + """ + parameters = {"sigma": sigma} + + # Prepare symbolic expressions + _t, _duration, _amp, _angle, _sigma = sym.symbols("t, duration, amp, angle, sigma") + time_argument = (_t - (_duration / 2)) / _sigma + sech_deriv = -sym.tanh(time_argument) * sym.sech(time_argument) / _sigma + + envelope_expr = _amp * sym.exp(sym.I * _angle) * sech_deriv + + consts_expr = _sigma > 0 + + valid_amp_conditions_expr = sym.Abs(_amp) / _sigma <= 2.0 + + return ScalableSymbolicPulse( + pulse_type="SechDeriv", + duration=duration, + amp=amp, + angle=angle, + parameters=parameters, + name=name, + limit_amplitude=limit_amplitude, + envelope=envelope_expr, + constraints=consts_expr, + valid_amp_conditions=valid_amp_conditions_expr, + ) diff --git a/qiskit_dynamics/pulse/library/waveform.py b/qiskit_dynamics/pulse/library/waveform.py new file mode 100644 index 000000000..323c73a78 --- /dev/null +++ b/qiskit_dynamics/pulse/library/waveform.py @@ -0,0 +1,134 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""A pulse that is described by complex-valued sample points.""" +from __future__ import annotations +from typing import Any + +import numpy as np + +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.library.pulse import Pulse + + +class Waveform(Pulse): + """A pulse specified completely by complex-valued samples; each sample is played for the + duration of the backend cycle-time, dt. + """ + + def __init__( + self, + samples: np.ndarray | list[complex], + name: str | None = None, + epsilon: float = 1e-7, + limit_amplitude: bool | None = None, + ): + """Create new sample pulse command. + + Args: + samples: Complex array of the samples in the pulse envelope. + name: Unique name to identify the pulse. + epsilon: Pulse sample norm tolerance for clipping. + If any sample's norm exceeds unity by less than or equal to epsilon + it will be clipped to unit norm. If the sample + norm is greater than 1+epsilon an error will be raised. + limit_amplitude: Passed to parent Pulse + """ + + super().__init__(duration=len(samples), name=name, limit_amplitude=limit_amplitude) + samples = np.asarray(samples, dtype=np.complex128) + self.epsilon = epsilon + self._samples = self._clip(samples, epsilon=epsilon) + + @property + def samples(self) -> np.ndarray: + """Return sample values.""" + return self._samples + + def _clip(self, samples: np.ndarray, epsilon: float = 1e-7) -> np.ndarray: + """If samples are within epsilon of unit norm, clip sample by reducing norm by (1-epsilon). + + If difference is greater than epsilon error is raised. + + Args: + samples: Complex array of the samples in the pulse envelope. + epsilon: Pulse sample norm tolerance for clipping. + If any sample's norm exceeds unity by less than or equal to epsilon + it will be clipped to unit norm. If the sample + norm is greater than 1+epsilon an error will be raised. + + Returns: + Clipped pulse samples. + + Raises: + PulseError: If there exists a pulse sample with a norm greater than 1+epsilon. + """ + samples_norm = np.abs(samples) + to_clip = (samples_norm > 1.0) & (samples_norm <= 1.0 + epsilon) + + if np.any(to_clip): + # first try normalizing by the abs value + clip_where = np.argwhere(to_clip) + clip_angle = np.angle(samples[clip_where]) + clipped_samples = np.exp(1j * clip_angle, dtype=np.complex128) + + # if norm still exceed one subtract epsilon + # required for some platforms + clipped_sample_norms = np.abs(clipped_samples) + to_clip_epsilon = clipped_sample_norms > 1.0 + if np.any(to_clip_epsilon): + clip_where_epsilon = np.argwhere(to_clip_epsilon) + clipped_samples_epsilon = (1 - epsilon) * np.exp( + 1j * clip_angle[clip_where_epsilon], dtype=np.complex128 + ) + clipped_samples[clip_where_epsilon] = clipped_samples_epsilon + + # update samples with clipped values + samples[clip_where] = clipped_samples + samples_norm[clip_where] = np.abs(clipped_samples) + + if np.any(samples_norm > 1.0) and self._limit_amplitude: + amp = np.max(samples_norm) + raise PulseError( + f"Pulse contains sample with norm {amp} greater than 1+epsilon." + " This can be overruled by setting Pulse.limit_amplitude." + ) + + return samples + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return False + + @property + def parameters(self) -> dict[str, Any]: + """Return a dictionary containing the pulse's parameters.""" + return {} + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Waveform): + return NotImplemented + return ( + super().__eq__(other) + and self.samples.shape == other.samples.shape + and np.allclose(self.samples, other.samples, rtol=0, atol=self.epsilon) + ) + + def __hash__(self) -> int: + return hash(self.samples.tobytes()) + + def __repr__(self) -> str: + opt = np.get_printoptions() + np.set_printoptions(threshold=50) + np.set_printoptions(**opt) + name_repr = f", name='{self.name}'" if self.name is not None else "" + return f"{self.__class__.__name__}({repr(self.samples)}{name_repr})" diff --git a/qiskit_dynamics/pulse/macros.py b/qiskit_dynamics/pulse/macros.py new file mode 100644 index 000000000..7025b1565 --- /dev/null +++ b/qiskit_dynamics/pulse/macros.py @@ -0,0 +1,262 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Module for common pulse programming macros.""" +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from qiskit_dynamics.pulse import channels, exceptions, instructions, utils +from qiskit_dynamics.pulse.instruction_schedule_map import InstructionScheduleMap +from qiskit_dynamics.pulse.schedule import Schedule +from qiskit.providers.backend import BackendV2 + + +if TYPE_CHECKING: + from qiskit.transpiler import Target + + +def measure( + qubits: Sequence[int], + backend=None, + inst_map: InstructionScheduleMap | None = None, + meas_map: list[list[int]] | dict[int, list[int]] | None = None, + qubit_mem_slots: dict[int, int] | None = None, + measure_name: str = "measure", +) -> Schedule: + """Return a schedule which measures the requested qubits according to the given + instruction mapping and measure map, or by using the defaults provided by the backend. + + .. note:: + This function internally dispatches schedule generation logic depending on input backend model. + For the :class:`.BackendV1`, it considers conventional :class:`.InstructionScheduleMap` + and utilizes the backend calibration defined for a group of qubits in the `meas_map`. + For the :class:`.BackendV2`, it assembles calibrations of single qubit measurement + defined in the backend target to build a composite measurement schedule for `qubits`. + + By default, the measurement results for each qubit are trivially mapped to the qubit + index. This behavior is overridden by qubit_mem_slots. For instance, to measure + qubit 0 into MemorySlot(1), qubit_mem_slots can be provided as {0: 1}. + + Args: + qubits: List of qubits to be measured. + backend (Union[Backend, BaseBackend]): A backend instance, which contains + hardware-specific data required for scheduling. + inst_map: Mapping of circuit operations to pulse schedules. If None, defaults to the + ``instruction_schedule_map`` of ``backend``. + meas_map: List of sets of qubits that must be measured together. If None, defaults to + the ``meas_map`` of ``backend``. + qubit_mem_slots: Mapping of measured qubit index to classical bit index. + measure_name: Name of the measurement schedule. + + Returns: + A measurement schedule corresponding to the inputs provided. + """ + + # backend is V2. + if isinstance(backend, BackendV2): + + return _measure_v2( + qubits=qubits, + target=backend.target, + meas_map=meas_map or backend.meas_map, + qubit_mem_slots=qubit_mem_slots or dict(zip(qubits, range(len(qubits)))), + measure_name=measure_name, + ) + # backend is V1 or backend is None. + else: + try: + return _measure_v1( + qubits=qubits, + inst_map=inst_map or backend.defaults().instruction_schedule_map, + meas_map=meas_map or backend.configuration().meas_map, + qubit_mem_slots=qubit_mem_slots, + measure_name=measure_name, + ) + except AttributeError as ex: + raise exceptions.PulseError( + "inst_map or meas_map, and backend cannot be None simultaneously" + ) from ex + + +def _measure_v1( + qubits: Sequence[int], + inst_map: InstructionScheduleMap, + meas_map: list[list[int]] | dict[int, list[int]], + qubit_mem_slots: dict[int, int] | None = None, + measure_name: str = "measure", +) -> Schedule: + """Return a schedule which measures the requested qubits according to the given + instruction mapping and measure map, or by using the defaults provided by the backendV1. + + Args: + qubits: List of qubits to be measured. + backend (Union[Backend, BaseBackend]): A backend instance, which contains + hardware-specific data required for scheduling. + inst_map: Mapping of circuit operations to pulse schedules. If None, defaults to the + ``instruction_schedule_map`` of ``backend``. + meas_map: List of sets of qubits that must be measured together. If None, defaults to + the ``meas_map`` of ``backend``. + qubit_mem_slots: Mapping of measured qubit index to classical bit index. + measure_name: Name of the measurement schedule. + Returns: + A measurement schedule corresponding to the inputs provided. + Raises: + PulseError: If both ``inst_map`` or ``meas_map``, and ``backend`` is None. + """ + + schedule = Schedule(name=f"Default measurement schedule for qubits {qubits}") + + if isinstance(meas_map, list): + meas_map = utils.format_meas_map(meas_map) + + measure_groups = set() + for qubit in qubits: + measure_groups.add(tuple(meas_map[qubit])) + for measure_group_qubits in measure_groups: + + unused_mem_slots = ( + set() + if qubit_mem_slots is None + else set(measure_group_qubits) - set(qubit_mem_slots.values()) + ) + + try: + default_sched = inst_map.get(measure_name, measure_group_qubits) + except exceptions.PulseError as ex: + raise exceptions.PulseError( + f"We could not find a default measurement schedule called '{measure_name}'. " + "Please provide another name using the 'measure_name' keyword " + "argument. For assistance, the instructions which are defined are: " + f"{inst_map.instructions}" + ) from ex + for time, inst in default_sched.instructions: + if inst.channel.index not in qubits: + continue + if qubit_mem_slots and isinstance(inst, instructions.Acquire): + if inst.channel.index in qubit_mem_slots: + mem_slot = channels.MemorySlot(qubit_mem_slots[inst.channel.index]) + else: + mem_slot = channels.MemorySlot(unused_mem_slots.pop()) + inst = instructions.Acquire(inst.duration, inst.channel, mem_slot=mem_slot) + # Measurement pulses should only be added if its qubit was measured by the user + schedule = schedule.insert(time, inst) + + return schedule + + +def _measure_v2( + qubits: Sequence[int], + target: Target, + meas_map: list[list[int]] | dict[int, list[int]], + qubit_mem_slots: dict[int, int], + measure_name: str = "measure", +) -> Schedule: + """Return a schedule which measures the requested qubits according to the given + target and measure map, or by using the defaults provided by the backendV2. + + Args: + qubits: List of qubits to be measured. + target: The :class:`~.Target` representing the target backend. + meas_map: List of sets of qubits that must be measured together. + qubit_mem_slots: Mapping of measured qubit index to classical bit index. + measure_name: Name of the measurement schedule. + + Returns: + A measurement schedule corresponding to the inputs provided. + """ + schedule = Schedule(name=f"Default measurement schedule for qubits {qubits}") + + if isinstance(meas_map, list): + meas_map = utils.format_meas_map(meas_map) + meas_group = set() + for qubit in qubits: + meas_group |= set(meas_map[qubit]) + meas_group = sorted(meas_group) + + meas_group_set = set(range(max(meas_group) + 1)) + unassigned_qubit_indices = sorted(set(meas_group) - qubit_mem_slots.keys()) + unassigned_reg_indices = sorted(meas_group_set - set(qubit_mem_slots.values()), reverse=True) + if set(qubit_mem_slots.values()).issubset(meas_group_set): + for qubit in unassigned_qubit_indices: + qubit_mem_slots[qubit] = unassigned_reg_indices.pop() + + for measure_qubit in meas_group: + try: + if measure_qubit in qubits: + default_sched = target.get_calibration(measure_name, (measure_qubit,)).filter( + channels=[ + channels.MeasureChannel(measure_qubit), + channels.AcquireChannel(measure_qubit), + ] + ) + schedule += _schedule_remapping_memory_slot(default_sched, qubit_mem_slots) + except KeyError as ex: + raise exceptions.PulseError( + f"We could not find a default measurement schedule called '{measure_name}'. " + "Please provide another name using the 'measure_name' keyword " + "argument. For assistance, the instructions which are defined are: " + f"{target.instructions}" + ) from ex + return schedule + + +def measure_all(backend) -> Schedule: + """ + Return a Schedule which measures all qubits of the given backend. + + Args: + backend (Union[Backend, BaseBackend]): A backend instance, which contains + hardware-specific data required for scheduling. + + Returns: + A schedule corresponding to the inputs provided. + """ + # backend is V2. + if isinstance(backend, BackendV2): + qubits = list(range(backend.num_qubits)) + else: + qubits = list(range(backend.configuration().n_qubits)) + return measure(qubits=qubits, backend=backend) + + +def _schedule_remapping_memory_slot( + schedule: Schedule, qubit_mem_slots: dict[int, int] +) -> Schedule: + """ + A helper function to overwrite MemorySlot index of :class:`.Acquire` instruction. + + Args: + schedule: A measurement schedule. + qubit_mem_slots: Mapping of measured qubit index to classical bit index. + + Returns: + A measurement schedule with new memory slot index. + """ + new_schedule = Schedule() + for t0, inst in schedule.instructions: + if isinstance(inst, instructions.Acquire): + qubit_index = inst.channel.index + reg_index = qubit_mem_slots.get(qubit_index, qubit_index) + new_schedule.insert( + t0, + instructions.Acquire( + inst.duration, + channels.AcquireChannel(qubit_index), + mem_slot=channels.MemorySlot(reg_index), + ), + inplace=True, + ) + else: + new_schedule.insert(t0, inst, inplace=True) + return new_schedule diff --git a/qiskit_dynamics/pulse/parameter_manager.py b/qiskit_dynamics/pulse/parameter_manager.py new file mode 100644 index 000000000..3a8337a18 --- /dev/null +++ b/qiskit_dynamics/pulse/parameter_manager.py @@ -0,0 +1,473 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. + +# pylint: disable=invalid-name + +""""Management of pulse program parameters. + +Background +========== + +In contrast to ``QuantumCircuit``, in pulse programs, parameter objects can be stored in +multiple places at different layers, for example + +- program variables: ``ScheduleBlock.alignment_context._context_params`` + +- instruction operands: ``ShiftPhase.phase``, ... + +- operand parameters: ``pulse.parameters``, ``channel.index`` ... + +This complexity is due to the tight coupling of the program to an underlying device Hamiltonian, +i.e. the variance of physical parameters between qubits and their couplings. +If we want to define a program that can be used with arbitrary qubits, +we should be able to parametrize every control parameter in the program. + +Implementation +============== + +Managing parameters in each object within a program, i.e. the ``ParameterTable`` model, +makes the framework quite complicated. With the ``ParameterManager`` class within this module, +the parameter assignment operation is performed by a visitor instance. + +The visitor pattern is a way of separating data processing from the object on which it operates. +This removes the overhead of parameter management from each piece of the program. +The computational complexity of the parameter assignment operation may be increased +from the parameter table model of ~O(1), however, usually, this calculation occurs +only once before the program is executed. Thus this doesn't hurt user experience during +pulse programming. On the contrary, it removes parameter table object and associated logic +from each object, yielding smaller object creation cost and higher performance +as the data amount scales. + +Note that we don't need to write any parameter management logic for each object, +and thus this parameter framework gives greater scalability to the pulse module. +""" +from __future__ import annotations +from copy import copy +from typing import Any, Mapping, Sequence + +from qiskit.circuit.parametervector import ParameterVector, ParameterVectorElement +from qiskit.circuit.parameter import Parameter +from qiskit.circuit.parameterexpression import ParameterExpression, ParameterValueType +from qiskit_dynamics.pulse import instructions, channels +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.library import SymbolicPulse, Waveform +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleBlock +from qiskit_dynamics.pulse.transforms.alignments import AlignmentKind +from qiskit_dynamics.pulse.utils import ( + format_parameter_value, + _validate_parameter_vector, + _validate_parameter_value, +) + + +class NodeVisitor: + """A node visitor base class that walks instruction data in a pulse program and calls + visitor functions for every node. + + Though this class implementation is based on Python AST, each node doesn't have + a dedicated node class due to the lack of an abstract syntax tree for pulse programs in + Qiskit. Instead of parsing pulse programs, this visitor class finds the associated visitor + function based on class name of the instruction node, i.e. ``Play``, ``Call``, etc... + The `.visit` method recursively checks superclass of given node since some parametrized + components such as ``DriveChannel`` may share a common superclass with other subclasses. + In this example, we can just define ``visit_Channel`` method instead of defining + the same visitor function for every subclasses. + + Some instructions may have special logic or data structure to store parameter objects, + and visitor functions for these nodes should be individually defined. + + Because pulse programs can be nested into another pulse program, + the visitor function should be able to recursively call proper visitor functions. + If visitor function is not defined for a given node, ``generic_visit`` + method is called. Usually, this method is provided for operating on object defined + outside of the Qiskit Pulse module. + """ + + def visit(self, node: Any): + """Visit a node.""" + visitor = self._get_visitor(type(node)) + return visitor(node) + + def _get_visitor(self, node_class): + """A helper function to recursively investigate superclass visitor method.""" + if node_class == object: + return self.generic_visit + + try: + return getattr(self, f"visit_{node_class.__name__}") + except AttributeError: + # check super class + return self._get_visitor(node_class.__base__) + + def visit_ScheduleBlock(self, node: ScheduleBlock): + """Visit ``ScheduleBlock``. Recursively visit context blocks and overwrite. + + .. note:: ``ScheduleBlock`` can have parameters in blocks and its alignment. + """ + raise NotImplementedError + + def visit_Schedule(self, node: Schedule): + """Visit ``Schedule``. Recursively visit schedule children and overwrite.""" + raise NotImplementedError + + def generic_visit(self, node: Any): + """Called if no explicit visitor function exists for a node.""" + raise NotImplementedError + + +class ParameterSetter(NodeVisitor): + """Node visitor for parameter binding. + + This visitor is initialized with a dictionary of parameters to be assigned, + and assign values to operands of nodes found. + """ + + def __init__(self, param_map: dict[ParameterExpression, ParameterValueType]): + self._param_map = param_map + + # Top layer: Assign parameters to programs + + def visit_ScheduleBlock(self, node: ScheduleBlock): + """Visit ``ScheduleBlock``. Recursively visit context blocks and overwrite. + + .. note:: ``ScheduleBlock`` can have parameters in blocks and its alignment. + """ + node._alignment_context = self.visit_AlignmentKind(node.alignment_context) + for elm in node._blocks: + self.visit(elm) + + self._update_parameter_manager(node) + return node + + def visit_Schedule(self, node: Schedule): + """Visit ``Schedule``. Recursively visit schedule children and overwrite.""" + # accessing to private member + # TODO: consider updating Schedule to handle this more gracefully + node._Schedule__children = [(t0, self.visit(sched)) for t0, sched in node.instructions] + node._renew_timeslots() + + self._update_parameter_manager(node) + return node + + def visit_AlignmentKind(self, node: AlignmentKind): + """Assign parameters to block's ``AlignmentKind`` specification.""" + new_parameters = tuple(self.visit(param) for param in node._context_params) + node._context_params = new_parameters + + return node + + # Mid layer: Assign parameters to instructions + + def visit_Instruction(self, node: instructions.Instruction): + """Assign parameters to general pulse instruction. + + .. note:: All parametrized object should be stored in the operands. + Otherwise parameter cannot be detected. + """ + if node.is_parameterized(): + node._operands = tuple(self.visit(op) for op in node.operands) + + return node + + # Lower layer: Assign parameters to operands + + def visit_Channel(self, node: channels.Channel): + """Assign parameters to ``Channel`` object.""" + if node.is_parameterized(): + new_index = self._assign_parameter_expression(node.index) + + # validate + if not isinstance(new_index, ParameterExpression): + if not isinstance(new_index, int) or new_index < 0: + raise PulseError("Channel index must be a nonnegative integer") + + # return new instance to prevent accidentally override timeslots without evaluation + return node.__class__(index=new_index) + + return node + + def visit_SymbolicPulse(self, node: SymbolicPulse): + """Assign parameters to ``SymbolicPulse`` object.""" + if node.is_parameterized(): + # Assign duration + if isinstance(node.duration, ParameterExpression): + node.duration = self._assign_parameter_expression(node.duration) + # Assign other parameters + for name in node._params: + pval = node._params[name] + if isinstance(pval, ParameterExpression): + new_val = self._assign_parameter_expression(pval) + node._params[name] = new_val + if not node.disable_validation: + node.validate_parameters() + + return node + + def visit_Waveform(self, node: Waveform): + """Assign parameters to ``Waveform`` object. + + .. node:: No parameter can be assigned to ``Waveform`` object. + """ + return node + + def generic_visit(self, node: Any): + """Assign parameters to object that doesn't belong to Qiskit Pulse module.""" + if isinstance(node, ParameterExpression): + return self._assign_parameter_expression(node) + else: + return node + + def _assign_parameter_expression(self, param_expr: ParameterExpression): + """A helper function to assign parameter value to parameter expression.""" + new_value = copy(param_expr) + updated = param_expr.parameters & self._param_map.keys() + for param in updated: + new_value = new_value.assign(param, self._param_map[param]) + new_value = format_parameter_value(new_value) + return new_value + + def _update_parameter_manager(self, node: Schedule | ScheduleBlock): + """A helper function to update parameter manager of pulse program.""" + if not hasattr(node, "_parameter_manager"): + raise PulseError(f"Node type {node.__class__.__name__} has no parameter manager.") + + param_manager = node._parameter_manager + updated = param_manager.parameters & self._param_map.keys() + + new_parameters = set() + for param in param_manager.parameters: + if param not in updated: + new_parameters.add(param) + continue + new_value = self._param_map[param] + if isinstance(new_value, ParameterExpression): + new_parameters |= new_value.parameters + param_manager._parameters = new_parameters + + +class ParameterGetter(NodeVisitor): + """Node visitor for parameter finding. + + This visitor initializes empty parameter array, and recursively visits nodes + and add parameters found to the array. + """ + + def __init__(self): + self.parameters = set() + + # Top layer: Get parameters from programs + + def visit_ScheduleBlock(self, node: ScheduleBlock): + """Visit ``ScheduleBlock``. Recursively visit context blocks and search parameters. + + .. note:: ``ScheduleBlock`` can have parameters in blocks and its alignment. + """ + # Note that node.parameters returns parameters of main program with subroutines. + # The manager of main program is not aware of parameters in subroutines. + self.parameters |= node._parameter_manager.parameters + + def visit_Schedule(self, node: Schedule): + """Visit ``Schedule``. Recursively visit schedule children and search parameters.""" + self.parameters |= node.parameters + + def visit_AlignmentKind(self, node: AlignmentKind): + """Get parameters from block's ``AlignmentKind`` specification.""" + for param in node._context_params: + if isinstance(param, ParameterExpression): + self.parameters |= param.parameters + + # Mid layer: Get parameters from instructions + + def visit_Instruction(self, node: instructions.Instruction): + """Get parameters from general pulse instruction. + + .. note:: All parametrized object should be stored in the operands. + Otherwise, parameter cannot be detected. + """ + for op in node.operands: + self.visit(op) + + # Lower layer: Get parameters from operands + + def visit_Channel(self, node: channels.Channel): + """Get parameters from ``Channel`` object.""" + self.parameters |= node.parameters + + def visit_SymbolicPulse(self, node: SymbolicPulse): + """Get parameters from ``SymbolicPulse`` object.""" + for op_value in node.parameters.values(): + if isinstance(op_value, ParameterExpression): + self.parameters |= op_value.parameters + + def visit_Waveform(self, node: Waveform): + """Get parameters from ``Waveform`` object. + + .. node:: No parameter can be assigned to ``Waveform`` object. + """ + pass + + def generic_visit(self, node: Any): + """Get parameters from object that doesn't belong to Qiskit Pulse module.""" + if isinstance(node, ParameterExpression): + self.parameters |= node.parameters + + +class ParameterManager: + """Helper class to manage parameter objects associated with arbitrary pulse programs. + + This object is implicitly initialized with the parameter object storage + that stores parameter objects added to the parent pulse program. + + Parameter assignment logic is implemented based on the visitor pattern. + Instruction data and its location are not directly associated with this object. + """ + + disable_parameter_validation = False + + def __init__(self): + """Create new parameter table for pulse programs.""" + self._parameters = set() + + @property + def parameters(self) -> set[Parameter]: + """Parameters which determine the schedule behavior.""" + return self._parameters + + def clear(self): + """Remove the parameters linked to this manager.""" + self._parameters.clear() + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return bool(self.parameters) + + def get_parameters(self, parameter_name: str) -> list[Parameter]: + """Get parameter object bound to this schedule by string name. + + Because different ``Parameter`` objects can have the same name, + this method returns a list of ``Parameter`` s for the provided name. + + Args: + parameter_name: Name of parameter. + + Returns: + Parameter objects that have corresponding name. + """ + return [param for param in self.parameters if param.name == parameter_name] + + def assign_parameters( + self, + pulse_program: Any, + value_dict: dict[ + ParameterExpression | ParameterVector | str, + ParameterValueType | Sequence[ParameterValueType], + ], + ) -> Any: + """Modify and return program data with parameters assigned according to the input. + + Args: + pulse_program: Arbitrary pulse program associated with this manager instance. + value_dict: A mapping from Parameters to either numeric values or another + Parameter expression. + + Returns: + Updated program data. + """ + unrolled_value_dict = self._unroll_param_dict(value_dict) + valid_map = { + k: unrolled_value_dict[k] for k in unrolled_value_dict.keys() & self._parameters + } + if valid_map: + visitor = ParameterSetter(param_map=valid_map) + return visitor.visit(pulse_program) + return pulse_program + + def update_parameter_table(self, new_node: Any): + """A helper function to update parameter table with given data node. + + Args: + new_node: A new data node to be added. + """ + visitor = ParameterGetter() + visitor.visit(new_node) + self._parameters |= visitor.parameters + + def _unroll_param_dict( + self, + parameter_binds: Mapping[ + Parameter | ParameterVector | str, ParameterValueType | Sequence[ParameterValueType] + ], + ) -> Mapping[Parameter, ParameterValueType]: + """ + Unroll parameter dictionary to a map from parameter to value. + + Args: + parameter_binds: A dictionary from parameter to value or a list of values. + + Returns: + A dictionary from parameter to value. + """ + out = {} + param_name_dict = {param.name: [] for param in self.parameters} + for param in self.parameters: + param_name_dict[param.name].append(param) + param_vec_dict = { + param.vector.name: param.vector + for param in self.parameters + if isinstance(param, ParameterVectorElement) + } + for name in param_vec_dict.keys(): + if name in param_name_dict: + param_name_dict[name].append(param_vec_dict[name]) + else: + param_name_dict[name] = [param_vec_dict[name]] + + for parameter, value in parameter_binds.items(): + if isinstance(parameter, ParameterVector): + if not self.disable_parameter_validation: + _validate_parameter_vector(parameter, value) + out.update(zip(parameter, value)) + elif isinstance(parameter, str): + for param in param_name_dict[parameter]: + if not self.disable_parameter_validation: + is_vec = _validate_parameter_value(param, value) + else: + is_vec = isinstance(param, ParameterVector) + if is_vec: + out.update(zip(param, value)) + else: + out[param] = value + elif isinstance(parameter, Sequence): + for param, val in zip(parameter, value): + if isinstance(param, str): + # BindingsArray case + for p in param_name_dict[param]: + if not self.disable_parameter_validation: + is_vec = _validate_parameter_value(p, val) + else: + is_vec = isinstance(p, ParameterVector) + if is_vec: + out.update(zip(p, val)) + else: + out[p] = val + elif isinstance(param, ParameterVector): + if not self.disable_parameter_validation: + _validate_parameter_vector(param, val) + out.update(zip(param, val)) + elif isinstance(param, Parameter): + if not self.disable_parameter_validation: + _validate_parameter_value(param, val) + out[param] = val + + else: + out[parameter] = value + return out diff --git a/qiskit_dynamics/pulse/pulse_to_signals.py b/qiskit_dynamics/pulse/pulse_to_signals.py index 445697766..a474ec83e 100644 --- a/qiskit_dynamics/pulse/pulse_to_signals.py +++ b/qiskit_dynamics/pulse/pulse_to_signals.py @@ -22,7 +22,7 @@ import numpy as np import sympy as sym -from qiskit.pulse import ( +from qiskit_dynamics.pulse import ( Schedule, Play, ShiftPhase, @@ -35,8 +35,8 @@ ControlChannel, AcquireChannel, ) -from qiskit.pulse.exceptions import PulseError -from qiskit.pulse.library import SymbolicPulse +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.library import SymbolicPulse from qiskit import QiskitError from qiskit_dynamics import DYNAMICS_NUMPY as unp @@ -56,9 +56,9 @@ class InstructionToSignals: The :class:`InstructionsToSignals` class converts a pulse schedule to a list of signals that can be given to a model. This conversion is done by calling the :meth:`get_signals` method on a - schedule. The converter applies to instances of :class:`~qiskit.pulse.Schedule`. Instances of - :class:`~qiskit.pulse.ScheduleBlock` must first be converted to :class:`~qiskit.pulse.Schedule` - using the :func:`~qiskit.pulse.transforms.block_to_schedule` function in Qiskit Pulse. + schedule. The converter applies to instances of :class:`~qiskit_dynamics.pulse.Schedule`. Instances of + :class:`~qiskit_dynamics.pulse.ScheduleBlock` must first be converted to :class:`~qiskit_dynamics.pulse.Schedule` + using the :func:`~qiskit_dynamics.pulse.transforms.block_to_schedule` function in Qiskit Pulse. The converter can be initialized with the optional arguments ``carriers`` and ``channels``. When ``channels`` is given, only the signals specified by name in ``channels`` are returned. The @@ -148,9 +148,9 @@ def get_signals(self, schedule: Schedule) -> List[DiscreteSignal]: Args: schedule: The schedule to represent in terms of signals. Instances of - :class:`~qiskit.pulse.ScheduleBlock` must first be converted to - :class:`~qiskit.pulse.Schedule` using the - :func:`~qiskit.pulse.transforms.block_to_schedule` function in Qiskit Pulse. + :class:`~qiskit_dynamics.pulse.ScheduleBlock` must first be converted to + :class:`~qiskit_dynamics.pulse.Schedule` using the + :func:`~qiskit_dynamics.pulse.transforms.block_to_schedule` function in Qiskit Pulse. Returns: A list of :class:`.DiscreteSignal` instances. diff --git a/qiskit_dynamics/pulse/reference_manager.py b/qiskit_dynamics/pulse/reference_manager.py new file mode 100644 index 000000000..c06b4b702 --- /dev/null +++ b/qiskit_dynamics/pulse/reference_manager.py @@ -0,0 +1,58 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Management of schedule block references.""" + +from typing import Tuple +from collections import UserDict +from qiskit_dynamics.pulse.exceptions import PulseError + + +class ReferenceManager(UserDict): + """Dictionary wrapper to manage pulse schedule references.""" + + def unassigned(self) -> Tuple[Tuple[str, ...], ...]: + """Get the keys of unassigned references. + + Returns: + Tuple of reference keys. + """ + keys = [] + for key, value in self.items(): + if value is None: + keys.append(key) + return tuple(keys) + + def __setitem__(self, key, value): + if key in self and self[key] is not None: + # Check subroutine conflict. + if self[key] != value: + raise PulseError( + f"Subroutine {key} is already assigned to the reference of the current scope, " + "however, the newly assigned schedule conflicts with the existing schedule. " + "This operation was not successfully done." + ) + return + super().__setitem__(key, value) + + def __repr__(self): + keys = ", ".join(map(repr, self.keys())) + return f"{self.__class__.__name__}(references=[{keys}])" + + def __str__(self): + out = f"{self.__class__.__name__}:" + for key, reference in self.items(): + prog_repr = repr(reference) + if len(prog_repr) > 50: + prog_repr = prog_repr[:50] + "..." + out += f"\n - {repr(key)}: {prog_repr}" + return out diff --git a/qiskit_dynamics/pulse/schedule.py b/qiskit_dynamics/pulse/schedule.py new file mode 100644 index 000000000..e273f57d8 --- /dev/null +++ b/qiskit_dynamics/pulse/schedule.py @@ -0,0 +1,1863 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +# pylint: disable=cyclic-import + +""" +========= +Schedules +========= + +.. currentmodule:: qiskit.pulse + +Schedules are Pulse programs. They describe instruction sequences for the control hardware. +The Schedule is one of the most fundamental objects to this pulse-level programming module. +A ``Schedule`` is a representation of a *program* in Pulse. Each schedule tracks the time of each +instruction occuring in parallel over multiple signal *channels*. + +.. autosummary:: + :toctree: ../stubs/ + + Schedule + ScheduleBlock +""" +from __future__ import annotations +import abc +import copy +import functools +import itertools +import multiprocessing as mp +import sys +import warnings +from collections.abc import Callable, Iterable +from typing import List, Tuple, Union, Dict, Any, Sequence + +import numpy as np +import rustworkx as rx + +from qiskit.circuit import ParameterVector +from qiskit.circuit.parameter import Parameter +from qiskit.circuit.parameterexpression import ParameterExpression, ParameterValueType +from qiskit_dynamics.pulse.channels import Channel +from qiskit_dynamics.pulse.exceptions import PulseError, UnassignedReferenceError +from qiskit_dynamics.pulse.instructions import Instruction, Reference +from qiskit_dynamics.pulse.utils import instruction_duration_validation +from qiskit_dynamics.pulse.reference_manager import ReferenceManager +from qiskit.utils.multiprocessing import is_main_process +from qiskit.utils import deprecate_arg + + +Interval = Tuple[int, int] +"""An interval type is a tuple of a start time (inclusive) and an end time (exclusive).""" + +TimeSlots = Dict[Channel, List[Interval]] +"""List of timeslots occupied by instructions for each channel.""" + + +class Schedule: + """A quantum program *schedule* with exact time constraints for its instructions, operating + over all input signal *channels* and supporting special syntaxes for building. + + Pulse program representation for the original Qiskit Pulse model [1]. + Instructions are not allowed to overlap in time + on the same channel. This overlap constraint is immediately + evaluated when a new instruction is added to the ``Schedule`` object. + + It is necessary to specify the absolute start time and duration + for each instruction so as to deterministically fix its execution time. + + The ``Schedule`` program supports some syntax sugar for easier programming. + + - Appending an instruction to the end of a channel + + .. code-block:: python + + from qiskit.pulse import Schedule, Gaussian, DriveChannel, Play + sched = Schedule() + sched += Play(Gaussian(160, 0.1, 40), DriveChannel(0)) + + - Appending an instruction shifted in time by a given amount + + .. code-block:: python + + sched = Schedule() + sched += Play(Gaussian(160, 0.1, 40), DriveChannel(0)) << 30 + + - Merge two schedules + + .. code-block:: python + + sched1 = Schedule() + sched1 += Play(Gaussian(160, 0.1, 40), DriveChannel(0)) + + sched2 = Schedule() + sched2 += Play(Gaussian(160, 0.1, 40), DriveChannel(1)) + sched2 = sched1 | sched2 + + A :obj:`.PulseError` is immediately raised when the overlap constraint is violated. + + In the schedule representation, we cannot parametrize the duration of instructions. + Thus, we need to create a new schedule object for each duration. + To parametrize an instruction's duration, the :class:`~qiskit.pulse.ScheduleBlock` + representation may be used instead. + + References: + [1]: https://arxiv.org/abs/2004.06755 + + """ + + # Prefix to use for auto naming. + prefix = "sched" + + # Counter to count instance number. + instances_counter = itertools.count() + + # Disable parameter validation. If set to True, there will not be any validation of + # parameters in the schedule. This is useful if schedule is used within a jit-compiled + # function. + disable_parameter_validation = False + + def __init__( + self, + *schedules: "ScheduleComponent" | tuple[int, "ScheduleComponent"], + name: str | None = None, + metadata: dict | None = None, + ): + """Create an empty schedule. + + Args: + *schedules: Child Schedules of this parent Schedule. May either be passed as + the list of schedules, or a list of ``(start_time, schedule)`` pairs. + name: Name of this schedule. Defaults to an autogenerated string if not provided. + metadata: Arbitrary key value metadata to associate with the schedule. This gets + stored as free-form data in a dict in the + :attr:`~qiskit.pulse.Schedule.metadata` attribute. It will not be directly + used in the schedule. + Raises: + TypeError: if metadata is not a dict. + """ + from qiskit.pulse.parameter_manager import ParameterManager + + if name is None: + name = self.prefix + str(next(self.instances_counter)) + if sys.platform != "win32" and not is_main_process(): + name += f"-{mp.current_process().pid}" + + self._name = name + ParameterManager.disable_parameter_validation = self.disable_parameter_validation + self._parameter_manager = ParameterManager() + + if not isinstance(metadata, dict) and metadata is not None: + raise TypeError("Only a dictionary or None is accepted for schedule metadata") + self._metadata = metadata or {} + + self._duration = 0 + + # These attributes are populated by ``_mutable_insert`` + self._timeslots: TimeSlots = {} + self._children: list[tuple[int, "ScheduleComponent"]] = [] + for sched_pair in schedules: + try: + time, sched = sched_pair + except TypeError: + # recreate as sequence starting at 0. + time, sched = 0, sched_pair + self._mutable_insert(time, sched) + + @classmethod + def initialize_from(cls, other_program: Any, name: str | None = None) -> "Schedule": + """Create new schedule object with metadata of another schedule object. + + Args: + other_program: Qiskit program that provides metadata to new object. + name: Name of new schedule. Name of ``schedule`` is used by default. + + Returns: + New schedule object with name and metadata. + + Raises: + PulseError: When `other_program` does not provide necessary information. + """ + try: + name = name or other_program.name + + if other_program.metadata: + metadata = other_program.metadata.copy() + else: + metadata = None + + return cls(name=name, metadata=metadata) + except AttributeError as ex: + raise PulseError( + f"{cls.__name__} cannot be initialized from the program data " + f"{other_program.__class__.__name__}." + ) from ex + + @property + def name(self) -> str: + """Name of this Schedule""" + return self._name + + @property + def metadata(self) -> dict[str, Any]: + """The user provided metadata associated with the schedule. + + User provided ``dict`` of metadata for the schedule. + The metadata contents do not affect the semantics of the program + but are used to influence the execution of the schedule. It is expected + to be passed between all transforms of the schedule and that providers + will associate any schedule metadata with the results it returns from the + execution of that schedule. + """ + return self._metadata + + @metadata.setter + def metadata(self, metadata): + """Update the schedule metadata""" + if not isinstance(metadata, dict) and metadata is not None: + raise TypeError("Only a dictionary or None is accepted for schedule metadata") + self._metadata = metadata or {} + + @property + def timeslots(self) -> TimeSlots: + """Time keeping attribute.""" + return self._timeslots + + @property + def duration(self) -> int: + """Duration of this schedule.""" + return self._duration + + @property + def start_time(self) -> int: + """Starting time of this schedule.""" + return self.ch_start_time(*self.channels) + + @property + def stop_time(self) -> int: + """Stopping time of this schedule.""" + return self.duration + + @property + def channels(self) -> tuple[Channel, ...]: + """Returns channels that this schedule uses.""" + return tuple(self._timeslots.keys()) + + @property + def children(self) -> tuple[tuple[int, "ScheduleComponent"], ...]: + """Return the child schedule components of this ``Schedule`` in the + order they were added to the schedule. + + Notes: + Nested schedules are returned as-is. If you want to collect only instructions, + use :py:meth:`~Schedule.instructions` instead. + + Returns: + A tuple, where each element is a two-tuple containing the initial + scheduled time of each ``NamedValue`` and the component + itself. + """ + return tuple(self._children) + + @property + def instructions(self) -> tuple[tuple[int, Instruction], ...]: + """Get the time-ordered instructions from self.""" + + def key(time_inst_pair): + inst = time_inst_pair[1] + return time_inst_pair[0], inst.duration, sorted(chan.name for chan in inst.channels) + + return tuple(sorted(self._instructions(), key=key)) + + @property + def parameters(self) -> set[Parameter]: + """Parameters which determine the schedule behavior.""" + return self._parameter_manager.parameters + + def ch_duration(self, *channels: Channel) -> int: + """Return the time of the end of the last instruction over the supplied channels. + + Args: + *channels: Channels within ``self`` to include. + """ + return self.ch_stop_time(*channels) + + def ch_start_time(self, *channels: Channel) -> int: + """Return the time of the start of the first instruction over the supplied channels. + + Args: + *channels: Channels within ``self`` to include. + """ + try: + chan_intervals = (self._timeslots[chan] for chan in channels if chan in self._timeslots) + return min(intervals[0][0] for intervals in chan_intervals) + except ValueError: + # If there are no instructions over channels + return 0 + + def ch_stop_time(self, *channels: Channel) -> int: + """Return maximum start time over supplied channels. + + Args: + *channels: Channels within ``self`` to include. + """ + try: + chan_intervals = (self._timeslots[chan] for chan in channels if chan in self._timeslots) + return max(intervals[-1][1] for intervals in chan_intervals) + except ValueError: + # If there are no instructions over channels + return 0 + + def _instructions(self, time: int = 0): + """Iterable for flattening Schedule tree. + + Args: + time: Shifted time due to parent. + + Yields: + Iterable[Tuple[int, Instruction]]: Tuple containing the time each + :class:`~qiskit.pulse.Instruction` + starts at and the flattened :class:`~qiskit.pulse.Instruction` s. + """ + for insert_time, child_sched in self.children: + yield from child_sched._instructions(time + insert_time) + + def shift(self, time: int, name: str | None = None, inplace: bool = False) -> "Schedule": + """Return a schedule shifted forward by ``time``. + + Args: + time: Time to shift by. + name: Name of the new schedule. Defaults to the name of self. + inplace: Perform operation inplace on this schedule. Otherwise + return a new ``Schedule``. + """ + if inplace: + return self._mutable_shift(time) + return self._immutable_shift(time, name=name) + + def _immutable_shift(self, time: int, name: str | None = None) -> "Schedule": + """Return a new schedule shifted forward by `time`. + + Args: + time: Time to shift by + name: Name of the new schedule if call was mutable. Defaults to name of self + """ + shift_sched = Schedule.initialize_from(self, name) + shift_sched.insert(time, self, inplace=True) + + return shift_sched + + def _mutable_shift(self, time: int) -> "Schedule": + """Return this schedule shifted forward by `time`. + + Args: + time: Time to shift by + + Raises: + PulseError: if ``time`` is not an integer. + """ + if not isinstance(time, int): + raise PulseError("Schedule start time must be an integer.") + + timeslots = {} + for chan, ch_timeslots in self._timeslots.items(): + timeslots[chan] = [(ts[0] + time, ts[1] + time) for ts in ch_timeslots] + + _check_nonnegative_timeslot(timeslots) + + self._duration = self._duration + time + self._timeslots = timeslots + self._children = [(orig_time + time, child) for orig_time, child in self.children] + return self + + def insert( + self, + start_time: int, + schedule: "ScheduleComponent", + name: str | None = None, + inplace: bool = False, + ) -> "Schedule": + """Return a new schedule with ``schedule`` inserted into ``self`` at ``start_time``. + + Args: + start_time: Time to insert the schedule. + schedule: Schedule to insert. + name: Name of the new schedule. Defaults to the name of self. + inplace: Perform operation inplace on this schedule. Otherwise + return a new ``Schedule``. + """ + if inplace: + return self._mutable_insert(start_time, schedule) + return self._immutable_insert(start_time, schedule, name=name) + + def _mutable_insert(self, start_time: int, schedule: "ScheduleComponent") -> "Schedule": + """Mutably insert `schedule` into `self` at `start_time`. + + Args: + start_time: Time to insert the second schedule. + schedule: Schedule to mutably insert. + """ + self._add_timeslots(start_time, schedule) + self._children.append((start_time, schedule)) + self._parameter_manager.update_parameter_table(schedule) + return self + + def _immutable_insert( + self, + start_time: int, + schedule: "ScheduleComponent", + name: str | None = None, + ) -> "Schedule": + """Return a new schedule with ``schedule`` inserted into ``self`` at ``start_time``. + Args: + start_time: Time to insert the schedule. + schedule: Schedule to insert. + name: Name of the new ``Schedule``. Defaults to name of ``self``. + """ + new_sched = Schedule.initialize_from(self, name) + new_sched._mutable_insert(0, self) + new_sched._mutable_insert(start_time, schedule) + return new_sched + + def append( + self, schedule: "ScheduleComponent", name: str | None = None, inplace: bool = False + ) -> "Schedule": + r"""Return a new schedule with ``schedule`` inserted at the maximum time over + all channels shared between ``self`` and ``schedule``. + + .. math:: + + t = \textrm{max}(\texttt{x.stop_time} |\texttt{x} \in + \texttt{self.channels} \cap \texttt{schedule.channels}) + + Args: + schedule: Schedule to be appended. + name: Name of the new ``Schedule``. Defaults to name of ``self``. + inplace: Perform operation inplace on this schedule. Otherwise + return a new ``Schedule``. + """ + common_channels = set(self.channels) & set(schedule.channels) + time = self.ch_stop_time(*common_channels) + return self.insert(time, schedule, name=name, inplace=inplace) + + def filter( + self, + *filter_funcs: Callable, + channels: Iterable[Channel] | None = None, + instruction_types: Iterable[abc.ABCMeta] | abc.ABCMeta = None, + time_ranges: Iterable[tuple[int, int]] | None = None, + intervals: Iterable[Interval] | None = None, + check_subroutine: bool = True, + ) -> "Schedule": + """Return a new ``Schedule`` with only the instructions from this ``Schedule`` which pass + though the provided filters; i.e. an instruction will be retained iff every function in + ``filter_funcs`` returns ``True``, the instruction occurs on a channel type contained in + ``channels``, the instruction type is contained in ``instruction_types``, and the period + over which the instruction operates is *fully* contained in one specified in + ``time_ranges`` or ``intervals``. + + If no arguments are provided, ``self`` is returned. + + Args: + filter_funcs: A list of Callables which take a (int, Union['Schedule', Instruction]) + tuple and return a bool. + channels: For example, ``[DriveChannel(0), AcquireChannel(0)]``. + instruction_types: For example, ``[PulseInstruction, AcquireInstruction]``. + time_ranges: For example, ``[(0, 5), (6, 10)]``. + intervals: For example, ``[(0, 5), (6, 10)]``. + check_subroutine: Set `True` to individually filter instructions inside of a subroutine + defined by the :py:class:`~qiskit.pulse.instructions.Call` instruction. + """ + from qiskit.pulse.filters import composite_filter, filter_instructions + + filters = composite_filter(channels, instruction_types, time_ranges, intervals) + filters.extend(filter_funcs) + + return filter_instructions( + self, filters=filters, negate=False, recurse_subroutines=check_subroutine + ) + + def exclude( + self, + *filter_funcs: Callable, + channels: Iterable[Channel] | None = None, + instruction_types: Iterable[abc.ABCMeta] | abc.ABCMeta = None, + time_ranges: Iterable[tuple[int, int]] | None = None, + intervals: Iterable[Interval] | None = None, + check_subroutine: bool = True, + ) -> "Schedule": + """Return a ``Schedule`` with only the instructions from this Schedule *failing* + at least one of the provided filters. + This method is the complement of :py:meth:`~Schedule.filter`, so that:: + + self.filter(args) | self.exclude(args) == self + + Args: + filter_funcs: A list of Callables which take a (int, Union['Schedule', Instruction]) + tuple and return a bool. + channels: For example, ``[DriveChannel(0), AcquireChannel(0)]``. + instruction_types: For example, ``[PulseInstruction, AcquireInstruction]``. + time_ranges: For example, ``[(0, 5), (6, 10)]``. + intervals: For example, ``[(0, 5), (6, 10)]``. + check_subroutine: Set `True` to individually filter instructions inside of a subroutine + defined by the :py:class:`~qiskit.pulse.instructions.Call` instruction. + """ + from qiskit.pulse.filters import composite_filter, filter_instructions + + filters = composite_filter(channels, instruction_types, time_ranges, intervals) + filters.extend(filter_funcs) + + return filter_instructions( + self, filters=filters, negate=True, recurse_subroutines=check_subroutine + ) + + def _add_timeslots(self, time: int, schedule: "ScheduleComponent") -> None: + """Update all time tracking within this schedule based on the given schedule. + + Args: + time: The time to insert the schedule into self. + schedule: The schedule to insert into self. + + Raises: + PulseError: If timeslots overlap or an invalid start time is provided. + """ + if not np.issubdtype(type(time), np.integer): + raise PulseError("Schedule start time must be an integer.") + + other_timeslots = _get_timeslots(schedule) + self._duration = max(self._duration, time + schedule.duration) + + for channel in schedule.channels: + if channel not in self._timeslots: + if time == 0: + self._timeslots[channel] = copy.copy(other_timeslots[channel]) + else: + self._timeslots[channel] = [ + (i[0] + time, i[1] + time) for i in other_timeslots[channel] + ] + continue + + for idx, interval in enumerate(other_timeslots[channel]): + if interval[0] + time >= self._timeslots[channel][-1][1]: + # Can append the remaining intervals + self._timeslots[channel].extend( + [(i[0] + time, i[1] + time) for i in other_timeslots[channel][idx:]] + ) + break + + try: + interval = (interval[0] + time, interval[1] + time) + index = _find_insertion_index(self._timeslots[channel], interval) + self._timeslots[channel].insert(index, interval) + except PulseError as ex: + raise PulseError( + f"Schedule(name='{schedule.name or ''}') cannot be inserted into " + f"Schedule(name='{self.name or ''}') at " + f"time {time} because its instruction on channel {channel} scheduled from time " + f"{interval[0]} to {interval[1]} overlaps with an existing instruction." + ) from ex + + _check_nonnegative_timeslot(self._timeslots) + + def _remove_timeslots(self, time: int, schedule: "ScheduleComponent"): + """Delete the timeslots if present for the respective schedule component. + + Args: + time: The time to remove the timeslots for the ``schedule`` component. + schedule: The schedule to insert into self. + + Raises: + PulseError: If timeslots overlap or an invalid start time is provided. + """ + if not isinstance(time, int): + raise PulseError("Schedule start time must be an integer.") + + for channel in schedule.channels: + + if channel not in self._timeslots: + raise PulseError(f"The channel {channel} is not present in the schedule") + + channel_timeslots = self._timeslots[channel] + other_timeslots = _get_timeslots(schedule) + + for interval in other_timeslots[channel]: + if channel_timeslots: + interval = (interval[0] + time, interval[1] + time) + index = _interval_index(channel_timeslots, interval) + if channel_timeslots[index] == interval: + channel_timeslots.pop(index) + continue + + raise PulseError( + f"Cannot find interval ({interval[0]}, {interval[1]}) to remove from " + f"channel {channel} in Schedule(name='{schedule.name}')." + ) + + if not channel_timeslots: + self._timeslots.pop(channel) + + def _replace_timeslots(self, time: int, old: "ScheduleComponent", new: "ScheduleComponent"): + """Replace the timeslots of ``old`` if present with the timeslots of ``new``. + + Args: + time: The time to remove the timeslots for the ``schedule`` component. + old: Instruction to replace. + new: Instruction to replace with. + """ + self._remove_timeslots(time, old) + self._add_timeslots(time, new) + + def _renew_timeslots(self): + """Regenerate timeslots based on current instructions.""" + self._timeslots.clear() + for t0, inst in self.instructions: + self._add_timeslots(t0, inst) + + def replace( + self, + old: "ScheduleComponent", + new: "ScheduleComponent", + inplace: bool = False, + ) -> "Schedule": + """Return a ``Schedule`` with the ``old`` instruction replaced with a ``new`` + instruction. + + The replacement matching is based on an instruction equality check. + + .. code-block:: + + from qiskit import pulse + + d0 = pulse.DriveChannel(0) + + sched = pulse.Schedule() + + old = pulse.Play(pulse.Constant(100, 1.0), d0) + new = pulse.Play(pulse.Constant(100, 0.1), d0) + + sched += old + + sched = sched.replace(old, new) + + assert sched == pulse.Schedule(new) + + Only matches at the top-level of the schedule tree. If you wish to + perform this replacement over all instructions in the schedule tree. + Flatten the schedule prior to running:: + + .. code-block:: + + sched = pulse.Schedule() + + sched += pulse.Schedule(old) + + sched = sched.replace(old, new) + + assert sched == pulse.Schedule(new) + + Args: + old: Instruction to replace. + new: Instruction to replace with. + inplace: Replace instruction by mutably modifying this ``Schedule``. + + Returns: + The modified schedule with ``old`` replaced by ``new``. + + Raises: + PulseError: If the ``Schedule`` after replacements will has a timing overlap. + """ + from qiskit.pulse.parameter_manager import ParameterManager + + new_children = [] + new_parameters = ParameterManager() + + for time, child in self.children: + if child == old: + new_children.append((time, new)) + new_parameters.update_parameter_table(new) + else: + new_children.append((time, child)) + new_parameters.update_parameter_table(child) + + if inplace: + self._children = new_children + self._parameter_manager = new_parameters + self._renew_timeslots() + return self + else: + try: + new_sched = Schedule.initialize_from(self) + for time, inst in new_children: + new_sched.insert(time, inst, inplace=True) + return new_sched + except PulseError as err: + raise PulseError( + f"Replacement of {old} with {new} results in overlapping instructions." + ) from err + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return self._parameter_manager.is_parameterized() + + def assign_parameters( + self, + value_dict: dict[ + Parameter | ParameterVector | str | Sequence[str | Parameter | ParameterVector], + ParameterValueType | Sequence[ParameterValueType | Sequence[ParameterValueType]], + ], + inplace: bool = True, + ) -> "Schedule": + """Assign the parameters in this schedule according to the input. + + Args: + value_dict: A mapping from parameters or parameter names (parameter vector + or parameter vector name) to either numeric values (list of numeric values) + or another parameter expression (list of parameter expressions). + inplace: Set ``True`` to override this instance with new parameter. + + Returns: + Schedule with updated parameters. + """ + if not inplace: + new_schedule = copy.deepcopy(self) + return new_schedule.assign_parameters(value_dict, inplace=True) + + return self._parameter_manager.assign_parameters(pulse_program=self, value_dict=value_dict) + + def get_parameters(self, parameter_name: str) -> list[Parameter]: + """Get parameter object bound to this schedule by string name. + + Because different ``Parameter`` objects can have the same name, + this method returns a list of ``Parameter`` s for the provided name. + + Args: + parameter_name: Name of parameter. + + Returns: + Parameter objects that have corresponding name. + """ + return self._parameter_manager.get_parameters(parameter_name) + + def __len__(self) -> int: + """Return number of instructions in the schedule.""" + return len(self.instructions) + + def __add__(self, other: "ScheduleComponent") -> "Schedule": + """Return a new schedule with ``other`` inserted within ``self`` at ``start_time``.""" + return self.append(other) + + def __or__(self, other: "ScheduleComponent") -> "Schedule": + """Return a new schedule which is the union of `self` and `other`.""" + return self.insert(0, other) + + def __lshift__(self, time: int) -> "Schedule": + """Return a new schedule which is shifted forward by ``time``.""" + return self.shift(time) + + def __eq__(self, other: object) -> bool: + """Test if two Schedule are equal. + + Equality is checked by verifying there is an equal instruction at every time + in ``other`` for every instruction in this ``Schedule``. + + .. warning:: + + This does not check for logical equivalency. Ie., + + ```python + >>> Delay(10, DriveChannel(0)) + Delay(10, DriveChannel(0)) + == Delay(20, DriveChannel(0)) + False + ``` + """ + # 0. type check, we consider Instruction is a subtype of schedule + if not isinstance(other, (type(self), Instruction)): + return False + + # 1. channel check + if set(self.channels) != set(other.channels): + return False + + # 2. size check + if len(self.instructions) != len(other.instructions): + return False + + # 3. instruction check + return all( + self_inst == other_inst + for self_inst, other_inst in zip(self.instructions, other.instructions) + ) + + def __repr__(self) -> str: + name = format(self._name) if self._name else "" + instructions = ", ".join([repr(instr) for instr in self.instructions[:50]]) + if len(self.instructions) > 25: + instructions += ", ..." + return f'{self.__class__.__name__}({instructions}, name="{name}")' + + +def _require_schedule_conversion(function: Callable) -> Callable: + """A method decorator to convert schedule block to pulse schedule. + + This conversation is performed for backward compatibility only if all durations are assigned. + """ + + @functools.wraps(function) + def wrapper(self, *args, **kwargs): + from qiskit.pulse.transforms import block_to_schedule + + return function(block_to_schedule(self), *args, **kwargs) + + return wrapper + + +class ScheduleBlock: + """Time-ordered sequence of instructions with alignment context. + + :class:`.ScheduleBlock` supports lazy scheduling of context instructions, + i.e. their timeslots is always generated at runtime. + This indicates we can parametrize instruction durations as well as + other parameters. In contrast to :class:`.Schedule` being somewhat static, + :class:`.ScheduleBlock` is a dynamic representation of a pulse program. + + .. rubric:: Pulse Builder + + The Qiskit pulse builder is a domain specific language that is developed on top of + the schedule block. Use of the builder syntax will improve the workflow of + pulse programming. See :ref:`pulse_builder` for a user guide. + + .. rubric:: Alignment contexts + + A schedule block is always relatively scheduled. + Instead of taking individual instructions with absolute execution time ``t0``, + the schedule block defines a context of scheduling and instructions + under the same context are scheduled in the same manner (alignment). + Several contexts are available in :ref:`pulse_alignments`. + A schedule block is instantiated with one of these alignment contexts. + The default context is :class:`AlignLeft`, for which all instructions are left-justified, + in other words, meaning they use as-soon-as-possible scheduling. + + If you need an absolute-time interval in between instructions, you can explicitly + insert :class:`~qiskit.pulse.instructions.Delay` instructions. + + .. rubric:: Nested blocks + + A schedule block can contain other nested blocks with different alignment contexts. + This enables advanced scheduling, where a subset of instructions is + locally scheduled in a different manner. + Note that a :class:`.Schedule` instance cannot be directly added to a schedule block. + To add a :class:`.Schedule` instance, wrap it in a :class:`.Call` instruction. + This is implicitly performed when a schedule is added through the :ref:`pulse_builder`. + + .. rubric:: Unsupported operations + + Because the schedule block representation lacks timeslots, it cannot + perform particular :class:`.Schedule` operations such as :meth:`insert` or :meth:`shift` that + require instruction start time ``t0``. + In addition, :meth:`exclude` and :meth:`filter` methods are not supported + because these operations may identify the target instruction with ``t0``. + Except for these operations, :class:`.ScheduleBlock` provides full compatibility + with :class:`.Schedule`. + + .. rubric:: Subroutine + + The timeslots-free representation offers much greater flexibility for writing pulse programs. + Because :class:`.ScheduleBlock` only cares about the ordering of the child blocks + we can add an undefined pulse sequence as a subroutine of the main program. + If your program contains the same sequence multiple times, this representation may + reduce the memory footprint required by the program construction. + Such a subroutine is realized by the special compiler directive + :class:`~qiskit.pulse.instructions.Reference` that is defined by + a unique set of reference key strings to the subroutine. + The (executable) subroutine is separately stored in the main program. + Appended reference directives are resolved when the main program is executed. + Subroutines must be assigned through :meth:`assign_references` before execution. + + One way to reference a subroutine in a schedule is to use the pulse + builder's :func:`~qiskit.pulse.builder.reference` function to declare an + unassigned reference. In this example, the program is called with the + reference key "grand_child". You can call a subroutine without specifying + a substantial program. + + .. code-block:: + + from qiskit import pulse + from qiskit.circuit.parameter import Parameter + + amp1 = Parameter("amp1") + amp2 = Parameter("amp2") + + with pulse.build() as sched_inner: + pulse.play(pulse.Constant(100, amp1), pulse.DriveChannel(0)) + + with pulse.build() as sched_outer: + with pulse.align_right(): + pulse.reference("grand_child") + pulse.play(pulse.Constant(200, amp2), pulse.DriveChannel(0)) + + # Now assign the inner pulse program to this reference + sched_outer.assign_references({("grand_child",): sched_inner}) + print(sched_outer.parameters) + + .. parsed-literal:: + + {Parameter(amp1), Parameter(amp2)} + + The outer program now has the parameter ``amp2`` from the inner program, + indicating that the inner program's data has been made available to the + outer program. + The program calling the "grand_child" has a reference program description + which is accessed through :attr:`ScheduleBlock.references`. + + .. code-block:: + + print(sched_outer.references) + + .. parsed-literal:: + + ReferenceManager: + - ('grand_child',): ScheduleBlock(Play(Constant(duration=100, amp=amp1,... + + Finally, you may want to call this program from another program. + Here we try a different approach to define subroutine. Namely, we call + a subroutine from the root program with the actual program ``sched2``. + + .. code-block:: + + amp3 = Parameter("amp3") + + with pulse.build() as main: + pulse.play(pulse.Constant(300, amp3), pulse.DriveChannel(0)) + pulse.call(sched_outer, name="child") + + print(main.parameters) + + .. parsed-literal:: + + {Parameter(amp1), Parameter(amp2), Parameter(amp3} + + This implicitly creates a reference named "child" within + the root program and assigns ``sched_outer`` to it. + + Note that the root program is only aware of its direct references. + + .. code-block:: + + print(main.references) + + .. parsed-literal:: + + ReferenceManager: + - ('child',): ScheduleBlock(ScheduleBlock(ScheduleBlock(Play(Con... + + As you can see the main program cannot directly assign a subroutine to the "grand_child" because + this subroutine is not called within the root program, i.e. it is indirectly called by "child". + However, the returned :class:`.ReferenceManager` is a dict-like object, and you can still + reach to "grand_child" via the "child" program with the following chained dict access. + + .. code-block:: + + main.references[("child", )].references[("grand_child", )] + + Note that :attr:`ScheduleBlock.parameters` still collects all parameters + also from the subroutine once it's assigned. + """ + + __slots__ = ( + "_parent", + "_name", + "_reference_manager", + "_parameter_manager", + "_alignment_context", + "_blocks", + "_metadata", + ) + + # Prefix to use for auto naming. + prefix = "block" + + # Counter to count instance number. + instances_counter = itertools.count() + + # Disable parameter validation. If set to True, there will not be any validation of + # parameters in the schedule. This is useful if schedule is used within a jit-compiled + # function. + disable_parameter_validation = False + + def __init__( + self, name: str | None = None, metadata: dict | None = None, alignment_context=None + ): + """Create an empty schedule block. + + Args: + name: Name of this schedule. Defaults to an autogenerated string if not provided. + metadata: Arbitrary key value metadata to associate with the schedule. This gets + stored as free-form data in a dict in the + :attr:`~qiskit.pulse.ScheduleBlock.metadata` attribute. It will not be directly + used in the schedule. + alignment_context (AlignmentKind): ``AlignmentKind`` instance that manages + scheduling of instructions in this block. + + Raises: + TypeError: if metadata is not a dict. + """ + from qiskit.pulse.parameter_manager import ParameterManager + from qiskit.pulse.transforms import AlignLeft + + if name is None: + name = self.prefix + str(next(self.instances_counter)) + if sys.platform != "win32" and not is_main_process(): + name += f"-{mp.current_process().pid}" + + # This points to the parent schedule object in the current scope. + # Note that schedule block can be nested without referencing, e.g. .append(child_block), + # and parent=None indicates the root program of the current scope. + # The nested schedule block objects should not have _reference_manager and + # should refer to the one of the root program. + # This also means referenced program should be assigned to the root program, not to child. + self._parent: ScheduleBlock | None = None + + self._name = name + ParameterManager.disable_parameter_validation = self.disable_parameter_validation + self._parameter_manager = ParameterManager() + self._reference_manager = ReferenceManager() + self._alignment_context = alignment_context or AlignLeft() + self._blocks: list["BlockComponent"] = [] + + # get parameters from context + self._parameter_manager.update_parameter_table(self._alignment_context) + + if not isinstance(metadata, dict) and metadata is not None: + raise TypeError("Only a dictionary or None is accepted for schedule metadata") + self._metadata = metadata or {} + + @classmethod + def initialize_from(cls, other_program: Any, name: str | None = None) -> "ScheduleBlock": + """Create new schedule object with metadata of another schedule object. + + Args: + other_program: Qiskit program that provides metadata to new object. + name: Name of new schedule. Name of ``block`` is used by default. + + Returns: + New block object with name and metadata. + + Raises: + PulseError: When ``other_program`` does not provide necessary information. + """ + try: + name = name or other_program.name + + if other_program.metadata: + metadata = other_program.metadata.copy() + else: + metadata = None + + try: + alignment_context = other_program.alignment_context + except AttributeError: + alignment_context = None + + return cls(name=name, metadata=metadata, alignment_context=alignment_context) + except AttributeError as ex: + raise PulseError( + f"{cls.__name__} cannot be initialized from the program data " + f"{other_program.__class__.__name__}." + ) from ex + + @property + def name(self) -> str: + """Return name of this schedule""" + return self._name + + @property + def metadata(self) -> dict[str, Any]: + """The user provided metadata associated with the schedule. + + User provided ``dict`` of metadata for the schedule. + The metadata contents do not affect the semantics of the program + but are used to influence the execution of the schedule. It is expected + to be passed between all transforms of the schedule and that providers + will associate any schedule metadata with the results it returns from the + execution of that schedule. + """ + return self._metadata + + @metadata.setter + def metadata(self, metadata): + """Update the schedule metadata""" + if not isinstance(metadata, dict) and metadata is not None: + raise TypeError("Only a dictionary or None is accepted for schedule metadata") + self._metadata = metadata or {} + + @property + def alignment_context(self): + """Return alignment instance that allocates block component to generate schedule.""" + return self._alignment_context + + def is_schedulable(self) -> bool: + """Return ``True`` if all durations are assigned.""" + # check context assignment + for context_param in self._alignment_context._context_params: + if isinstance(context_param, ParameterExpression): + return False + + # check duration assignment + for elm in self.blocks: + if isinstance(elm, ScheduleBlock): + if not elm.is_schedulable(): + return False + else: + try: + if not isinstance(elm.duration, int): + return False + except UnassignedReferenceError: + return False + return True + + @property + @_require_schedule_conversion + def duration(self) -> int: + """Duration of this schedule block.""" + return self.duration + + @property + def channels(self) -> tuple[Channel, ...]: + """Returns channels that this schedule block uses.""" + chans: set[Channel] = set() + for elm in self.blocks: + if isinstance(elm, Reference): + raise UnassignedReferenceError( + f"This schedule contains unassigned reference {elm.ref_keys} " + "and channels are ambiguous. Please assign the subroutine first." + ) + chans = chans | set(elm.channels) + return tuple(chans) + + @property + @_require_schedule_conversion + def instructions(self) -> tuple[tuple[int, Instruction]]: + """Get the time-ordered instructions from self.""" + return self.instructions + + @property + def blocks(self) -> tuple["BlockComponent", ...]: + """Get the block elements added to self. + + .. note:: + + The sequence of elements is returned in order of addition. Because the first element is + schedule first, e.g. FIFO, the returned sequence is roughly time-ordered. + However, in the parallel alignment context, especially in + the as-late-as-possible scheduling, or :class:`.AlignRight` context, + the actual timing of when the instructions are issued is unknown until + the :class:`.ScheduleBlock` is scheduled and converted into a :class:`.Schedule`. + """ + blocks = [] + for elm in self._blocks: + if isinstance(elm, Reference): + elm = self.references.get(elm.ref_keys, None) or elm + blocks.append(elm) + return tuple(blocks) + + @property + def parameters(self) -> set[Parameter]: + """Return unassigned parameters with raw names.""" + # Need new object not to mutate parameter_manager.parameters + out_params = set() + + out_params |= self._parameter_manager.parameters + for subroutine in self.references.values(): + if subroutine is None: + continue + out_params |= subroutine.parameters + + return out_params + + @property + def references(self) -> ReferenceManager: + """Return a reference manager of the current scope.""" + if self._parent is not None: + return self._parent.references + return self._reference_manager + + @_require_schedule_conversion + def ch_duration(self, *channels: Channel) -> int: + """Return the time of the end of the last instruction over the supplied channels. + + Args: + *channels: Channels within ``self`` to include. + """ + return self.ch_duration(*channels) + + def append( + self, block: "BlockComponent", name: str | None = None, inplace: bool = True + ) -> "ScheduleBlock": + """Return a new schedule block with ``block`` appended to the context block. + The execution time is automatically assigned when the block is converted into schedule. + + Args: + block: ScheduleBlock to be appended. + name: Name of the new ``Schedule``. Defaults to name of ``self``. + inplace: Perform operation inplace on this schedule. Otherwise, + return a new ``Schedule``. + + Returns: + Schedule block with appended schedule. + + Raises: + PulseError: When invalid schedule type is specified. + """ + if not isinstance(block, (ScheduleBlock, Instruction)): + raise PulseError( + f"Appended `schedule` {block.__class__.__name__} is invalid type. " + "Only `Instruction` and `ScheduleBlock` can be accepted." + ) + + if not inplace: + schedule = copy.deepcopy(self) + schedule._name = name or self.name + schedule.append(block, inplace=True) + return schedule + + if isinstance(block, Reference) and block.ref_keys not in self.references: + self.references[block.ref_keys] = None + + elif isinstance(block, ScheduleBlock): + block = copy.deepcopy(block) + # Expose subroutines to the current main scope. + # Note that this 'block' is not called. + # The block is just directly appended to the current scope. + if block.is_referenced(): + if block._parent is not None: + # This is an edge case: + # If this is not a parent, block.references points to the parent's reference + # where subroutine not referred within the 'block' may exist. + # Move only references existing in the 'block'. + # See 'test.python.pulse.test_reference.TestReference.test_appending_child_block' + for ref in _get_references(block._blocks): + self.references[ref.ref_keys] = block.references[ref.ref_keys] + else: + # Avoid using dict.update and explicitly call __set_item__ for validation. + # Reference manager of appended block is cleared because of data reduction. + for ref_keys, ref in block._reference_manager.items(): + self.references[ref_keys] = ref + block._reference_manager.clear() + # Now switch the parent because block is appended to self. + block._parent = self + + self._blocks.append(block) + self._parameter_manager.update_parameter_table(block) + + return self + + def filter( + self, + *filter_funcs: Callable[..., bool], + channels: Iterable[Channel] | None = None, + instruction_types: Iterable[abc.ABCMeta] | abc.ABCMeta = None, + check_subroutine: bool = True, + ): + """Return a new ``ScheduleBlock`` with only the instructions from this ``ScheduleBlock`` + which pass though the provided filters; i.e. an instruction will be retained if + every function in ``filter_funcs`` returns ``True``, the instruction occurs on + a channel type contained in ``channels``, and the instruction type is contained + in ``instruction_types``. + + .. warning:: + Because ``ScheduleBlock`` is not aware of the execution time of + the context instructions, filtering out some instructions may + change the execution time of the remaining instructions. + + If no arguments are provided, ``self`` is returned. + + Args: + filter_funcs: A list of Callables which take a ``Instruction`` and return a bool. + channels: For example, ``[DriveChannel(0), AcquireChannel(0)]``. + instruction_types: For example, ``[PulseInstruction, AcquireInstruction]``. + check_subroutine: Set `True` to individually filter instructions inside a subroutine + defined by the :py:class:`~qiskit.pulse.instructions.Call` instruction. + + Returns: + ``ScheduleBlock`` consisting of instructions that matches with filtering condition. + """ + from qiskit.pulse.filters import composite_filter, filter_instructions + + filters = composite_filter(channels, instruction_types) + filters.extend(filter_funcs) + + return filter_instructions( + self, filters=filters, negate=False, recurse_subroutines=check_subroutine + ) + + def exclude( + self, + *filter_funcs: Callable[..., bool], + channels: Iterable[Channel] | None = None, + instruction_types: Iterable[abc.ABCMeta] | abc.ABCMeta = None, + check_subroutine: bool = True, + ): + """Return a new ``ScheduleBlock`` with only the instructions from this ``ScheduleBlock`` + *failing* at least one of the provided filters. + This method is the complement of :py:meth:`~ScheduleBlock.filter`, so that:: + + self.filter(args) + self.exclude(args) == self in terms of instructions included. + + .. warning:: + Because ``ScheduleBlock`` is not aware of the execution time of + the context instructions, excluding some instructions may + change the execution time of the remaining instructions. + + Args: + filter_funcs: A list of Callables which take a ``Instruction`` and return a bool. + channels: For example, ``[DriveChannel(0), AcquireChannel(0)]``. + instruction_types: For example, ``[PulseInstruction, AcquireInstruction]``. + check_subroutine: Set `True` to individually filter instructions inside of a subroutine + defined by the :py:class:`~qiskit.pulse.instructions.Call` instruction. + + Returns: + ``ScheduleBlock`` consisting of instructions that do not match with + at least one of filtering conditions. + """ + from qiskit.pulse.filters import composite_filter, filter_instructions + + filters = composite_filter(channels, instruction_types) + filters.extend(filter_funcs) + + return filter_instructions( + self, filters=filters, negate=True, recurse_subroutines=check_subroutine + ) + + def replace( + self, + old: "BlockComponent", + new: "BlockComponent", + inplace: bool = True, + ) -> "ScheduleBlock": + """Return a ``ScheduleBlock`` with the ``old`` component replaced with a ``new`` + component. + + Args: + old: Schedule block component to replace. + new: Schedule block component to replace with. + inplace: Replace instruction by mutably modifying this ``ScheduleBlock``. + + Returns: + The modified schedule block with ``old`` replaced by ``new``. + """ + if not inplace: + schedule = copy.deepcopy(self) + return schedule.replace(old, new, inplace=True) + + if old not in self._blocks: + # Avoid unnecessary update of reference and parameter manager + return self + + # Temporarily copies references + all_references = ReferenceManager() + if isinstance(new, ScheduleBlock): + new = copy.deepcopy(new) + all_references.update(new.references) + new._reference_manager.clear() + new._parent = self + for ref_key, subroutine in self.references.items(): + if ref_key in all_references: + warnings.warn( + f"Reference {ref_key} conflicts with substituted program {new.name}. " + "Existing reference has been replaced with new reference.", + UserWarning, + ) + continue + all_references[ref_key] = subroutine + + # Regenerate parameter table by regenerating elements. + # Note that removal of parameters in old is not sufficient, + # because corresponding parameters might be also used in another block element. + self._parameter_manager.clear() + self._parameter_manager.update_parameter_table(self._alignment_context) + + new_elms = [] + for elm in self._blocks: + if elm == old: + elm = new + self._parameter_manager.update_parameter_table(elm) + new_elms.append(elm) + self._blocks = new_elms + + # Regenerate reference table + # Note that reference is attached to the outer schedule if nested. + # Thus, this investigates all references within the scope. + self.references.clear() + root = self + while root._parent is not None: + root = root._parent + for ref in _get_references(root._blocks): + self.references[ref.ref_keys] = all_references[ref.ref_keys] + + return self + + def is_parameterized(self) -> bool: + """Return True iff the instruction is parameterized.""" + return any(self.parameters) + + def is_referenced(self) -> bool: + """Return True iff the current schedule block contains reference to subroutine.""" + return len(self.references) > 0 + + def assign_parameters( + self, + value_dict: dict[ + Parameter | ParameterVector | str | Sequence[str | Parameter | ParameterVector], + ParameterValueType | Sequence[ParameterValueType | Sequence[ParameterValueType]], + ], + inplace: bool = True, + ) -> "ScheduleBlock": + """Assign the parameters in this schedule according to the input. + + Args: + value_dict: A mapping from parameters or parameter names (parameter vector + or parameter vector name) to either numeric values (list of numeric values) + or another parameter expression (list of parameter expressions). + inplace: Set ``True`` to override this instance with new parameter. + + Returns: + Schedule with updated parameters. + + Raises: + PulseError: When the block is nested into another block. + """ + if not inplace: + new_schedule = copy.deepcopy(self) + return new_schedule.assign_parameters(value_dict, inplace=True) + + # Update parameters in the current scope + self._parameter_manager.assign_parameters(pulse_program=self, value_dict=value_dict) + + for subroutine in self._reference_manager.values(): + # Also assigning parameters to the references associated with self. + # Note that references are always stored in the root program. + # So calling assign_parameters from nested block doesn't update references. + if subroutine is None: + continue + subroutine.assign_parameters(value_dict=value_dict, inplace=True) + + return self + + def assign_references( + self, + subroutine_dict: dict[str | tuple[str, ...], "ScheduleBlock"], + inplace: bool = True, + ) -> "ScheduleBlock": + """Assign schedules to references. + + It is only capable of assigning a schedule block to immediate references + which are directly referred within the current scope. + Let's see following example: + + .. code-block:: python + + from qiskit import pulse + + with pulse.build() as nested_prog: + pulse.delay(10, pulse.DriveChannel(0)) + + with pulse.build() as sub_prog: + pulse.reference("A") + + with pulse.build() as main_prog: + pulse.reference("B") + + In above example, the ``main_prog`` can refer to the subroutine "root::B" and the + reference of "B" to program "A", i.e., "B::A", is not defined in the root namespace. + This prevents breaking the reference "root::B::A" by the assignment of "root::B". + For example, if a user could indirectly assign "root::B::A" from the root program, + one can later assign another program to "root::B" that doesn't contain "A" within it. + In this situation, a reference "root::B::A" would still live in + the reference manager of the root. + However, the subroutine "root::B::A" would no longer be used in the actual pulse program. + To assign subroutine "A" to ``nested_prog`` as a nested subprogram of ``main_prog``, + you must first assign "A" of the ``sub_prog``, + and then assign the ``sub_prog`` to the ``main_prog``. + + .. code-block:: python + + sub_prog.assign_references({("A", ): nested_prog}, inplace=True) + main_prog.assign_references({("B", ): sub_prog}, inplace=True) + + Alternatively, you can also write + + .. code-block:: python + + main_prog.assign_references({("B", ): sub_prog}, inplace=True) + main_prog.references[("B", )].assign_references({("A", ): nested_prog}, inplace=True) + + Here :attr:`.references` returns a dict-like object, and you can + mutably update the nested reference of the particular subroutine. + + .. note:: + + Assigned programs are deep-copied to prevent an unexpected update. + + Args: + subroutine_dict: A mapping from reference key to schedule block of the subroutine. + inplace: Set ``True`` to override this instance with new subroutine. + + Returns: + Schedule block with assigned subroutine. + + Raises: + PulseError: When reference key is not defined in the current scope. + """ + if not inplace: + new_schedule = copy.deepcopy(self) + return new_schedule.assign_references(subroutine_dict, inplace=True) + + for key, subroutine in subroutine_dict.items(): + if key not in self.references: + unassigned_keys = ", ".join(map(repr, self.references.unassigned())) + raise PulseError( + f"Reference instruction with {key} doesn't exist " + f"in the current scope: {unassigned_keys}" + ) + self.references[key] = copy.deepcopy(subroutine) + + return self + + def get_parameters(self, parameter_name: str) -> list[Parameter]: + """Get parameter object bound to this schedule by string name. + + Note that we can define different parameter objects with the same name, + because these different objects are identified by their unique uuid. + For example, + + .. code-block:: python + + from qiskit import pulse, circuit + + amp1 = circuit.Parameter("amp") + amp2 = circuit.Parameter("amp") + + with pulse.build() as sub_prog: + pulse.play(pulse.Constant(100, amp1), pulse.DriveChannel(0)) + + with pulse.build() as main_prog: + pulse.call(sub_prog, name="sub") + pulse.play(pulse.Constant(100, amp2), pulse.DriveChannel(0)) + + main_prog.get_parameters("amp") + + This returns a list of two parameters ``amp1`` and ``amp2``. + + Args: + parameter_name: Name of parameter. + + Returns: + Parameter objects that have corresponding name. + """ + matched = [p for p in self.parameters if p.name == parameter_name] + return matched + + def __len__(self) -> int: + """Return number of instructions in the schedule.""" + return len(self.blocks) + + def __eq__(self, other: object) -> bool: + """Test if two ScheduleBlocks are equal. + + Equality is checked by verifying there is an equal instruction at every time + in ``other`` for every instruction in this ``ScheduleBlock``. This check is + performed by converting the instruction representation into directed acyclic graph, + in which execution order of every instruction is evaluated correctly across all channels. + Also ``self`` and ``other`` should have the same alignment context. + + .. warning:: + + This does not check for logical equivalency. Ie., + + ```python + >>> Delay(10, DriveChannel(0)) + Delay(10, DriveChannel(0)) + == Delay(20, DriveChannel(0)) + False + ``` + """ + # 0. type check + if not isinstance(other, type(self)): + return False + + # 1. transformation check + if self.alignment_context != other.alignment_context: + return False + + # 2. size check + if len(self) != len(other): + return False + + # 3. instruction check with alignment + from qiskit.pulse.transforms.dag import block_to_dag as dag + + if not rx.is_isomorphic_node_match(dag(self), dag(other), lambda x, y: x == y): + return False + + return True + + def __repr__(self) -> str: + name = format(self._name) if self._name else "" + blocks = ", ".join([repr(instr) for instr in self.blocks[:50]]) + if len(self.blocks) > 25: + blocks += ", ..." + return ( + f'{self.__class__.__name__}({blocks}, name="{name}",' + f" transform={repr(self.alignment_context)})" + ) + + def __add__(self, other: "BlockComponent") -> "ScheduleBlock": + """Return a new schedule with ``other`` inserted within ``self`` at ``start_time``.""" + return self.append(other) + + +def _common_method(*classes): + """A function decorator to attach the function to specified classes as a method. + + .. note:: For developer: A method attached through this decorator may hurt readability + of the codebase, because the method may not be detected by a code editor. + Thus, this decorator should be used to a limited extent, i.e. huge helper method. + By using this decorator wisely, we can reduce code maintenance overhead without + losing readability of the codebase. + """ + + def decorator(method): + @functools.wraps(method) + def wrapper(*args, **kwargs): + return method(*args, **kwargs) + + for cls in classes: + setattr(cls, method.__name__, wrapper) + return method + + return decorator + + +@deprecate_arg("show_barriers", new_alias="plot_barriers", since="1.1.0", pending=True) +@_common_method(Schedule, ScheduleBlock) +def draw( + self, + style: dict[str, Any] | None = None, + backend=None, # importing backend causes cyclic import + time_range: tuple[int, int] | None = None, + time_unit: str = "dt", + disable_channels: list[Channel] | None = None, + show_snapshot: bool = True, + show_framechange: bool = True, + show_waveform_info: bool = True, + plot_barrier: bool = True, + plotter: str = "mpl2d", + axis: Any | None = None, + show_barrier: bool = True, +): + """Plot the schedule. + + Args: + style: Stylesheet options. This can be dictionary or preset stylesheet classes. See + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXStandard`, + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXSimple`, and + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXDebugging` for details of + preset stylesheets. + backend (Optional[BaseBackend]): Backend object to play the input pulse program. + If provided, the plotter may use to make the visualization hardware aware. + time_range: Set horizontal axis limit. Tuple ``(tmin, tmax)``. + time_unit: The unit of specified time range either ``dt`` or ``ns``. + The unit of `ns` is available only when ``backend`` object is provided. + disable_channels: A control property to show specific pulse channel. + Pulse channel instances provided as a list are not shown in the output image. + show_snapshot: Show snapshot instructions. + show_framechange: Show frame change instructions. The frame change represents + instructions that modulate phase or frequency of pulse channels. + show_waveform_info: Show additional information about waveforms such as their name. + plot_barrier: Show barrier lines. + plotter: Name of plotter API to generate an output image. + One of following APIs should be specified:: + + mpl2d: Matplotlib API for 2D image generation. + Matplotlib API to generate 2D image. Charts are placed along y axis with + vertical offset. This API takes matplotlib.axes.Axes as ``axis`` input. + + ``axis`` and ``style`` kwargs may depend on the plotter. + axis: Arbitrary object passed to the plotter. If this object is provided, + the plotters use a given ``axis`` instead of internally initializing + a figure object. This object format depends on the plotter. + See plotter argument for details. + show_barrier: DEPRECATED. Show barrier lines. + + Returns: + Visualization output data. + The returned data type depends on the ``plotter``. + If matplotlib family is specified, this will be a ``matplotlib.pyplot.Figure`` data. + """ + # pylint: disable=cyclic-import + from qiskit_dynamics.pulse.visualization import draw as pulse_drawer + + del show_barrier + return pulse_drawer( + program=self, + style=style, + backend=backend, + time_range=time_range, + time_unit=time_unit, + disable_channels=disable_channels, + show_snapshot=show_snapshot, + show_framechange=show_framechange, + show_waveform_info=show_waveform_info, + plot_barrier=plot_barrier, + plotter=plotter, + axis=axis, + ) + + +def _interval_index(intervals: list[Interval], interval: Interval) -> int: + """Find the index of an interval. + + Args: + intervals: A sorted list of non-overlapping Intervals. + interval: The interval for which the index into intervals will be found. + + Returns: + The index of the interval. + + Raises: + PulseError: If the interval does not exist. + """ + index = _locate_interval_index(intervals, interval) + found_interval = intervals[index] + if found_interval != interval: + raise PulseError(f"The interval: {interval} does not exist in intervals: {intervals}") + return index + + +def _locate_interval_index(intervals: list[Interval], interval: Interval, index: int = 0) -> int: + """Using binary search on start times, find an interval. + + Args: + intervals: A sorted list of non-overlapping Intervals. + interval: The interval for which the index into intervals will be found. + index: A running tally of the index, for recursion. The user should not pass a value. + + Returns: + The index into intervals that new_interval would be inserted to maintain + a sorted list of intervals. + """ + if not intervals or len(intervals) == 1: + return index + + mid_idx = len(intervals) // 2 + mid = intervals[mid_idx] + if interval[1] <= mid[0] and (interval != mid): + return _locate_interval_index(intervals[:mid_idx], interval, index=index) + else: + return _locate_interval_index(intervals[mid_idx:], interval, index=index + mid_idx) + + +def _find_insertion_index(intervals: list[Interval], new_interval: Interval) -> int: + """Using binary search on start times, return the index into `intervals` where the new interval + belongs, or raise an error if the new interval overlaps with any existing ones. + Args: + intervals: A sorted list of non-overlapping Intervals. + new_interval: The interval for which the index into intervals will be found. + Returns: + The index into intervals that new_interval should be inserted to maintain a sorted list + of intervals. + Raises: + PulseError: If new_interval overlaps with the given intervals. + """ + index = _locate_interval_index(intervals, new_interval) + if index < len(intervals): + if _overlaps(intervals[index], new_interval): + raise PulseError("New interval overlaps with existing.") + return index if new_interval[1] <= intervals[index][0] else index + 1 + return index + + +def _overlaps(first: Interval, second: Interval) -> bool: + """Return True iff first and second overlap. + Note: first.stop may equal second.start, since Interval stop times are exclusive. + """ + if first[0] == second[0] == second[1]: + # They fail to overlap if one of the intervals has duration 0 + return False + if first[0] > second[0]: + first, second = second, first + return second[0] < first[1] + + +def _check_nonnegative_timeslot(timeslots: TimeSlots): + """Test that a channel has no negative timeslots. + + Raises: + PulseError: If a channel timeslot is negative. + """ + for chan, chan_timeslots in timeslots.items(): + if chan_timeslots: + if chan_timeslots[0][0] < 0: + raise PulseError(f"An instruction on {chan} has a negative starting time.") + + +def _get_timeslots(schedule: "ScheduleComponent") -> TimeSlots: + """Generate timeslots from given schedule component. + + Args: + schedule: Input schedule component. + + Raises: + PulseError: When invalid schedule type is specified. + """ + if isinstance(schedule, Instruction): + duration = schedule.duration + instruction_duration_validation(duration) + timeslots = {channel: [(0, duration)] for channel in schedule.channels} + elif isinstance(schedule, Schedule): + timeslots = schedule.timeslots + else: + raise PulseError(f"Invalid schedule type {type(schedule)} is specified.") + + return timeslots + + +def _get_references(block_elms: list["BlockComponent"]) -> set[Reference]: + """Recursively get reference instructions in the current scope. + + Args: + block_elms: List of schedule block elements to investigate. + + Returns: + A set of unique reference instructions. + """ + references = set() + for elm in block_elms: + if isinstance(elm, ScheduleBlock): + references |= _get_references(elm._blocks) + elif isinstance(elm, Reference): + references.add(elm) + return references + + +# These type aliases are defined at the bottom of the file, because as of 2022-01-18 they are +# imported into other parts of Terra. Previously, the aliases were at the top of the file and used +# forwards references within themselves. This was fine within the same file, but causes scoping +# issues when the aliases are imported into different scopes, in which the `ForwardRef` instances +# would no longer resolve. Instead, we only use forward references in the annotations of _this_ +# file to reference the aliases, which are guaranteed to resolve in scope, so the aliases can all be +# concrete. + +ScheduleComponent = Union[Schedule, Instruction] +"""An element that composes a pulse schedule.""" + +BlockComponent = Union[ScheduleBlock, Instruction] +"""An element that composes a pulse schedule block.""" diff --git a/qiskit_dynamics/pulse/transforms/__init__.py b/qiskit_dynamics/pulse/transforms/__init__.py new file mode 100644 index 000000000..4fb4bf40e --- /dev/null +++ b/qiskit_dynamics/pulse/transforms/__init__.py @@ -0,0 +1,106 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +r""" +================================================= +Pulse Transforms (:mod:`qiskit.pulse.transforms`) +================================================= + +The pulse transforms provide transformation routines to reallocate and optimize +pulse programs for backends. + +.. _pulse_alignments: + +Alignments +========== + +The alignment transforms define alignment policies of instructions in :obj:`.ScheduleBlock`. +These transformations are called to create :obj:`.Schedule`\ s from :obj:`.ScheduleBlock`\ s. + +.. autosummary:: + :toctree: ../stubs/ + + AlignEquispaced + AlignFunc + AlignLeft + AlignRight + AlignSequential + +These are all subtypes of the abstract base class :class:`AlignmentKind`. + +.. autoclass:: AlignmentKind + + +.. _pulse_canonical_transform: + +Canonicalization +================ + +The canonicalization transforms convert schedules to a form amenable for execution on +OpenPulse backends. + +.. autofunction:: add_implicit_acquires +.. autofunction:: align_measures +.. autofunction:: block_to_schedule +.. autofunction:: compress_pulses +.. autofunction:: flatten +.. autofunction:: inline_subroutines +.. autofunction:: pad +.. autofunction:: remove_directives +.. autofunction:: remove_trivial_barriers + + +.. _pulse_dag: + +DAG +=== + +The DAG transforms create DAG representation of input program. This can be used for +optimization of instructions and equality checks. + +.. autofunction:: block_to_dag + + +.. _pulse_transform_chain: + +Composite transform +=================== + +A sequence of transformations to generate a target code. + +.. autofunction:: target_qobj_transform + +""" + +from .alignments import ( + AlignEquispaced, + AlignFunc, + AlignLeft, + AlignRight, + AlignSequential, + AlignmentKind, +) + +from .base_transforms import target_qobj_transform + +from .canonicalization import ( + add_implicit_acquires, + align_measures, + block_to_schedule, + compress_pulses, + flatten, + inline_subroutines, + pad, + remove_directives, + remove_trivial_barriers, +) + +from .dag import block_to_dag diff --git a/qiskit_dynamics/pulse/transforms/alignments.py b/qiskit_dynamics/pulse/transforms/alignments.py new file mode 100644 index 000000000..98e91da6c --- /dev/null +++ b/qiskit_dynamics/pulse/transforms/alignments.py @@ -0,0 +1,406 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +"""A collection of passes to reallocate the timeslots of instructions according to context.""" +from __future__ import annotations +import abc +from typing import Callable, Tuple + +import numpy as np + +from qiskit.circuit.parameterexpression import ParameterExpression, ParameterValueType +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleComponent +from qiskit_dynamics.pulse.utils import instruction_duration_validation + + +class AlignmentKind(abc.ABC): + """An abstract class for schedule alignment.""" + + def __init__(self, context_params: Tuple[ParameterValueType, ...]): + """Create new context.""" + self._context_params = tuple(context_params) + + @abc.abstractmethod + def align(self, schedule: Schedule) -> Schedule: + """Reallocate instructions according to the policy. + + Only top-level sub-schedules are aligned. If sub-schedules are nested, + nested schedules are not recursively aligned. + + Args: + schedule: Schedule to align. + + Returns: + Schedule with reallocated instructions. + """ + pass + + @property + @abc.abstractmethod + def is_sequential(self) -> bool: + """Return ``True`` if this is sequential alignment context. + + This information is used to evaluate DAG equivalency of two :class:`.ScheduleBlock`s. + When the context has two pulses in different channels, + a sequential context subtype intends to return following scheduling outcome. + + .. parsed-literal:: + + ┌────────┐ + D0: ┤ pulse1 ├──────────── + └────────┘ ┌────────┐ + D1: ────────────┤ pulse2 ├ + └────────┘ + + On the other hand, parallel context with ``is_sequential=False`` returns + + .. parsed-literal:: + + ┌────────┐ + D0: ┤ pulse1 ├ + ├────────┤ + D1: ┤ pulse2 ├ + └────────┘ + + All subclasses must implement this method according to scheduling strategy. + """ + pass + + def __eq__(self, other: object) -> bool: + """Check equality of two transforms.""" + if type(self) is not type(other): + return False + if self._context_params != other._context_params: + return False + return True + + def __repr__(self): + return f"{self.__class__.__name__}({', '.join(self._context_params)})" + + +class AlignLeft(AlignmentKind): + """Align instructions in as-soon-as-possible manner. + + Instructions are placed at earliest available timeslots. + """ + + def __init__(self): + """Create new left-justified context.""" + super().__init__(context_params=()) + + @property + def is_sequential(self) -> bool: + return False + + def align(self, schedule: Schedule) -> Schedule: + """Reallocate instructions according to the policy. + + Only top-level sub-schedules are aligned. If sub-schedules are nested, + nested schedules are not recursively aligned. + + Args: + schedule: Schedule to align. + + Returns: + Schedule with reallocated instructions. + """ + aligned = Schedule.initialize_from(schedule) + for _, child in schedule.children: + self._push_left_append(aligned, child) + + return aligned + + @staticmethod + def _push_left_append(this: Schedule, other: ScheduleComponent) -> Schedule: + """Return ``this`` with ``other`` inserted at the maximum time over + all channels shared between ```this`` and ``other``. + + Args: + this: Input schedule to which ``other`` will be inserted. + other: Other schedule to insert. + + Returns: + Push left appended schedule. + """ + this_channels = set(this.channels) + other_channels = set(other.channels) + shared_channels = list(this_channels & other_channels) + ch_slacks = [ + this.stop_time - this.ch_stop_time(channel) + other.ch_start_time(channel) + for channel in shared_channels + ] + + if ch_slacks: + slack_chan = shared_channels[np.argmin(ch_slacks)] + shared_insert_time = this.ch_stop_time(slack_chan) - other.ch_start_time(slack_chan) + else: + shared_insert_time = 0 + + # Handle case where channels not common to both might actually start + # after ``this`` has finished. + other_only_insert_time = other.ch_start_time(*(other_channels - this_channels)) + # Choose whichever is greatest. + insert_time = max(shared_insert_time, other_only_insert_time) + + return this.insert(insert_time, other, inplace=True) + + +class AlignRight(AlignmentKind): + """Align instructions in as-late-as-possible manner. + + Instructions are placed at latest available timeslots. + """ + + def __init__(self): + """Create new right-justified context.""" + super().__init__(context_params=()) + + @property + def is_sequential(self) -> bool: + return False + + def align(self, schedule: Schedule) -> Schedule: + """Reallocate instructions according to the policy. + + Only top-level sub-schedules are aligned. If sub-schedules are nested, + nested schedules are not recursively aligned. + + Args: + schedule: Schedule to align. + + Returns: + Schedule with reallocated instructions. + """ + aligned = Schedule.initialize_from(schedule) + for _, child in reversed(schedule.children): + aligned = self._push_right_prepend(aligned, child) + + return aligned + + @staticmethod + def _push_right_prepend(this: Schedule, other: ScheduleComponent) -> Schedule: + """Return ``this`` with ``other`` inserted at the latest possible time + such that ``other`` ends before it overlaps with any of ``this``. + + If required ``this`` is shifted to start late enough so that there is room + to insert ``other``. + + Args: + this: Input schedule to which ``other`` will be inserted. + other: Other schedule to insert. + + Returns: + Push right prepended schedule. + """ + this_channels = set(this.channels) + other_channels = set(other.channels) + shared_channels = list(this_channels & other_channels) + ch_slacks = [ + this.ch_start_time(channel) - other.ch_stop_time(channel) for channel in shared_channels + ] + + if ch_slacks: + insert_time = min(ch_slacks) + other.start_time + else: + insert_time = this.stop_time - other.stop_time + other.start_time + + if insert_time < 0: + this.shift(-insert_time, inplace=True) + this.insert(0, other, inplace=True) + else: + this.insert(insert_time, other, inplace=True) + + return this + + +class AlignSequential(AlignmentKind): + """Align instructions sequentially. + + Instructions played on different channels are also arranged in a sequence. + No buffer time is inserted in between instructions. + """ + + def __init__(self): + """Create new sequential context.""" + super().__init__(context_params=()) + + @property + def is_sequential(self) -> bool: + return True + + def align(self, schedule: Schedule) -> Schedule: + """Reallocate instructions according to the policy. + + Only top-level sub-schedules are aligned. If sub-schedules are nested, + nested schedules are not recursively aligned. + + Args: + schedule: Schedule to align. + + Returns: + Schedule with reallocated instructions. + """ + aligned = Schedule.initialize_from(schedule) + for _, child in schedule.children: + aligned.insert(aligned.duration, child, inplace=True) + + return aligned + + +class AlignEquispaced(AlignmentKind): + """Align instructions with equispaced interval within a specified duration. + + Instructions played on different channels are also arranged in a sequence. + This alignment is convenient to create dynamical decoupling sequences such as PDD. + """ + + def __init__(self, duration: int | ParameterExpression): + """Create new equispaced context. + + Args: + duration: Duration of this context. This should be larger than the schedule duration. + If the specified duration is shorter than the schedule duration, + no alignment is performed and the input schedule is just returned. + This duration can be parametrized. + """ + super().__init__(context_params=(duration,)) + + @property + def is_sequential(self) -> bool: + return True + + @property + def duration(self): + """Return context duration.""" + return self._context_params[0] + + def align(self, schedule: Schedule) -> Schedule: + """Reallocate instructions according to the policy. + + Only top-level sub-schedules are aligned. If sub-schedules are nested, + nested schedules are not recursively aligned. + + Args: + schedule: Schedule to align. + + Returns: + Schedule with reallocated instructions. + """ + instruction_duration_validation(self.duration) + + total_duration = sum(child.duration for _, child in schedule.children) + if self.duration < total_duration: + return schedule + + total_delay = self.duration - total_duration + + if len(schedule.children) > 1: + # Calculate the interval in between sub-schedules. + # If the duration cannot be divided by the number of sub-schedules, + # the modulo is appended and prepended to the input schedule. + interval, mod = np.divmod(total_delay, len(schedule.children) - 1) + else: + interval = 0 + mod = total_delay + + # Calculate pre schedule delay + delay, mod = np.divmod(mod, 2) + + aligned = Schedule.initialize_from(schedule) + # Insert sub-schedules with interval + _t0 = int(aligned.stop_time + delay + mod) + for _, child in schedule.children: + aligned.insert(_t0, child, inplace=True) + _t0 = int(aligned.stop_time + interval) + + return aligned + + +class AlignFunc(AlignmentKind): + """Allocate instructions at position specified by callback function. + + The position is specified for each instruction of index ``j`` as a + fractional coordinate in [0, 1] within the specified duration. + + Instructions played on different channels are also arranged in a sequence. + This alignment is convenient to create dynamical decoupling sequences such as UDD. + + For example, UDD sequence with 10 pulses can be specified with following function. + + .. code-block:: python + + import numpy as np + + def udd10_pos(j): + return np.sin(np.pi*j/(2*10 + 2))**2 + + .. note:: + + This context cannot be QPY serialized because of the callable. If you use this context, + your program cannot be saved in QPY format. + + """ + + def __init__(self, duration: int | ParameterExpression, func: Callable): + """Create new equispaced context. + + Args: + duration: Duration of this context. This should be larger than the schedule duration. + If the specified duration is shorter than the schedule duration, + no alignment is performed and the input schedule is just returned. + This duration can be parametrized. + func: A function that takes an index of sub-schedule and returns the + fractional coordinate of of that sub-schedule. The returned value should be + defined within [0, 1]. The pulse index starts from 1. + """ + super().__init__(context_params=(duration, func)) + + @property + def is_sequential(self) -> bool: + return True + + @property + def duration(self): + """Return context duration.""" + return self._context_params[0] + + @property + def func(self): + """Return context alignment function.""" + return self._context_params[1] + + def align(self, schedule: Schedule) -> Schedule: + """Reallocate instructions according to the policy. + + Only top-level sub-schedules are aligned. If sub-schedules are nested, + nested schedules are not recursively aligned. + + Args: + schedule: Schedule to align. + + Returns: + Schedule with reallocated instructions. + """ + instruction_duration_validation(self.duration) + + if self.duration < schedule.duration: + return schedule + + aligned = Schedule.initialize_from(schedule) + for ind, (_, child) in enumerate(schedule.children): + _t_center = self.duration * self.func(ind + 1) + _t0 = int(_t_center - 0.5 * child.duration) + if _t0 < 0 or _t0 > self.duration: + raise PulseError(f"Invalid schedule position t={_t0} is specified at index={ind}") + aligned.insert(_t0, child, inplace=True) + + return aligned diff --git a/qiskit_dynamics/pulse/transforms/base_transforms.py b/qiskit_dynamics/pulse/transforms/base_transforms.py new file mode 100644 index 000000000..78b802c81 --- /dev/null +++ b/qiskit_dynamics/pulse/transforms/base_transforms.py @@ -0,0 +1,71 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +"""A collection of set of transforms.""" + +# TODO: replace this with proper pulse transformation passes. Qiskit-terra/#6121 + +from typing import Union, Iterable, Tuple + +from qiskit_dynamics.pulse.instructions import Instruction +from qiskit_dynamics.pulse.schedule import ScheduleBlock, Schedule +from qiskit_dynamics.pulse.transforms import canonicalization + +InstructionSched = Union[Tuple[int, Instruction], Instruction] + + +def target_qobj_transform( + sched: Union[ScheduleBlock, Schedule, InstructionSched, Iterable[InstructionSched]], + remove_directives: bool = True, +) -> Schedule: + """A basic pulse program transformation for OpenPulse API execution. + + Args: + sched: Input program to transform. + remove_directives: Set `True` to remove compiler directives. + + Returns: + Transformed program for execution. + """ + if not isinstance(sched, Schedule): + # convert into schedule representation + if isinstance(sched, ScheduleBlock): + sched = canonicalization.block_to_schedule(sched) + else: + sched = Schedule(*_format_schedule_component(sched)) + + # remove subroutines, i.e. Call instructions + sched = canonicalization.inline_subroutines(sched) + + # inline nested schedules + sched = canonicalization.flatten(sched) + + # remove directives, e.g. barriers + if remove_directives: + sched = canonicalization.remove_directives(sched) + + return sched + + +def _format_schedule_component(sched: Union[InstructionSched, Iterable[InstructionSched]]): + """A helper function to convert instructions into list of instructions.""" + # TODO remove schedule initialization with *args, Qiskit-terra/#5093 + + try: + sched = list(sched) + # (t0, inst), or list of it + if isinstance(sched[0], int): + # (t0, inst) tuple + return [tuple(sched)] + else: + return sched + except TypeError: + return [sched] diff --git a/qiskit_dynamics/pulse/transforms/canonicalization.py b/qiskit_dynamics/pulse/transforms/canonicalization.py new file mode 100644 index 000000000..cf6f006e5 --- /dev/null +++ b/qiskit_dynamics/pulse/transforms/canonicalization.py @@ -0,0 +1,498 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +"""Basic rescheduling functions which take schedule or instructions and return new schedules.""" +from __future__ import annotations +import typing +import warnings +from collections import defaultdict +from collections.abc import Iterable +from typing import Type + +import numpy as np + +from qiskit_dynamics.pulse import channels as chans, exceptions, instructions +from qiskit_dynamics.pulse.channels import ClassicalIOChannel +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.exceptions import UnassignedDurationError +from qiskit_dynamics.pulse.instruction_schedule_map import InstructionScheduleMap +from qiskit_dynamics.pulse.instructions import directives +from qiskit_dynamics.pulse.schedule import Schedule, ScheduleBlock, ScheduleComponent + +if typing.TYPE_CHECKING: + from qiskit.pulse.library import Pulse # pylint: disable=cyclic-import + + +def block_to_schedule(block: ScheduleBlock) -> Schedule: + """Convert ``ScheduleBlock`` to ``Schedule``. + + Args: + block: A ``ScheduleBlock`` to convert. + + Returns: + Scheduled pulse program. + + Raises: + UnassignedDurationError: When any instruction duration is not assigned. + PulseError: When the alignment context duration is shorter than the schedule duration. + + .. note:: This transform may insert barriers in between contexts. + """ + if not block.is_schedulable(): + raise UnassignedDurationError( + "All instruction durations should be assigned before creating `Schedule`." + "Please check `.parameters` to find unassigned parameter objects." + ) + + schedule = Schedule.initialize_from(block) + + for op_data in block.blocks: + if isinstance(op_data, ScheduleBlock): + context_schedule = block_to_schedule(op_data) + if hasattr(op_data.alignment_context, "duration"): + # context may have local scope duration, e.g. EquispacedAlignment for 1000 dt + post_buffer = op_data.alignment_context.duration - context_schedule.duration + if post_buffer < 0: + raise PulseError( + f"ScheduleBlock {op_data.name} has longer duration than " + "the specified context duration " + f"{context_schedule.duration} > {op_data.duration}." + ) + else: + post_buffer = 0 + schedule.append(context_schedule, inplace=True) + + # prevent interruption by following instructions. + # padding with delay instructions is no longer necessary, thanks to alignment context. + if post_buffer > 0: + context_boundary = instructions.RelativeBarrier(*op_data.channels) + schedule.append(context_boundary.shift(post_buffer), inplace=True) + else: + schedule.append(op_data, inplace=True) + + # transform with defined policy + return block.alignment_context.align(schedule) + + +def compress_pulses(schedules: list[Schedule]) -> list[Schedule]: + """Optimization pass to replace identical pulses. + + Args: + schedules: Schedules to compress. + + Returns: + Compressed schedules. + """ + existing_pulses: list[Pulse] = [] + new_schedules = [] + + for schedule in schedules: + new_schedule = Schedule.initialize_from(schedule) + + for time, inst in schedule.instructions: + if isinstance(inst, instructions.Play): + if inst.pulse in existing_pulses: + idx = existing_pulses.index(inst.pulse) + identical_pulse = existing_pulses[idx] + new_schedule.insert( + time, + instructions.Play(identical_pulse, inst.channel, inst.name), + inplace=True, + ) + else: + existing_pulses.append(inst.pulse) + new_schedule.insert(time, inst, inplace=True) + else: + new_schedule.insert(time, inst, inplace=True) + + new_schedules.append(new_schedule) + + return new_schedules + + +def flatten(program: Schedule) -> Schedule: + """Flatten (inline) any called nodes into a Schedule tree with no nested children. + + Args: + program: Pulse program to remove nested structure. + + Returns: + Flatten pulse program. + + Raises: + PulseError: When invalid data format is given. + """ + if isinstance(program, Schedule): + flat_sched = Schedule.initialize_from(program) + for time, inst in program.instructions: + flat_sched.insert(time, inst, inplace=True) + return flat_sched + else: + raise PulseError(f"Invalid input program {program.__class__.__name__} is specified.") + + +def inline_subroutines(program: Schedule | ScheduleBlock) -> Schedule | ScheduleBlock: + """Recursively remove call instructions and inline the respective subroutine instructions. + + Assigned parameter values, which are stored in the parameter table, are also applied. + The subroutine is copied before the parameter assignment to avoid mutation problem. + + Args: + program: A program which may contain the subroutine, i.e. ``Call`` instruction. + + Returns: + A schedule without subroutine. + + Raises: + PulseError: When input program is not valid data format. + """ + if isinstance(program, Schedule): + return _inline_schedule(program) + elif isinstance(program, ScheduleBlock): + return _inline_block(program) + else: + raise PulseError(f"Invalid program {program.__class__.__name__} is specified.") + + +def _inline_schedule(schedule: Schedule) -> Schedule: + """A helper function to inline subroutine of schedule. + + .. note:: If subroutine is ``ScheduleBlock`` it is converted into Schedule to get ``t0``. + """ + ret_schedule = Schedule.initialize_from(schedule) + for t0, inst in schedule.children: + # note that schedule.instructions unintentionally flatten the nested schedule. + # this should be performed by another transformer node. + if isinstance(inst, Schedule): + # recursively inline the program + inline_schedule = _inline_schedule(inst) + ret_schedule.insert(t0, inline_schedule, inplace=True) + else: + ret_schedule.insert(t0, inst, inplace=True) + return ret_schedule + + +def _inline_block(block: ScheduleBlock) -> ScheduleBlock: + """A helper function to inline subroutine of schedule block. + + .. note:: If subroutine is ``Schedule`` the function raises an error. + """ + ret_block = ScheduleBlock.initialize_from(block) + for inst in block.blocks: + if isinstance(inst, ScheduleBlock): + # recursively inline the program + inline_block = _inline_block(inst) + ret_block.append(inline_block, inplace=True) + else: + ret_block.append(inst, inplace=True) + return ret_block + + +def remove_directives(schedule: Schedule) -> Schedule: + """Remove directives. + + Args: + schedule: A schedule to remove compiler directives. + + Returns: + A schedule without directives. + """ + return schedule.exclude(instruction_types=[directives.Directive]) + + +def remove_trivial_barriers(schedule: Schedule) -> Schedule: + """Remove trivial barriers with 0 or 1 channels. + + Args: + schedule: A schedule to remove trivial barriers. + + Returns: + schedule: A schedule without trivial barriers + """ + + def filter_func(inst): + return isinstance(inst[1], directives.RelativeBarrier) and len(inst[1].channels) < 2 + + return schedule.exclude(filter_func) + + +def align_measures( + schedules: Iterable[ScheduleComponent], + inst_map: InstructionScheduleMap | None = None, + cal_gate: str = "u3", + max_calibration_duration: int | None = None, + align_time: int | None = None, + align_all: bool | None = True, +) -> list[Schedule]: + """Return new schedules where measurements occur at the same physical time. + + This transformation will align the first :class:`.Acquire` on + every channel to occur at the same time. + + Minimum measurement wait time (to allow for calibration pulses) is enforced + and may be set with ``max_calibration_duration``. + + By default only instructions containing a :class:`.AcquireChannel` or :class:`.MeasureChannel` + will be shifted. If you wish to keep the relative timing of all instructions in the schedule set + ``align_all=True``. + + This method assumes that ``MeasureChannel(i)`` and ``AcquireChannel(i)`` + correspond to the same qubit and the acquire/play instructions + should be shifted together on these channels. + + .. code-block:: + + from qiskit import pulse + from qiskit.pulse import transforms + + d0 = pulse.DriveChannel(0) + m0 = pulse.MeasureChannel(0) + a0 = pulse.AcquireChannel(0) + mem0 = pulse.MemorySlot(0) + + sched = pulse.Schedule() + sched.append(pulse.Play(pulse.Constant(10, 0.5), d0), inplace=True) + sched.append(pulse.Play(pulse.Constant(10, 1.), m0).shift(sched.duration), inplace=True) + sched.append(pulse.Acquire(20, a0, mem0).shift(sched.duration), inplace=True) + + sched_shifted = sched << 20 + + aligned_sched, aligned_sched_shifted = transforms.align_measures([sched, sched_shifted]) + + assert aligned_sched == aligned_sched_shifted + + If it is desired to only shift acquisition and measurement stimulus instructions + set the flag ``align_all=False``: + + .. code-block:: + + aligned_sched, aligned_sched_shifted = transforms.align_measures( + [sched, sched_shifted], + align_all=False, + ) + + assert aligned_sched != aligned_sched_shifted + + + Args: + schedules: Collection of schedules to be aligned together + inst_map: Mapping of circuit operations to pulse schedules + cal_gate: The name of the gate to inspect for the calibration time + max_calibration_duration: If provided, inst_map and cal_gate will be ignored + align_time: If provided, this will be used as final align time. + align_all: Shift all instructions in the schedule such that they maintain + their relative alignment with the shifted acquisition instruction. + If ``False`` only the acquisition and measurement pulse instructions + will be shifted. + Returns: + The input list of schedules transformed to have their measurements aligned. + + Raises: + PulseError: If the provided alignment time is negative. + """ + + def get_first_acquire_times(schedules): + """Return a list of first acquire times for each schedule.""" + acquire_times = [] + for schedule in schedules: + visited_channels = set() + qubit_first_acquire_times: dict[int, int] = defaultdict(lambda: None) + + for time, inst in schedule.instructions: + if isinstance(inst, instructions.Acquire) and inst.channel not in visited_channels: + visited_channels.add(inst.channel) + qubit_first_acquire_times[inst.channel.index] = time + + acquire_times.append(qubit_first_acquire_times) + return acquire_times + + def get_max_calibration_duration(inst_map, cal_gate): + """Return the time needed to allow for readout discrimination calibration pulses.""" + # TODO (qiskit-terra #5472): fix behavior of this. + max_calibration_duration = 0 + for qubits in inst_map.qubits_with_instruction(cal_gate): + cmd = inst_map.get(cal_gate, qubits, np.pi, 0, np.pi) + max_calibration_duration = max(cmd.duration, max_calibration_duration) + return max_calibration_duration + + if align_time is not None and align_time < 0: + raise exceptions.PulseError("Align time cannot be negative.") + + first_acquire_times = get_first_acquire_times(schedules) + # Extract the maximum acquire in every schedule across all acquires in the schedule. + # If there are no acquires in the schedule default to 0. + max_acquire_times = [max(0, *times.values()) for times in first_acquire_times] + if align_time is None: + if max_calibration_duration is None: + if inst_map: + max_calibration_duration = get_max_calibration_duration(inst_map, cal_gate) + else: + max_calibration_duration = 0 + align_time = max(max_calibration_duration, *max_acquire_times) + + # Shift acquires according to the new scheduled time + new_schedules = [] + for sched_idx, schedule in enumerate(schedules): + new_schedule = Schedule.initialize_from(schedule) + stop_time = schedule.stop_time + + if align_all: + if first_acquire_times[sched_idx]: + shift = align_time - max_acquire_times[sched_idx] + else: + shift = align_time - stop_time + else: + shift = 0 + + for time, inst in schedule.instructions: + measurement_channels = { + chan.index + for chan in inst.channels + if isinstance(chan, (chans.MeasureChannel, chans.AcquireChannel)) + } + if measurement_channels: + sched_first_acquire_times = first_acquire_times[sched_idx] + max_start_time = max( + sched_first_acquire_times[chan] + for chan in measurement_channels + if chan in sched_first_acquire_times + ) + shift = align_time - max_start_time + + if shift < 0: + warnings.warn( + "The provided alignment time is scheduling an acquire instruction " + "earlier than it was scheduled for in the original Schedule. " + "This may result in an instruction being scheduled before t=0 and " + "an error being raised." + ) + new_schedule.insert(time + shift, inst, inplace=True) + + new_schedules.append(new_schedule) + + return new_schedules + + +def add_implicit_acquires(schedule: ScheduleComponent, meas_map: list[list[int]]) -> Schedule: + """Return a new schedule with implicit acquires from the measurement mapping replaced by + explicit ones. + + .. warning:: Since new acquires are being added, Memory Slots will be set to match the + qubit index. This may overwrite your specification. + + Args: + schedule: Schedule to be aligned. + meas_map: List of lists of qubits that are measured together. + + Returns: + A ``Schedule`` with the additional acquisition instructions. + """ + new_schedule = Schedule.initialize_from(schedule) + acquire_map = {} + + for time, inst in schedule.instructions: + if isinstance(inst, instructions.Acquire): + if inst.mem_slot and inst.mem_slot.index != inst.channel.index: + warnings.warn( + "One of your acquires was mapped to a memory slot which didn't match" + " the qubit index. I'm relabeling them to match." + ) + + # Get the label of all qubits that are measured with the qubit(s) in this instruction + all_qubits = [] + for sublist in meas_map: + if inst.channel.index in sublist: + all_qubits.extend(sublist) + # Replace the old acquire instruction by a new one explicitly acquiring all qubits in + # the measurement group. + for i in all_qubits: + explicit_inst = instructions.Acquire( + inst.duration, + chans.AcquireChannel(i), + mem_slot=chans.MemorySlot(i), + kernel=inst.kernel, + discriminator=inst.discriminator, + ) + if time not in acquire_map: + new_schedule.insert(time, explicit_inst, inplace=True) + acquire_map = {time: {i}} + elif i not in acquire_map[time]: + new_schedule.insert(time, explicit_inst, inplace=True) + acquire_map[time].add(i) + else: + new_schedule.insert(time, inst, inplace=True) + + return new_schedule + + +def pad( + schedule: Schedule, + channels: Iterable[chans.Channel] | None = None, + until: int | None = None, + inplace: bool = False, + pad_with: Type[instructions.Instruction] | None = None, +) -> Schedule: + """Pad the input Schedule with ``Delay``s on all unoccupied timeslots until + ``schedule.duration`` or ``until`` if not ``None``. + + Args: + schedule: Schedule to pad. + channels: Channels to pad. Defaults to all channels in + ``schedule`` if not provided. If the supplied channel is not a member + of ``schedule`` it will be added. + until: Time to pad until. Defaults to ``schedule.duration`` if not provided. + inplace: Pad this schedule by mutating rather than returning a new schedule. + pad_with: Pulse ``Instruction`` subclass to be used for padding. + Default to :class:`~qiskit.pulse.instructions.Delay` instruction. + + Returns: + The padded schedule. + + Raises: + PulseError: When non pulse instruction is set to `pad_with`. + """ + until = until or schedule.duration + channels = channels or schedule.channels + + if pad_with: + if issubclass(pad_with, instructions.Instruction): + pad_cls = pad_with + else: + raise PulseError( + f"'{pad_with.__class__.__name__}' is not valid pulse instruction to pad with." + ) + else: + pad_cls = instructions.Delay + + for channel in channels: + if isinstance(channel, ClassicalIOChannel): + continue + + if channel not in schedule.channels: + schedule = schedule.insert(0, instructions.Delay(until, channel), inplace=inplace) + continue + + prev_time = 0 + timeslots = iter(schedule.timeslots[channel]) + to_pad = [] + while prev_time < until: + try: + t0, t1 = next(timeslots) + except StopIteration: + to_pad.append((prev_time, until - prev_time)) + break + if prev_time < t0: + to_pad.append((prev_time, min(t0, until) - prev_time)) + prev_time = t1 + for t0, duration in to_pad: + schedule = schedule.insert(t0, pad_cls(duration, channel), inplace=inplace) + + return schedule diff --git a/qiskit_dynamics/pulse/transforms/dag.py b/qiskit_dynamics/pulse/transforms/dag.py new file mode 100644 index 000000000..478e0b056 --- /dev/null +++ b/qiskit_dynamics/pulse/transforms/dag.py @@ -0,0 +1,122 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +"""A collection of functions to convert ScheduleBlock to DAG representation.""" +from __future__ import annotations + +import typing + +import rustworkx as rx + + +from qiskit_dynamics.pulse.channels import Channel +from qiskit_dynamics.pulse.exceptions import UnassignedReferenceError + +if typing.TYPE_CHECKING: + from qiskit.pulse import ScheduleBlock # pylint: disable=cyclic-import + + +def block_to_dag(block: ScheduleBlock) -> rx.PyDAG: + """Convert schedule block instruction into DAG. + + ``ScheduleBlock`` can be represented as a DAG as needed. + For example, equality of two programs are efficiently checked on DAG representation. + + .. code-block:: python + + from qiskit import pulse + + my_gaussian0 = pulse.Gaussian(100, 0.5, 20) + my_gaussian1 = pulse.Gaussian(100, 0.3, 10) + + with pulse.build() as sched1: + with pulse.align_left(): + pulse.play(my_gaussian0, pulse.DriveChannel(0)) + pulse.shift_phase(1.57, pulse.DriveChannel(2)) + pulse.play(my_gaussian1, pulse.DriveChannel(1)) + + with pulse.build() as sched2: + with pulse.align_left(): + pulse.shift_phase(1.57, pulse.DriveChannel(2)) + pulse.play(my_gaussian1, pulse.DriveChannel(1)) + pulse.play(my_gaussian0, pulse.DriveChannel(0)) + + Here the ``sched1 `` and ``sched2`` are different implementations of the same program, + but it is difficult to confirm on the list representation. + + Another example is instruction optimization. + + .. code-block:: python + + from qiskit import pulse + + with pulse.build() as sched: + with pulse.align_left(): + pulse.shift_phase(1.57, pulse.DriveChannel(1)) + pulse.play(my_gaussian0, pulse.DriveChannel(0)) + pulse.shift_phase(-1.57, pulse.DriveChannel(1)) + + In above program two ``shift_phase`` instructions can be cancelled out because + they are consecutive on the same drive channel. + This can be easily found on the DAG representation. + + Args: + block ("ScheduleBlock"): A schedule block to be converted. + + Returns: + Instructions in DAG representation. + + Raises: + PulseError: When the context is invalid subclass. + """ + if block.alignment_context.is_sequential: + return _sequential_allocation(block) + return _parallel_allocation(block) + + +def _sequential_allocation(block) -> rx.PyDAG: + """A helper function to create a DAG of a sequential alignment context.""" + dag = rx.PyDAG() + + edges: list[tuple[int, int]] = [] + prev_id = None + for elm in block.blocks: + node_id = dag.add_node(elm) + if dag.num_nodes() > 1: + edges.append((prev_id, node_id)) + prev_id = node_id + dag.add_edges_from_no_data(edges) + return dag + + +def _parallel_allocation(block) -> rx.PyDAG: + """A helper function to create a DAG of a parallel alignment context.""" + dag = rx.PyDAG() + + slots: dict[Channel, int] = {} + edges: set[tuple[int, int]] = set() + prev_reference = None + for elm in block.blocks: + node_id = dag.add_node(elm) + try: + for chan in elm.channels: + prev_id = slots.pop(chan, prev_reference) + if prev_id is not None: + edges.add((prev_id, node_id)) + slots[chan] = node_id + except UnassignedReferenceError: + # Broadcasting channels because the reference's channels are unknown. + for chan, prev_id in slots.copy().items(): + edges.add((prev_id, node_id)) + slots[chan] = node_id + prev_reference = node_id + dag.add_edges_from_no_data(list(edges)) + return dag diff --git a/qiskit_dynamics/pulse/update_circuit.py b/qiskit_dynamics/pulse/update_circuit.py new file mode 100644 index 000000000..6135cacf3 --- /dev/null +++ b/qiskit_dynamics/pulse/update_circuit.py @@ -0,0 +1,736 @@ +""" +Module for updating the QuantumCircuit class to include calibration information. +""" + +import typing + +from qiskit._accelerate.quantum_circuit import CircuitData +from qiskit.circuit import ( + QuantumCircuit, + CircuitInstruction, + Clbit, + IfElseOp, + WhileLoopOp, + SwitchCaseOp, + _classical_resource_map, +) +from qiskit.circuit.parameter import Parameter, ParameterExpression +from qiskit.circuit.exceptions import CircuitError +from qiskit.circuit.gate import Gate +from qiskit.circuit.parameterexpression import ParameterValueType +from qiskit.circuit.quantumcircuit import ( + _ParameterBindsDict, + _ParameterBindsSequence, + _OuterCircuitScopeInterface, +) +from qiskit.circuit.quantumregister import Qubit +from qiskit.circuit.quantumcircuit import QubitSpecifier, ClbitSpecifier +from qiskit.circuit.instruction import Instruction +from qiskit.circuit.controlflow import ControlFlowOp +from qiskit.circuit.store import Store +from qiskit.circuit.classical import expr +import copy as _copy + + +import collections +from collections import defaultdict +from typing import Union, Mapping, Iterable, Optional, Sequence, Literal +import numpy as np + + +def op_start_times(self) -> list[int]: + """Return a list of operation start times. + + This attribute is enabled once one of scheduling analysis passes + runs on the quantum circuit. + + Returns: + List of integers representing instruction start times. + The index corresponds to the index of instruction in :attr:`QuantumCircuit.data`. + + Raises: + AttributeError: When circuit is not scheduled. + """ + if self._op_start_times is None: + raise AttributeError( + "This circuit is not scheduled. " + "To schedule it run the circuit through one of the transpiler scheduling passes." + ) + return self._op_start_times + + +def calibrations_getter(self) -> dict: + """Return calibration dictionary. + + The custom pulse definition of a given gate is of the form + ``{'gate_name': {(qubits, params): schedule}}`` + """ + return dict(self._calibrations) + + +def calibrations_setter(self, calibrations: dict): + """Set the circuit calibration data from a dictionary of calibration definition. + + Args: + calibrations (dict): A dictionary of input in the format + ``{'gate_name': {(qubits, gate_params): schedule}}`` + """ + self._calibrations = defaultdict(dict, calibrations) + + +def has_calibration_for(self, instruction: Union[CircuitInstruction, tuple]): + """Return True if the circuit has a calibration defined for the instruction context. In this + case, the operation does not need to be translated to the device basis. + """ + if isinstance(instruction, CircuitInstruction): + operation = instruction.operation + qubits = instruction.qubits + else: + operation, qubits, _ = instruction + if not self.calibrations or operation.name not in self.calibrations: + return False + qubits = tuple(self.qubits.index(qubit) for qubit in qubits) + params = [] + for p in operation.params: + if isinstance(p, ParameterExpression) and not p.parameters: + params.append(float(p)) + else: + params.append(p) + params = tuple(params) + return (qubits, params) in self.calibrations[operation.name] + + +def assign_parameters( # pylint: disable=missing-raises-doc + self, + parameters: Union[Mapping[Parameter, ParameterValueType], Iterable[ParameterValueType]], + inplace: bool = False, + *, + flat_input: bool = False, + strict: bool = True, +) -> Optional["QuantumCircuit"]: + """Assign parameters to new parameters or values. + + If ``parameters`` is passed as a dictionary, the keys should be :class:`.Parameter` + instances in the current circuit. The values of the dictionary can either be numeric values + or new parameter objects. + + If ``parameters`` is passed as a list or array, the elements are assigned to the + current parameters in the order of :attr:`parameters` which is sorted + alphabetically (while respecting the ordering in :class:`.ParameterVector` objects). + + The values can be assigned to the current circuit object or to a copy of it. + + .. note:: + When ``parameters`` is given as a mapping, it is permissible to have keys that are + strings of the parameter names; these will be looked up using :meth:`get_parameter`. + You can also have keys that are :class:`.ParameterVector` instances, and in this case, + the dictionary value should be a sequence of values of the same length as the vector. + + If you use either of these cases, you must leave the setting ``flat_input=False``; + changing this to ``True`` enables the fast path, where all keys must be + :class:`.Parameter` instances. + + Args: + parameters: Either a dictionary or iterable specifying the new parameter values. + inplace: If False, a copy of the circuit with the bound parameters is returned. + If True the circuit instance itself is modified. + flat_input: If ``True`` and ``parameters`` is a mapping type, it is assumed to be + exactly a mapping of ``{parameter: value}``. By default (``False``), the mapping + may also contain :class:`.ParameterVector` keys that point to a corresponding + sequence of values, and these will be unrolled during the mapping, or string keys, + which will be converted to :class:`.Parameter` instances using + :meth:`get_parameter`. + strict: If ``False``, any parameters given in the mapping that are not used in the + circuit will be ignored. If ``True`` (the default), an error will be raised + indicating a logic error. + + Raises: + CircuitError: If parameters is a dict and contains parameters not present in the + circuit. + ValueError: If parameters is a list/array and the length mismatches the number of free + parameters in the circuit. + + Returns: + A copy of the circuit with bound parameters if ``inplace`` is False, otherwise None. + + Examples: + + Create a parameterized circuit and assign the parameters in-place. + + .. plot:: + :include-source: + + from qiskit.circuit import QuantumCircuit, Parameter + + circuit = QuantumCircuit(2) + params = [Parameter('A'), Parameter('B'), Parameter('C')] + circuit.ry(params[0], 0) + circuit.crx(params[1], 0, 1) + circuit.draw('mpl') + circuit.assign_parameters({params[0]: params[2]}, inplace=True) + circuit.draw('mpl') + + Bind the values out-of-place by list and get a copy of the original circuit. + + .. plot:: + :include-source: + + from qiskit.circuit import QuantumCircuit, ParameterVector + + circuit = QuantumCircuit(2) + params = ParameterVector('P', 2) + circuit.ry(params[0], 0) + circuit.crx(params[1], 0, 1) + + bound_circuit = circuit.assign_parameters([1, 2]) + bound_circuit.draw('mpl') + + circuit.draw('mpl') + + """ + if inplace: + target = self + else: + if not isinstance(parameters, dict): + # We're going to need to access the sorted order wihin the inner Rust method on + # `target`, so warm up our own cache first so that subsequent calls to + # `assign_parameters` on `self` benefit as well. + _ = self._data.parameters + target = self.copy() + target._increment_instances() + target._name_update() + + if isinstance(parameters, collections.abc.Mapping): + raw_mapping = parameters if flat_input else self._unroll_param_dict(parameters) + our_parameters = self._data.unsorted_parameters() + if strict and (extras := raw_mapping.keys() - our_parameters): + raise CircuitError( + f"Cannot bind parameters ({', '.join(str(x) for x in extras)}) not present in" + " the circuit." + ) + parameter_binds = _ParameterBindsDict(raw_mapping, our_parameters) + target._data.assign_parameters_mapping(parameter_binds) + else: + parameter_binds = _ParameterBindsSequence(target._data.parameters, parameters) + target._data.assign_parameters_iterable(parameters) + + # Finally, assign the parameters inside any of the calibrations. We don't track these in + # the `ParameterTable`, so we manually reconstruct things. + def map_calibration(qubits, parameters, schedule): + modified = False + new_parameters = list(parameters) + for i, parameter in enumerate(new_parameters): + if not isinstance(parameter, ParameterExpression): + continue + if not (contained := parameter.parameters & parameter_binds.mapping.keys()): + continue + for to_bind in contained: + parameter = parameter.assign(to_bind, parameter_binds.mapping[to_bind]) + if not parameter.parameters: + parameter = parameter.numeric() + if isinstance(parameter, complex): + raise TypeError(f"Calibration cannot use complex number: '{parameter}'") + new_parameters[i] = parameter + modified = True + if modified: + schedule.assign_parameters(parameter_binds.mapping) + return (qubits, tuple(new_parameters)), schedule + + target._calibrations = defaultdict( + dict, + ( + ( + gate, + dict( + map_calibration(qubits, parameters, schedule) + for (qubits, parameters), schedule in calibrations.items() + ), + ) + for gate, calibrations in target._calibrations.items() + ), + ) + return None if inplace else target + + +def add_calibration( + self, + gate: Union[Gate, str], + qubits: Sequence[int], + # Schedule has the type `qiskit.pulse.Schedule`, but `qiskit.pulse` cannot be imported + # while this module is, and so Sphinx will not accept a forward reference to it. Sphinx + # needs the types available at runtime, whereas mypy will accept it, because it handles the + # type checking by static analysis. + schedule, + params: Optional[Sequence[ParameterValueType]] = None, +) -> None: + """Register a low-level, custom pulse definition for the given gate. + + Args: + gate (Union[Gate, str]): Gate information. + qubits (Union[int, Tuple[int]]): List of qubits to be measured. + schedule (Schedule): Schedule information. + params (Optional[List[Union[float, Parameter]]]): A list of parameters. + + Raises: + Exception: if the gate is of type string and params is None. + """ + + def _format(operand): + try: + # Using float/complex value as a dict key is not good idea. + # This makes the mapping quite sensitive to the rounding error. + # However, the mechanism is already tied to the execution model (i.e. pulse gate) + # and we cannot easily update this rule. + # The same logic exists in DAGCircuit.add_calibration. + evaluated = complex(operand) + if np.isreal(evaluated): + evaluated = float(evaluated.real) + if evaluated.is_integer(): + evaluated = int(evaluated) + return evaluated + except TypeError: + # Unassigned parameter + return operand + + if isinstance(gate, Gate): + params = gate.params + gate = gate.name + if params is not None: + params = tuple(map(_format, params)) + else: + params = () + + self._calibrations[gate][(tuple(qubits), params)] = schedule + + +def compose( + self, + other: Union["QuantumCircuit", Instruction], + qubits: Optional[Union[QubitSpecifier, Sequence[QubitSpecifier]]] = None, + clbits: Optional[Union[ClbitSpecifier, Sequence[ClbitSpecifier]]] = None, + front: bool = False, + inplace: bool = False, + wrap: bool = False, + *, + copy: bool = True, + var_remap: Optional[Mapping[Union[str, expr.Var], Union[str, expr.Var]]] = None, + inline_captures: bool = False, +) -> Optional["QuantumCircuit"]: + """Apply the instructions from one circuit onto specified qubits and/or clbits on another. + + .. note:: + + By default, this creates a new circuit object, leaving ``self`` untouched. For most + uses of this function, it is far more efficient to set ``inplace=True`` and modify the + base circuit in-place. + + When dealing with realtime variables (:class:`.expr.Var` instances), there are two principal + strategies for using :meth:`compose`: + + 1. The ``other`` circuit is treated as entirely additive, including its variables. The + variables in ``other`` must be entirely distinct from those in ``self`` (use + ``var_remap`` to help with this), and all variables in ``other`` will be declared anew in + the output with matching input/capture/local scoping to how they are in ``other``. This + is generally what you want if you're joining two unrelated circuits. + + 2. The ``other`` circuit was created as an exact extension to ``self`` to be inlined onto + it, including acting on the existing variables in their states at the end of ``self``. + In this case, ``other`` should be created with all these variables to be inlined declared + as "captures", and then you can use ``inline_captures=True`` in this method to link them. + This is generally what you want if you're building up a circuit by defining layers + on-the-fly, or rebuilding a circuit using layers taken from itself. You might find the + ``vars_mode="captures"`` argument to :meth:`copy_empty_like` useful to create each + layer's base, in this case. + + Args: + other (qiskit.circuit.Instruction or QuantumCircuit): + (sub)circuit or instruction to compose onto self. If not a :obj:`.QuantumCircuit`, + this can be anything that :obj:`.append` will accept. + qubits (list[Qubit|int]): qubits of self to compose onto. + clbits (list[Clbit|int]): clbits of self to compose onto. + front (bool): If ``True``, front composition will be performed. This is not possible within + control-flow builder context managers. + inplace (bool): If ``True``, modify the object. Otherwise, return composed circuit. + copy (bool): If ``True`` (the default), then the input is treated as shared, and any + contained instructions will be copied, if they might need to be mutated in the + future. You can set this to ``False`` if the input should be considered owned by + the base circuit, in order to avoid unnecessary copies; in this case, it is not + valid to use ``other`` afterward, and some instructions may have been mutated in + place. + var_remap (Mapping): mapping to use to rewrite :class:`.expr.Var` nodes in ``other`` as + they are inlined into ``self``. This can be used to avoid naming conflicts. + + Both keys and values can be given as strings or direct :class:`.expr.Var` instances. + If a key is a string, it matches any :class:`~.expr.Var` with the same name. If a + value is a string, whenever a new key matches a it, a new :class:`~.expr.Var` is + created with the correct type. If a value is a :class:`~.expr.Var`, its + :class:`~.expr.Expr.type` must exactly match that of the variable it is replacing. + inline_captures (bool): if ``True``, then all "captured" :class:`~.expr.Var` nodes in + the ``other`` :class:`.QuantumCircuit` are assumed to refer to variables already + declared in ``self`` (as any input/capture/local type), and the uses in ``other`` + will apply to the existing variables. If you want to build up a layer for an + existing circuit to use with :meth:`compose`, you might find the + ``vars_mode="captures"`` argument to :meth:`copy_empty_like` useful. Any remapping + in ``vars_remap`` occurs before evaluating this variable inlining. + + If this is ``False`` (the default), then all variables in ``other`` will be required + to be distinct from those in ``self``, and new declarations will be made for them. + wrap (bool): If True, wraps the other circuit into a gate (or instruction, depending on + whether it contains only unitary instructions) before composing it onto self. + Rather than using this option, it is almost always better to manually control this + yourself by using :meth:`to_instruction` or :meth:`to_gate`, and then call + :meth:`append`. + + Returns: + QuantumCircuit: the composed circuit (returns None if inplace==True). + + Raises: + CircuitError: if no correct wire mapping can be made between the two circuits, such as + if ``other`` is wider than ``self``. + CircuitError: if trying to emit a new circuit while ``self`` has a partially built + control-flow context active, such as the context-manager forms of :meth:`if_test`, + :meth:`for_loop` and :meth:`while_loop`. + CircuitError: if trying to compose to the front of a circuit when a control-flow builder + block is active; there is no clear meaning to this action. + + Examples: + .. code-block:: python + + >>> lhs.compose(rhs, qubits=[3, 2], inplace=True) + + .. parsed-literal:: + + ┌───┐ ┌─────┐ ┌───┐ + lqr_1_0: ───┤ H ├─── rqr_0: ──■──┤ Tdg ├ lqr_1_0: ───┤ H ├─────────────── + ├───┤ ┌─┴─┐└─────┘ ├───┤ + lqr_1_1: ───┤ X ├─── rqr_1: ┤ X ├─────── lqr_1_1: ───┤ X ├─────────────── + ┌──┴───┴──┐ └───┘ ┌──┴───┴──┐┌───┐ + lqr_1_2: ┤ U1(0.1) ├ + = lqr_1_2: ┤ U1(0.1) ├┤ X ├─────── + └─────────┘ └─────────┘└─┬─┘┌─────┐ + lqr_2_0: ─────■───── lqr_2_0: ─────■───────■──┤ Tdg ├ + ┌─┴─┐ ┌─┴─┐ └─────┘ + lqr_2_1: ───┤ X ├─── lqr_2_1: ───┤ X ├─────────────── + └───┘ └───┘ + lcr_0: 0 ═══════════ lcr_0: 0 ═══════════════════════ + + lcr_1: 0 ═══════════ lcr_1: 0 ═══════════════════════ + + """ + + if inplace and front and self._control_flow_scopes: + # If we're composing onto ourselves while in a stateful control-flow builder context, + # there's no clear meaning to composition to the "front" of the circuit. + raise CircuitError( + "Cannot compose to the front of a circuit while a control-flow context is active." + ) + if not inplace and self._control_flow_scopes: + # If we're inside a stateful control-flow builder scope, even if we successfully cloned + # the partial builder scope (not simple), the scope wouldn't be controlled by an active + # `with` statement, so the output circuit would be permanently broken. + raise CircuitError( + "Cannot emit a new composed circuit while a control-flow context is active." + ) + + # Avoid mutating `dest` until as much of the error checking as possible is complete, to + # avoid an in-place composition getting `self` in a partially mutated state for a simple + # error that the user might want to correct in an interactive session. + dest = self if inplace else self.copy() + + var_remap = {} if var_remap is None else var_remap + + # This doesn't use `functools.cache` so we can access it during the variable remapping of + # instructions. We cache all replacement lookups for a) speed and b) to ensure that + # the same variable _always_ maps to the same replacement even if it's used in different + # places in the recursion tree (such as being a captured variable). + def replace_var(var: expr.Var, cache: Mapping[expr.Var, expr.Var]) -> expr.Var: + # This is closing over an argument to `compose`. + nonlocal var_remap + + if out := cache.get(var): + return out + if (replacement := var_remap.get(var)) or (replacement := var_remap.get(var.name)): + if isinstance(replacement, str): + replacement = expr.Var.new(replacement, var.type) + if replacement.type != var.type: + raise CircuitError( + f"mismatched types in replacement for '{var.name}':" + f" '{var.type}' cannot become '{replacement.type}'" + ) + else: + replacement = var + cache[var] = replacement + return replacement + + # As a special case, allow composing some clbits onto no clbits - normally the destination + # has to be strictly larger. This allows composing final measurements onto unitary circuits. + if isinstance(other, QuantumCircuit): + if not self.clbits and other.clbits: + if dest._control_flow_scopes: + raise CircuitError("cannot implicitly add clbits while within a control-flow scope") + dest.add_bits(other.clbits) + for reg in other.cregs: + dest.add_register(reg) + + if wrap and isinstance(other, QuantumCircuit): + other = ( + other.to_gate() + if all(isinstance(ins.operation, Gate) for ins in other.data) + else other.to_instruction() + ) + + if not isinstance(other, QuantumCircuit): + if qubits is None: + qubits = self.qubits[: other.num_qubits] + if clbits is None: + clbits = self.clbits[: other.num_clbits] + if front: + # Need to keep a reference to the data for use after we've emptied it. + old_data = dest._data.copy(copy_instructions=copy) + dest.clear() + dest.append(other, qubits, clbits, copy=copy) + for instruction in old_data: + dest._append(instruction) + else: + dest.append(other, qargs=qubits, cargs=clbits, copy=copy) + return None if inplace else dest + + if other.num_qubits > dest.num_qubits or other.num_clbits > dest.num_clbits: + raise CircuitError( + "Trying to compose with another QuantumCircuit which has more 'in' edges." + ) + + # Maps bits in 'other' to bits in 'dest'. + mapped_qubits: list[Qubit] + mapped_clbits: list[Clbit] + edge_map: dict[Union[Qubit, Clbit], Union[Qubit, Clbit]] = {} + if qubits is None: + mapped_qubits = dest.qubits + edge_map.update(zip(other.qubits, dest.qubits)) + else: + mapped_qubits = dest._qbit_argument_conversion(qubits) + if len(mapped_qubits) != other.num_qubits: + raise CircuitError( + f"Number of items in qubits parameter ({len(mapped_qubits)}) does not" + f" match number of qubits in the circuit ({other.num_qubits})." + ) + if len(set(mapped_qubits)) != len(mapped_qubits): + raise CircuitError( + f"Duplicate qubits referenced in 'qubits' parameter: '{mapped_qubits}'" + ) + edge_map.update(zip(other.qubits, mapped_qubits)) + + if clbits is None: + mapped_clbits = dest.clbits + edge_map.update(zip(other.clbits, dest.clbits)) + else: + mapped_clbits = dest._cbit_argument_conversion(clbits) + if len(mapped_clbits) != other.num_clbits: + raise CircuitError( + f"Number of items in clbits parameter ({len(mapped_clbits)}) does not" + f" match number of clbits in the circuit ({other.num_clbits})." + ) + if len(set(mapped_clbits)) != len(mapped_clbits): + raise CircuitError( + f"Duplicate clbits referenced in 'clbits' parameter: '{mapped_clbits}'" + ) + edge_map.update(zip(other.clbits, dest._cbit_argument_conversion(clbits))) + + for gate, cals in other.calibrations.items(): + dest._calibrations[gate].update(cals) + + dest.duration = None + dest.unit = "dt" + dest.global_phase += other.global_phase + + # This is required to trigger data builds if the `other` is an unbuilt `BlueprintCircuit`, + # so we can the access the complete `CircuitData` object at `_data`. + _ = other.data + + def copy_with_remapping( + source, dest, bit_map, var_map, inline_captures, new_qubits=None, new_clbits=None + ): + # Copy the instructions from `source` into `dest`, remapping variables in instructions + # according to `var_map`. If `new_qubits` or `new_clbits` are given, the qubits and + # clbits of the source instruction are remapped to those as well. + for var in source.iter_input_vars(): + dest.add_input(replace_var(var, var_map)) + if inline_captures: + for var in source.iter_captured_vars(): + replacement = replace_var(var, var_map) + if not dest.has_var(replace_var(var, var_map)): + if var is replacement: + raise CircuitError( + f"Variable '{var}' to be inlined is not in the base circuit." + " If you wanted it to be automatically added, use" + " `inline_captures=False`." + ) + raise CircuitError( + f"Replacement '{replacement}' for variable '{var}' is not in the" + " base circuit. Is the replacement correct?" + ) + else: + for var in source.iter_captured_vars(): + dest.add_capture(replace_var(var, var_map)) + for var in source.iter_declared_vars(): + dest.add_uninitialized_var(replace_var(var, var_map)) + + def recurse_block(block): + # Recurse the remapping into a control-flow block. Note that this doesn't remap the + # clbits within; the story around nested classical-register-based control-flow + # doesn't really work in the current data model, and we hope to replace it with + # `Expr`-based control-flow everywhere. + new_block = block.copy_empty_like() + new_block._vars_input = {} + new_block._vars_capture = {} + new_block._vars_local = {} + # For the recursion, we never want to inline captured variables because we're not + # copying onto a base that has variables. + copy_with_remapping(block, new_block, bit_map, var_map, inline_captures=False) + return new_block + + variable_mapper = _classical_resource_map.VariableMapper( + dest.cregs, bit_map, var_map, add_register=dest.add_register + ) + + def map_vars(op): + n_op = op + is_control_flow = isinstance(n_op, ControlFlowOp) + if not is_control_flow and (condition := getattr(n_op, "condition", None)) is not None: + n_op = n_op.copy() if n_op is op and copy else n_op + n_op.condition = variable_mapper.map_condition(condition) + elif is_control_flow: + n_op = n_op.replace_blocks(recurse_block(block) for block in n_op.blocks) + if isinstance(n_op, (IfElseOp, WhileLoopOp)): + n_op.condition = variable_mapper.map_condition(n_op.condition) + elif isinstance(n_op, SwitchCaseOp): + n_op.target = variable_mapper.map_target(n_op.target) + elif isinstance(n_op, Store): + n_op = Store( + variable_mapper.map_expr(n_op.lvalue), variable_mapper.map_expr(n_op.rvalue) + ) + return n_op.copy() if n_op is op and copy else n_op + + instructions = source._data.copy(copy_instructions=copy) + instructions.replace_bits(qubits=new_qubits, clbits=new_clbits) + instructions.map_nonstandard_ops(map_vars) + dest._current_scope().extend(instructions) + + append_existing = None + if front: + append_existing = dest._data.copy(copy_instructions=copy) + dest.clear() + copy_with_remapping( + other, + dest, + bit_map=edge_map, + # The actual `Var: Var` map gets built up from the more freeform user input as we + # encounter the variables, since the user might be using string keys to refer to more + # than one variable in separated scopes of control-flow operations. + var_map={}, + inline_captures=inline_captures, + new_qubits=mapped_qubits, + new_clbits=mapped_clbits, + ) + if append_existing: + dest._current_scope().extend(append_existing) + + return None if inplace else dest + + +def copy_empty_like( + self, + name: Optional[str] = None, + *, + vars_mode: Literal["alike", "captures", "drop"] = "alike", +) -> typing.Self: + """Return a copy of self with the same structure but empty. + + That structure includes: + + * name, calibrations and other metadata + * global phase + * all the qubits and clbits, including the registers + * the realtime variables defined in the circuit, handled according to the ``vars`` keyword + argument. + + .. warning:: + + If the circuit contains any local variable declarations (those added by the + ``declarations`` argument to the circuit constructor, or using :meth:`add_var`), they + may be **uninitialized** in the output circuit. You will need to manually add store + instructions for them (see :class:`.Store` and :meth:`.QuantumCircuit.store`) to + initialize them. + + Args: + name: Name for the copied circuit. If None, then the name stays the same. + vars_mode: The mode to handle realtime variables in. + + alike + The variables in the output circuit will have the same declaration semantics as + in the original circuit. For example, ``input`` variables in the source will be + ``input`` variables in the output circuit. + + captures + All variables will be converted to captured variables. This is useful when you + are building a new layer for an existing circuit that you will want to + :meth:`compose` onto the base, since :meth:`compose` can inline captures onto + the base circuit (but not other variables). + + drop + The output circuit will have no variables defined. + + Returns: + QuantumCircuit: An empty copy of self. + """ + if not (name is None or isinstance(name, str)): + raise TypeError( + f"invalid name for a circuit: '{name}'. The name must be a string or 'None'." + ) + cpy = _copy.copy(self) + # copy registers correctly, in copy.copy they are only copied via reference + cpy.qregs = self.qregs.copy() + cpy.cregs = self.cregs.copy() + cpy._builder_api = _OuterCircuitScopeInterface(cpy) + cpy._ancillas = self._ancillas.copy() + cpy._qubit_indices = self._qubit_indices.copy() + cpy._clbit_indices = self._clbit_indices.copy() + + if vars_mode == "alike": + # Note that this causes the local variables to be uninitialised, because the stores are + # not copied. This can leave the circuit in a potentially dangerous state for users if + # they don't re-add initializer stores. + cpy._vars_local = self._vars_local.copy() + cpy._vars_input = self._vars_input.copy() + cpy._vars_capture = self._vars_capture.copy() + elif vars_mode == "captures": + cpy._vars_local = {} + cpy._vars_input = {} + cpy._vars_capture = {var.name: var for var in self.iter_vars()} + elif vars_mode == "drop": + cpy._vars_local = {} + cpy._vars_input = {} + cpy._vars_capture = {} + else: # pragma: no cover + raise ValueError(f"unknown vars_mode: '{vars_mode}'") + + cpy._data = CircuitData( + self._data.qubits, self._data.clbits, global_phase=self._data.global_phase + ) + + cpy._calibrations = _copy.deepcopy(self._calibrations) + cpy._metadata = _copy.deepcopy(self._metadata) + + if name: + cpy.name = name + return cpy + + +if not hasattr(QuantumCircuit, "calibrations"): + setattr(QuantumCircuit, "_calibrations", defaultdict(dict)) + setattr(QuantumCircuit, "calibrations", property(calibrations_getter, calibrations_setter)) + setattr(QuantumCircuit, "add_calibration", add_calibration) + setattr(QuantumCircuit, "has_calibration_for", has_calibration_for) + setattr(QuantumCircuit, "_op_start_times", None) + setattr(QuantumCircuit, "assign_parameters", assign_parameters) + setattr(QuantumCircuit, "compose", compose) + setattr(QuantumCircuit, "copy_empty_like", copy_empty_like) diff --git a/qiskit_dynamics/pulse/utils.py b/qiskit_dynamics/pulse/utils.py new file mode 100644 index 000000000..8c4873ecb --- /dev/null +++ b/qiskit_dynamics/pulse/utils.py @@ -0,0 +1,149 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Module for common pulse programming utilities.""" +from typing import List, Dict, Union, Sequence +import warnings + +import numpy as np + +from qiskit.circuit import ParameterVector, Parameter +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse.exceptions import UnassignedDurationError, QiskitError, PulseError + + +def format_meas_map(meas_map: List[List[int]]) -> Dict[int, List[int]]: + """ + Return a mapping from qubit label to measurement group given the nested list meas_map returned + by a backend configuration. (Qubits can not always be measured independently.) Sorts the + measurement group for consistency. + + Args: + meas_map: Groups of qubits that get measured together, for example: [[0, 1], [2, 3, 4]] + Returns: + Measure map in map format + """ + qubit_mapping = {} + for sublist in meas_map: + sublist.sort() + for q in sublist: + qubit_mapping[q] = sublist + return qubit_mapping + + +def format_parameter_value( + operand: ParameterExpression, + decimal: int = 10, +) -> Union[ParameterExpression, complex]: + """Convert ParameterExpression into the most suitable data type. + + Args: + operand: Operand value in arbitrary data type including ParameterExpression. + decimal: Number of digit to round returned value. + + Returns: + Value casted to non-parameter data type, when possible. + """ + if isinstance(operand, ParameterExpression): + try: + operand = operand.numeric() + except TypeError: + # Unassigned expression + return operand + + # Return integer before calling the numpy round function. + # The input value is multiplied by 10**decimals, rounds to an integer + # and divided by 10**decimals. For a large enough integer, + # this operation may introduce a rounding error in the float operations + # and accidentally returns a float number. + if isinstance(operand, int): + return operand + + # Remove truncation error and convert the result into Python builtin type. + # Value could originally contain a rounding error, e.g. 1.00000000001 + # which may occur during the parameter expression evaluation. + evaluated = np.round(operand, decimals=decimal).item() + + if isinstance(evaluated, complex): + if np.isclose(evaluated.imag, 0.0): + evaluated = evaluated.real + else: + warnings.warn( + "Assignment of complex values to ParameterExpression in Qiskit Pulse objects is " + "now pending deprecation. This will align the Pulse module with other modules " + "where such assignment wasn't possible to begin with. The typical use case for complex " + "parameters in the module was the SymbolicPulse library. As of Qiskit-Terra " + "0.23.0 all library pulses were converted from complex amplitude representation" + " to real representation using two floats (amp,angle), as used in the " + "ScalableSymbolicPulse class. This eliminated the need for complex parameters. " + "Any use of complex parameters (and particularly custom-built pulses) should be " + "converted in a similar fashion to avoid the use of complex parameters.", + PendingDeprecationWarning, + ) + return evaluated + # Type cast integer-like float into Python builtin integer, after rounding. + if evaluated.is_integer(): + return int(evaluated) + return evaluated + + +def instruction_duration_validation(duration: int): + """Validate instruction duration. + + Args: + duration: Instruction duration value to validate. + + Raises: + UnassignedDurationError: When duration is unassigned. + QiskitError: When invalid duration is assigned. + """ + if isinstance(duration, ParameterExpression): + raise UnassignedDurationError( + f"Instruction duration {repr(duration)} is not assigned. " + "Please bind all durations to an integer value before playing in the Schedule, " + "or use ScheduleBlock to align instructions with unassigned duration." + ) + + if not isinstance(duration, (int, np.integer)) or duration < 0: + raise QiskitError( + f"Instruction duration must be a non-negative integer, got {duration} instead." + ) + + +def _validate_parameter_vector(parameter: ParameterVector, value): + """Validate parameter vector and its value.""" + if not isinstance(value, Sequence): + raise PulseError( + f"Parameter vector '{parameter.name}' has length {len(parameter)}," + f" but was assigned to {value}." + ) + if len(parameter) != len(value): + raise PulseError( + f"Parameter vector '{parameter.name}' has length {len(parameter)}," + f" but was assigned to {len(value)} values." + ) + + +def _validate_single_parameter(parameter: Parameter, value): + """Validate single parameter and its value.""" + if not isinstance(value, (int, float, complex, ParameterExpression)): + raise PulseError(f"Parameter '{parameter.name}' is not assignable to {value}.") + + +def _validate_parameter_value(parameter, value): + """Validate parameter and its value.""" + if isinstance(parameter, ParameterVector): + _validate_parameter_vector(parameter, value) + return True + else: + _validate_single_parameter(parameter, value) + return False diff --git a/qiskit_dynamics/pulse/visualization/__init__.py b/qiskit_dynamics/pulse/visualization/__init__.py new file mode 100644 index 000000000..9d49c25a6 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/__init__.py @@ -0,0 +1,21 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +""" +Pulse visualization module. +""" + +# interface +from qiskit_dynamics.pulse.visualization.interface import draw + +# stylesheets +from qiskit_dynamics.pulse.visualization.stylesheet import IQXStandard, IQXSimple, IQXDebugging diff --git a/qiskit_dynamics/pulse/visualization/core.py b/qiskit_dynamics/pulse/visualization/core.py new file mode 100644 index 000000000..20686f6fb --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/core.py @@ -0,0 +1,901 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +""" +Core module of the pulse drawer. + +This module provides the `DrawerCanvas` which is a collection of `Chart` object. +The `Chart` object is a collection of drawings. A user can assign multiple channels +to a single chart instance. For example, we can define a chart for specific qubit +and assign all related channels to the chart. This chart-channel mapping is defined by +the function specified by ``layout.chart_channel_map`` of the stylesheet. + +Because this chart instance is decoupled from the coordinate system of the plotter, +we can arbitrarily place charts on the plotter canvas, i.e. if we want to create 3D plot, +each chart may be placed on the X-Z plane and charts are arranged along the Y-axis. +Thus this data model maximizes the flexibility to generate an output image. + +The chart instance is not just a container of drawings, as it also performs +data processing like binding abstract coordinates and truncating long pulses for an axis break. +Each chart object has `.parent` which points to the `DrawerCanvas` instance so that +each child chart can refer to the global figure settings such as time range and axis break. + + +Initialization +~~~~~~~~~~~~~~ +The `DataCanvas` and `Chart` are not exposed to users as they are implicitly +initialized in the interface function. It is noteworthy that the data canvas is agnostic +to plotters. This means once the canvas instance is initialized we can reuse this data +among multiple plotters. The canvas is initialized with a stylesheet and quantum backend +information :py:class:`~qiskit.visualization.pulse_v2.device_info.DrawerBackendInfo`. +Chart instances are automatically generated when pulse program is loaded. + + ```python + canvas = DrawerCanvas(stylesheet=stylesheet, device=device) + canvas.load_program(sched) + canvas.update() + ``` + +Once all properties are set, `.update` method is called to apply changes to drawings. +If the `DrawDataContainer` is initialized without backend information, the output shows +the time in units of the system cycle time `dt` and the frequencies are initialized to zero. + +Update +~~~~~~ +To update the image, a user can set new values to canvas and then call the `.update` method. + + ```python + canvas.set_time_range(2000, 3000, seconds=False) + canvas.update() + ``` + +All stored drawings are updated accordingly. The plotter API can access to +drawings with `.collections` property of chart instance. This returns +an iterator of drawing with the unique data key. +If a plotter provides object handler for plotted shapes, the plotter API can manage +the lookup table of the handler and the drawing by using this data key. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from copy import deepcopy +from enum import Enum +from functools import partial +from itertools import chain + +import numpy as np +from qiskit import pulse +from qiskit.pulse.transforms import target_qobj_transform +from qiskit.visualization.exceptions import VisualizationError +from qiskit.visualization.pulse_v2 import events, types, drawings, device_info +from qiskit.visualization.pulse_v2.stylesheet import QiskitPulseStyle + + +class DrawerCanvas: + """Collection of `Chart` and configuration data. + + Pulse channels are associated with some `Chart` instance and + drawing data object are stored in the `Chart` instance. + + Device, stylesheet, and some user generators are stored in the `DrawingCanvas` + and `Chart` instances are also attached to the `DrawerCanvas` as children. + Global configurations are accessed by those children to modify + the appearance of the `Chart` output. + """ + + def __init__(self, stylesheet: QiskitPulseStyle, device: device_info.DrawerBackendInfo): + """Create new data container with backend system information. + + Args: + stylesheet: Stylesheet to decide appearance of output image. + device: Backend information to run the program. + """ + # stylesheet + self.formatter = stylesheet.formatter + self.generator = stylesheet.generator + self.layout = stylesheet.layout + + # device info + self.device = device + + # chart + self.global_charts = Chart(parent=self, name="global") + self.charts: list[Chart] = [] + + # visible controls + self.disable_chans: set[pulse.channels.Channel] = set() + self.disable_types: set[str] = set() + + # data scaling + self.chan_scales: dict[ + pulse.channels.DriveChannel + | pulse.channels.MeasureChannel + | pulse.channels.ControlChannel + | pulse.channels.AcquireChannel, + float, + ] = {} + + # global time + self._time_range = (0, 0) + self._time_breaks: list[tuple[int, int]] = [] + + # title + self.fig_title = "" + + @property + def time_range(self) -> tuple[int, int]: + """Return current time range to draw. + + Calculate net duration and add side margin to edge location. + + Returns: + Time window considering side margin. + """ + t0, t1 = self._time_range + + total_time_elimination = 0 + for t0b, t1b in self.time_breaks: + if t1b > t0 and t0b < t1: + total_time_elimination += t1b - t0b + net_duration = t1 - t0 - total_time_elimination + + new_t0 = t0 - net_duration * self.formatter["margin.left_percent"] + new_t1 = t1 + net_duration * self.formatter["margin.right_percent"] + + return new_t0, new_t1 + + @time_range.setter + def time_range(self, new_range: tuple[int, int]): + """Update time range to draw.""" + self._time_range = new_range + + @property + def time_breaks(self) -> list[tuple[int, int]]: + """Return time breaks with time range. + + If an edge of time range is in the axis break period, + the axis break period is recalculated. + + Raises: + VisualizationError: When axis break is greater than time window. + + Returns: + List of axis break periods considering the time window edges. + """ + t0, t1 = self._time_range + + axis_breaks = [] + for t0b, t1b in self._time_breaks: + if t0b >= t1 or t1b <= t0: + # skip because break period is outside of time window + continue + + if t0b < t0 and t1b > t1: + raise VisualizationError( + "Axis break is greater than time window. Nothing will be drawn." + ) + if t0b < t0 < t1b: + if t1b - t0 > self.formatter["axis_break.length"]: + new_t0 = t0 + 0.5 * self.formatter["axis_break.max_length"] + axis_breaks.append((new_t0, t1b)) + continue + if t0b < t1 < t1b: + if t1 - t0b > self.formatter["axis_break.length"]: + new_t1 = t1 - 0.5 * self.formatter["axis_break.max_length"] + axis_breaks.append((t0b, new_t1)) + continue + axis_breaks.append((t0b, t1b)) + + return axis_breaks + + @time_breaks.setter + def time_breaks(self, new_breaks: list[tuple[int, int]]): + """Set new time breaks.""" + self._time_breaks = sorted(new_breaks, key=lambda x: x[0]) + + def load_program( + self, + program: pulse.Waveform | pulse.SymbolicPulse | pulse.Schedule | pulse.ScheduleBlock, + ): + """Load a program to draw. + + Args: + program: Pulse program or waveform to draw. + + Raises: + VisualizationError: When input program is invalid data format. + """ + if isinstance(program, (pulse.Schedule, pulse.ScheduleBlock)): + self._schedule_loader(program) + elif isinstance(program, (pulse.Waveform, pulse.SymbolicPulse)): + self._waveform_loader(program) + else: + raise VisualizationError(f"Data type {type(program)} is not supported.") + + # update time range + self.set_time_range(0, program.duration, seconds=False) + + # set title + self.fig_title = self.layout["figure_title"](program=program, device=self.device) + + def _waveform_loader( + self, + program: pulse.Waveform | pulse.SymbolicPulse, + ): + """Load Waveform instance. + + This function is sub-routine of py:method:`load_program`. + + Args: + program: `Waveform` to draw. + """ + chart = Chart(parent=self) + + # add waveform data + fake_inst = pulse.Play(program, types.WaveformChannel()) + inst_data = types.PulseInstruction( + t0=0, + dt=self.device.dt, + frame=types.PhaseFreqTuple(phase=0, freq=0), + inst=fake_inst, + is_opaque=program.is_parameterized(), + ) + for gen in self.generator["waveform"]: + obj_generator = partial(gen, formatter=self.formatter, device=self.device) + for data in obj_generator(inst_data): + chart.add_data(data) + + self.charts.append(chart) + + def _schedule_loader(self, program: pulse.Schedule | pulse.ScheduleBlock): + """Load Schedule instance. + + This function is sub-routine of py:method:`load_program`. + + Args: + program: `Schedule` to draw. + """ + program = target_qobj_transform(program, remove_directives=False) + + # initialize scale values + self.chan_scales = {} + for chan in program.channels: + if isinstance(chan, pulse.channels.DriveChannel): + self.chan_scales[chan] = self.formatter["channel_scaling.drive"] + elif isinstance(chan, pulse.channels.MeasureChannel): + self.chan_scales[chan] = self.formatter["channel_scaling.measure"] + elif isinstance(chan, pulse.channels.ControlChannel): + self.chan_scales[chan] = self.formatter["channel_scaling.control"] + elif isinstance(chan, pulse.channels.AcquireChannel): + self.chan_scales[chan] = self.formatter["channel_scaling.acquire"] + else: + self.chan_scales[chan] = 1.0 + + # create charts + mapper = self.layout["chart_channel_map"] + for name, chans in mapper( + channels=program.channels, formatter=self.formatter, device=self.device + ): + + chart = Chart(parent=self, name=name) + + # add standard pulse instructions + for chan in chans: + chart.load_program(program=program, chan=chan) + + # add barriers + barrier_sched = program.filter( + instruction_types=[pulse.instructions.RelativeBarrier], channels=chans + ) + for t0, _ in barrier_sched.instructions: + inst_data = types.BarrierInstruction(t0, self.device.dt, chans) + for gen in self.generator["barrier"]: + obj_generator = partial(gen, formatter=self.formatter, device=self.device) + for data in obj_generator(inst_data): + chart.add_data(data) + + # add chart axis + chart_axis = types.ChartAxis(name=chart.name, channels=chart.channels) + for gen in self.generator["chart"]: + obj_generator = partial(gen, formatter=self.formatter, device=self.device) + for data in obj_generator(chart_axis): + chart.add_data(data) + + self.charts.append(chart) + + # add snapshot data to global + snapshot_sched = program.filter(instruction_types=[pulse.instructions.Snapshot]) + for t0, inst in snapshot_sched.instructions: + inst_data = types.SnapshotInstruction(t0, self.device.dt, inst.label, inst.channels) + for gen in self.generator["snapshot"]: + obj_generator = partial(gen, formatter=self.formatter, device=self.device) + for data in obj_generator(inst_data): + self.global_charts.add_data(data) + + # calculate axis break + self.time_breaks = self._calculate_axis_break(program) + + def _calculate_axis_break(self, program: pulse.Schedule) -> list[tuple[int, int]]: + """A helper function to calculate axis break of long pulse sequence. + + Args: + program: A schedule to calculate axis break. + + Returns: + List of axis break periods. + """ + axis_breaks = [] + + edges = set() + for t0, t1 in chain.from_iterable(program.timeslots.values()): + if t1 - t0 > 0: + edges.add(t0) + edges.add(t1) + edges = sorted(edges) + + for t0, t1 in zip(edges[:-1], edges[1:]): + if t1 - t0 > self.formatter["axis_break.length"]: + t_l = t0 + 0.5 * self.formatter["axis_break.max_length"] + t_r = t1 - 0.5 * self.formatter["axis_break.max_length"] + axis_breaks.append((t_l, t_r)) + + return axis_breaks + + def set_time_range(self, t_start: float, t_end: float, seconds: bool = True): + """Set time range to draw. + + All child chart instances are updated when time range is updated. + + Args: + t_start: Left boundary of drawing in units of cycle time or real time. + t_end: Right boundary of drawing in units of cycle time or real time. + seconds: Set `True` if times are given in SI unit rather than dt. + + Raises: + VisualizationError: When times are given in float without specifying dt. + """ + # convert into nearest cycle time + if seconds: + if self.device.dt is not None: + t_start = int(np.round(t_start / self.device.dt)) + t_end = int(np.round(t_end / self.device.dt)) + else: + raise VisualizationError( + "Setting time range with SI units requires backend `dt` information." + ) + self.time_range = (t_start, t_end) + + def set_disable_channel(self, channel: pulse.channels.Channel, remove: bool = True): + """Interface method to control visibility of pulse channels. + + Specified object in the blocked list will not be shown. + + Args: + channel: A pulse channel object to disable. + remove: Set `True` to disable, set `False` to enable. + """ + if remove: + self.disable_chans.add(channel) + else: + self.disable_chans.discard(channel) + + def set_disable_type(self, data_type: types.DataTypes, remove: bool = True): + """Interface method to control visibility of data types. + + Specified object in the blocked list will not be shown. + + Args: + data_type: A drawing data type to disable. + remove: Set `True` to disable, set `False` to enable. + """ + if isinstance(data_type, Enum): + data_type_str = str(data_type.value) + else: + data_type_str = data_type + + if remove: + self.disable_types.add(data_type_str) + else: + self.disable_types.discard(data_type_str) + + def update(self): + """Update all associated charts and generate actual drawing data from template object. + + This method should be called before the canvas is passed to the plotter. + """ + for chart in self.charts: + chart.update() + + +class Chart: + """A collection of drawing to be shown on the same line. + + Multiple pulse channels can be assigned to a single `Chart`. + The parent `DrawerCanvas` should be specified to refer to the current user preference. + + The vertical value of each `Chart` should be in the range [-1, 1]. + This truncation should be performed in the plotter interface. + """ + + # unique index of chart + chart_index = 0 + + # list of waveform type names + waveform_types = [ + str(types.WaveformType.REAL.value), + str(types.WaveformType.IMAG.value), + str(types.WaveformType.OPAQUE.value), + ] + + def __init__(self, parent: DrawerCanvas, name: str | None = None): + """Create new chart. + + Args: + parent: `DrawerCanvas` that this `Chart` instance belongs to. + name: Name of this `Chart` instance. + """ + self.parent = parent + + # data stored in this channel + self._collections: dict[str, drawings.ElementaryData] = {} + self._output_dataset: dict[str, drawings.ElementaryData] = {} + + # channel metadata + self.index = self._cls_index() + self.name = name or "" + self._channels: set[pulse.channels.Channel] = set() + + # vertical axis information + self.vmax = 0 + self.vmin = 0 + self.scale = 1.0 + + self._increment_cls_index() + + def add_data(self, data: drawings.ElementaryData): + """Add drawing to collections. + + If the given object already exists in the collections, + this interface replaces the old object instead of adding new entry. + + Args: + data: New drawing to add. + """ + self._collections[data.data_key] = data + + def load_program(self, program: pulse.Schedule, chan: pulse.channels.Channel): + """Load pulse schedule. + + This method internally generates `ChannelEvents` to parse the program + for the specified pulse channel. This method is called once + + Args: + program: Pulse schedule to load. + chan: A pulse channels associated with this instance. + """ + chan_events = events.ChannelEvents.load_program(program, chan) + chan_events.set_config( + dt=self.parent.device.dt, + init_frequency=self.parent.device.get_channel_frequency(chan), + init_phase=0, + ) + + # create objects associated with waveform + for gen in self.parent.generator["waveform"]: + waveforms = chan_events.get_waveforms() + obj_generator = partial(gen, formatter=self.parent.formatter, device=self.parent.device) + drawing_items = [obj_generator(waveform) for waveform in waveforms] + for drawing_item in list(chain.from_iterable(drawing_items)): + self.add_data(drawing_item) + + # create objects associated with frame change + for gen in self.parent.generator["frame"]: + frames = chan_events.get_frame_changes() + obj_generator = partial(gen, formatter=self.parent.formatter, device=self.parent.device) + drawing_items = [obj_generator(frame) for frame in frames] + for drawing_item in list(chain.from_iterable(drawing_items)): + self.add_data(drawing_item) + + self._channels.add(chan) + + def update(self): + """Update vertical data range and scaling factor of this chart. + + Those parameters are updated based on current time range in the parent canvas. + """ + self._output_dataset.clear() + self.vmax = 0 + self.vmin = 0 + + # waveform + for key, data in self._collections.items(): + if data.data_type not in Chart.waveform_types: + continue + + # truncate, assume no abstract coordinate in waveform sample + trunc_x, trunc_y = self._truncate_data(data) + + # no available data points + if trunc_x.size == 0 or trunc_y.size == 0: + continue + + # update y range + scale = min(self.parent.chan_scales.get(chan, 1.0) for chan in data.channels) + self.vmax = max(scale * np.max(trunc_y), self.vmax) + self.vmin = min(scale * np.min(trunc_y), self.vmin) + + # generate new data + new_data = deepcopy(data) + new_data.xvals = trunc_x + new_data.yvals = trunc_y + + self._output_dataset[key] = new_data + + # calculate chart level scaling factor + if self.parent.formatter["control.auto_chart_scaling"]: + max_val = max( + abs(self.vmax), abs(self.vmin), self.parent.formatter["general.vertical_resolution"] + ) + self.scale = min(1.0 / max_val, self.parent.formatter["general.max_scale"]) + else: + self.scale = 1.0 + + # update vertical range with scaling and limitation + self.vmax = max( + self.scale * self.vmax, self.parent.formatter["channel_scaling.pos_spacing"] + ) + + self.vmin = min( + self.scale * self.vmin, self.parent.formatter["channel_scaling.neg_spacing"] + ) + + # other data + for key, data in self._collections.items(): + if data.data_type in Chart.waveform_types: + continue + + # truncate + trunc_x, trunc_y = self._truncate_data(data) + + # no available data points + if trunc_x.size == 0 or trunc_y.size == 0: + continue + + # generate new data + new_data = deepcopy(data) + new_data.xvals = trunc_x + new_data.yvals = trunc_y + + self._output_dataset[key] = new_data + + @property + def is_active(self) -> bool: + """Check if there is any active waveform data in this entry. + + Returns: + Return `True` if there is any visible waveform in this chart. + """ + for data in self._output_dataset.values(): + if data.data_type in Chart.waveform_types and self._check_visible(data): + return True + return False + + @property + def collections(self) -> Iterator[tuple[str, drawings.ElementaryData]]: + """Return currently active entries from drawing data collection. + + The object is returned with unique name as a key of an object handler. + When the horizontal coordinate contains `AbstractCoordinate`, + the value is substituted by current time range preference. + """ + for name, data in self._output_dataset.items(): + # prepare unique name + unique_id = f"chart{self.index:d}_{name}" + if self._check_visible(data): + yield unique_id, data + + @property + def channels(self) -> list[pulse.channels.Channel]: + """Return a list of channels associated with this chart. + + Returns: + List of channels associated with this chart. + """ + return list(self._channels) + + def _truncate_data(self, data: drawings.ElementaryData) -> tuple[np.ndarray, np.ndarray]: + """A helper function to truncate drawings according to time breaks. + + # TODO: move this function to common module to support axis break for timeline. + + Args: + data: Drawing object to truncate. + + Returns: + Set of truncated numpy arrays for x and y coordinate. + """ + xvals = self._bind_coordinate(data.xvals) + yvals = self._bind_coordinate(data.yvals) + + if isinstance(data, drawings.BoxData): + # truncate box data. these object don't require interpolation at axis break. + return self._truncate_boxes(xvals, yvals) + elif data.data_type in [types.LabelType.PULSE_NAME, types.LabelType.OPAQUE_BOXTEXT]: + # truncate pulse labels. these objects are not removed by truncation. + return self._truncate_pulse_labels(xvals, yvals) + else: + # other objects + return self._truncate_vectors(xvals, yvals) + + def _truncate_pulse_labels( + self, xvals: np.ndarray, yvals: np.ndarray + ) -> tuple[np.ndarray, np.ndarray]: + """A helper function to remove text according to time breaks. + + Args: + xvals: Time points. + yvals: Data points. + + Returns: + Set of truncated numpy arrays for x and y coordinate. + """ + xpos = xvals[0] + t0, t1 = self.parent.time_range + + if xpos < t0 or xpos > t1: + return np.array([]), np.array([]) + offset_accumulation = 0 + for tl, tr in self.parent.time_breaks: + if xpos < tl: + return np.array([xpos - offset_accumulation]), yvals + if tl < xpos < tr: + return np.array([tl - offset_accumulation]), yvals + else: + offset_accumulation += tr - tl + return np.array([xpos - offset_accumulation]), yvals + + def _truncate_boxes( + self, xvals: np.ndarray, yvals: np.ndarray + ) -> tuple[np.ndarray, np.ndarray]: + """A helper function to clip box object according to time breaks. + + Args: + xvals: Time points. + yvals: Data points. + + Returns: + Set of truncated numpy arrays for x and y coordinate. + """ + x0, x1 = xvals + t0, t1 = self.parent.time_range + + if x1 < t0 or x0 > t1: + # out of drawing range + return np.array([]), np.array([]) + + # clip outside + x0 = max(t0, x0) + x1 = min(t1, x1) + + offset_accumulate = 0 + for tl, tr in self.parent.time_breaks: + tl -= offset_accumulate + tr -= offset_accumulate + + # + # truncate, there are 5 patterns wrt the relative position of truncation and xvals + # + if x1 < tl: + break + + if tl < x0 and tr > x1: + # case 1: all data points are truncated + # : +-----+ : + # : |/////| : + # -----:---+-----+---:----- + # l 0 1 r + return np.array([]), np.array([]) + elif tl < x1 < tr: + # case 2: t < tl, right side is truncated + # +---:-----+ : + # | ://///| : + # -----+---:-----+---:----- + # 0 l 1 r + x1 = tl + elif tl < x0 < tr: + # case 3: tr > t, left side is truncated + # : +-----:---+ + # : |/////: | + # -----:---+-----:---+----- + # l 0 r 1 + x0 = tl + x1 = tl + t1 - tr + elif tl > x0 and tr < x1: + # case 4: tr > t > tl, middle part is truncated + # +---:-----:---+ + # | ://///: | + # -----+---:-----:---+----- + # 0 l r 1 + x1 -= tr - tl + elif tr < x0: + # case 5: tr > t > tl, nothing truncated but need time shift + # : : +---+ + # : : | | + # -----:---:-----+---+----- + # l r 0 1 + x0 -= tr - tl + x1 -= tr - tl + + offset_accumulate += tr - tl + + return np.asarray([x0, x1], dtype=float), yvals + + def _truncate_vectors( + self, xvals: np.ndarray, yvals: np.ndarray + ) -> tuple[np.ndarray, np.ndarray]: + """A helper function to remove sequential data points according to time breaks. + + Args: + xvals: Time points. + yvals: Data points. + + Returns: + Set of truncated numpy arrays for x and y coordinate. + """ + xvals = np.asarray(xvals, dtype=float) + yvals = np.asarray(yvals, dtype=float) + t0, t1 = self.parent.time_range + + if max(xvals) < t0 or min(xvals) > t1: + # out of drawing range + return np.array([]), np.array([]) + + if min(xvals) < t0: + # truncate x less than left limit + inds = xvals > t0 + yvals = np.append(np.interp(t0, xvals, yvals), yvals[inds]) + xvals = np.append(t0, xvals[inds]) + + if max(xvals) > t1: + # truncate x larger than right limit + inds = xvals < t1 + yvals = np.append(yvals[inds], np.interp(t1, xvals, yvals)) + xvals = np.append(xvals[inds], t1) + + # time breaks + trunc_xvals = [xvals] + trunc_yvals = [yvals] + offset_accumulate = 0 + for tl, tr in self.parent.time_breaks: + sub_xs = trunc_xvals.pop() + sub_ys = trunc_yvals.pop() + tl -= offset_accumulate + tr -= offset_accumulate + + # + # truncate, there are 5 patterns wrt the relative position of truncation and xvals + # + min_xs = min(sub_xs) + max_xs = max(sub_xs) + if max_xs < tl: + trunc_xvals.append(sub_xs) + trunc_yvals.append(sub_ys) + break + + if tl < min_xs and tr > max_xs: + # case 1: all data points are truncated + # : +-----+ : + # : |/////| : + # -----:---+-----+---:----- + # l min max r + return np.array([]), np.array([]) + elif tl < max_xs < tr: + # case 2: t < tl, right side is truncated + # +---:-----+ : + # | ://///| : + # -----+---:-----+---:----- + # min l max r + inds = sub_xs > tl + trunc_xvals.append(np.append(tl, sub_xs[inds]) - (tl - min_xs)) + trunc_yvals.append(np.append(np.interp(tl, sub_xs, sub_ys), sub_ys[inds])) + elif tl < min_xs < tr: + # case 3: tr > t, left side is truncated + # : +-----:---+ + # : |/////: | + # -----:---+-----:---+----- + # l min r max + inds = sub_xs < tr + trunc_xvals.append(np.append(sub_xs[inds], tr)) + trunc_yvals.append(np.append(sub_ys[inds], np.interp(tr, sub_xs, sub_ys))) + elif tl > min_xs and tr < max_xs: + # case 4: tr > t > tl, middle part is truncated + # +---:-----:---+ + # | ://///: | + # -----+---:-----:---+----- + # min l r max + inds0 = sub_xs < tl + trunc_xvals.append(np.append(sub_xs[inds0], tl)) + trunc_yvals.append(np.append(sub_ys[inds0], np.interp(tl, sub_xs, sub_ys))) + inds1 = sub_xs > tr + trunc_xvals.append(np.append(tr, sub_xs[inds1]) - (tr - tl)) + trunc_yvals.append(np.append(np.interp(tr, sub_xs, sub_ys), sub_ys[inds1])) + elif tr < min_xs: + # case 5: tr > t > tl, nothing truncated but need time shift + # : : +---+ + # : : | | + # -----:---:-----+---+----- + # l r 0 1 + trunc_xvals.append(sub_xs - (tr - tl)) + trunc_yvals.append(sub_ys) + else: + # no need to truncate + trunc_xvals.append(sub_xs) + trunc_yvals.append(sub_ys) + offset_accumulate += tr - tl + + new_x = np.concatenate(trunc_xvals) + new_y = np.concatenate(trunc_yvals) + + return np.asarray(new_x, dtype=float), np.asarray(new_y, dtype=float) + + def _bind_coordinate(self, vals: Sequence[types.Coordinate] | np.ndarray) -> np.ndarray: + """A helper function to bind actual coordinates to an `AbstractCoordinate`. + + Args: + vals: Sequence of coordinate objects associated with a drawing. + + Returns: + Numpy data array with substituted values. + """ + + def substitute(val: types.Coordinate): + if val == types.AbstractCoordinate.LEFT: + return self.parent.time_range[0] + if val == types.AbstractCoordinate.RIGHT: + return self.parent.time_range[1] + if val == types.AbstractCoordinate.TOP: + return self.vmax + if val == types.AbstractCoordinate.BOTTOM: + return self.vmin + raise VisualizationError(f"Coordinate {val} is not supported.") + + try: + return np.asarray(vals, dtype=float) + except (TypeError, ValueError): + return np.asarray(list(map(substitute, vals)), dtype=float) + + def _check_visible(self, data: drawings.ElementaryData) -> bool: + """A helper function to check if the data is visible. + + Args: + data: Drawing object to test. + + Returns: + Return `True` if the data is visible. + """ + is_active_type = data.data_type not in self.parent.disable_types + is_active_chan = any(chan not in self.parent.disable_chans for chan in data.channels) + if not (is_active_type and is_active_chan): + return False + + return True + + @classmethod + def _increment_cls_index(cls): + """Increment counter of the chart.""" + cls.chart_index += 1 + + @classmethod + def _cls_index(cls) -> int: + """Return counter index of the chart.""" + return cls.chart_index diff --git a/qiskit_dynamics/pulse/visualization/device_info.py b/qiskit_dynamics/pulse/visualization/device_info.py new file mode 100644 index 000000000..7898f9787 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/device_info.py @@ -0,0 +1,173 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""A collection of backend information formatted to generate drawing data. + +This instance will be provided to generator functions. The module provides an abstract +class :py:class:``DrawerBackendInfo`` with necessary methods to generate drawing objects. + +Because the data structure of backend class may depend on providers, this abstract class +has an abstract factory method `create_from_backend`. Each subclass should provide +the factory method which conforms to the associated provider. By default we provide +:py:class:``OpenPulseBackendInfo`` class that has the factory method taking backends +satisfying OpenPulse specification [1]. + +This class can be also initialized without the factory method by manually specifying +required information. This may be convenient for visualizing a pulse program for simulator +backend that only has a device Hamiltonian information. This requires two mapping objects +for channel/qubit and channel/frequency along with the system cycle time. + +If those information are not provided, this class will be initialized with a set of +empty data and the drawer illustrates a pulse program without any specific information. + +Reference: +- [1] Qiskit Backend Specifications for OpenQASM and OpenPulse Experiments, + https://arxiv.org/abs/1809.03452 +""" + +from abc import ABC, abstractmethod +from collections import defaultdict +from typing import Dict, List, Union, Optional + +from qiskit import pulse +from qiskit.providers import BackendConfigurationError +from qiskit.providers.backend import Backend, BackendV2 + + +class DrawerBackendInfo(ABC): + """Backend information to be used for the drawing data generation.""" + + def __init__( + self, + name: Optional[str] = None, + dt: Optional[float] = None, + channel_frequency_map: Optional[Dict[pulse.channels.Channel, float]] = None, + qubit_channel_map: Optional[Dict[int, List[pulse.channels.Channel]]] = None, + ): + """Create new backend information. + + Args: + name: Name of the backend. + dt: System cycle time. + channel_frequency_map: Mapping of channel and associated frequency. + qubit_channel_map: Mapping of qubit and associated channels. + """ + self.backend_name = name or "no-backend" + self._dt = dt + self._chan_freq_map = channel_frequency_map or {} + self._qubit_channel_map = qubit_channel_map or {} + + @classmethod + @abstractmethod + def create_from_backend(cls, backend: Backend): + """Initialize a class with backend information provided by provider. + + Args: + backend: Backend object. + """ + raise NotImplementedError + + @property + def dt(self): + """Return cycle time.""" + return self._dt + + def get_qubit_index(self, chan: pulse.channels.Channel) -> Union[int, None]: + """Get associated qubit index of given channel object.""" + for qind, chans in self._qubit_channel_map.items(): + if chan in chans: + return qind + return chan.index + + def get_channel_frequency(self, chan: pulse.channels.Channel) -> Union[float, None]: + """Get frequency of given channel object.""" + return self._chan_freq_map.get(chan, None) + + +class OpenPulseBackendInfo(DrawerBackendInfo): + """Drawing information of backend that conforms to OpenPulse specification.""" + + @classmethod + def create_from_backend(cls, backend: Backend): + """Initialize a class with backend information provided by provider. + + Args: + backend: Backend object. + + Returns: + OpenPulseBackendInfo: New configured instance. + """ + chan_freqs = {} + qubit_channel_map = defaultdict(list) + + if hasattr(backend, "configuration") and hasattr(backend, "defaults"): + configuration = backend.configuration() + defaults = backend.defaults() + + name = configuration.backend_name + dt = configuration.dt + + # load frequencies + chan_freqs.update( + { + pulse.DriveChannel(qind): freq + for qind, freq in enumerate(defaults.qubit_freq_est) + } + ) + chan_freqs.update( + { + pulse.MeasureChannel(qind): freq + for qind, freq in enumerate(defaults.meas_freq_est) + } + ) + for qind, u_lo_mappers in enumerate(configuration.u_channel_lo): + temp_val = 0.0 + 0.0j + for u_lo_mapper in u_lo_mappers: + temp_val += defaults.qubit_freq_est[u_lo_mapper.q] * u_lo_mapper.scale + chan_freqs[pulse.ControlChannel(qind)] = temp_val.real + + # load qubit channel mapping + for qind in range(configuration.n_qubits): + qubit_channel_map[qind].append(configuration.drive(qubit=qind)) + qubit_channel_map[qind].append(configuration.measure(qubit=qind)) + for tind in range(configuration.n_qubits): + try: + qubit_channel_map[qind].extend(configuration.control(qubits=(qind, tind))) + except BackendConfigurationError: + pass + elif isinstance(backend, BackendV2): + # Pure V2 model doesn't contain channel frequency information. + name = backend.name + dt = backend.dt + + # load qubit channel mapping + for qind in range(backend.num_qubits): + # channels are NotImplemented by default so we must catch arbitrary error. + try: + qubit_channel_map[qind].append(backend.drive_channel(qind)) + except Exception: # pylint: disable=broad-except + pass + try: + qubit_channel_map[qind].append(backend.measure_channel(qind)) + except Exception: # pylint: disable=broad-except + pass + for tind in range(backend.num_qubits): + try: + qubit_channel_map[qind].extend(backend.control_channel(qubits=(qind, tind))) + except Exception: # pylint: disable=broad-except + pass + else: + raise RuntimeError("Backend object not yet supported") + + return OpenPulseBackendInfo( + name=name, dt=dt, channel_frequency_map=chan_freqs, qubit_channel_map=qubit_channel_map + ) diff --git a/qiskit_dynamics/pulse/visualization/drawings.py b/qiskit_dynamics/pulse/visualization/drawings.py new file mode 100644 index 000000000..e5a61512a --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/drawings.py @@ -0,0 +1,253 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +""" +Drawing objects for pulse drawer. + +Drawing objects play two important roles: + - Allowing unittests of visualization module. Usually it is hard for image files to be tested. + - Removing program parser from each plotter interface. We can easily add new plotter. + +This module is based on the structure of matplotlib as it is the primary plotter +of the pulse drawer. However this interface is agnostic to the actual plotter. + +Design concept +~~~~~~~~~~~~~~ +When we think about dynamically updating drawings, it will be most efficient to +update only the changed properties of drawings rather than regenerating entirely from scratch. +Thus the core :py:class:`qiskit.visualization.pulse_v2.core.DrawerCanvas` generates +all possible drawings in the beginning and then the canvas instance manages +visibility of each drawing according to the end-user request. + +Data key +~~~~~~~~ +In the abstract class ``ElementaryData`` common attributes to represent a drawing are +specified. In addition, drawings have the `data_key` property that returns an +unique hash of the object for comparison. +This key is generated from a data type and the location of the drawing in the canvas. +See py:mod:`qiskit.visualization.pulse_v2.types` for detail on the data type. +If a data key cannot distinguish two independent objects, you need to add a new data type. +The data key may be used in the plotter interface to identify the object. + +Drawing objects +~~~~~~~~~~~~~~~ +To support not only `matplotlib` but also multiple plotters, those drawings should be +universal and designed without strong dependency on modules in `matplotlib`. +This means drawings that represent primitive geometries are preferred. +It should be noted that there will be no unittest for each plotter API, which takes +drawings and outputs image data, we should avoid adding a complicated geometry +that has a context of the pulse program. + +For example, a pulse envelope is complex valued number array and may be represented +by two lines with different colors associated with the real and the imaginary component. +We can use two line-type objects rather than defining a new drawing that takes +complex value. As many plotters don't support an API that visualizes complex-valued +data arrays, if we introduced such a drawing and wrote a custom wrapper function +on top of the existing API, it could be difficult to prevent bugs with the CI tools +due to lack of the effective unittest. +""" +from __future__ import annotations + +from abc import ABC +from enum import Enum +from typing import Any + +import numpy as np + +from qiskit.pulse.channels import Channel +from qiskit.visualization.pulse_v2 import types +from qiskit.visualization.exceptions import VisualizationError + + +class ElementaryData(ABC): + """Base class of the pulse visualization interface.""" + + __hash__ = None + + def __init__( + self, + data_type: str | Enum, + xvals: np.ndarray, + yvals: np.ndarray, + channels: Channel | list[Channel] | None = None, + meta: dict[str, Any] | None = None, + ignore_scaling: bool = False, + styles: dict[str, Any] | None = None, + ): + """Create new drawing. + + Args: + data_type: String representation of this drawing. + xvals: Series of horizontal coordinate that the object is drawn. + yvals: Series of vertical coordinate that the object is drawn. + channels: Pulse channel object bound to this drawing. + meta: Meta data dictionary of the object. + ignore_scaling: Set ``True`` to disable scaling. + styles: Style keyword args of the object. This conforms to `matplotlib`. + """ + if channels and isinstance(channels, Channel): + channels = [channels] + + if isinstance(data_type, Enum): + data_type = data_type.value + + self.data_type = str(data_type) + self.xvals = np.array(xvals, dtype=object) + self.yvals = np.array(yvals, dtype=object) + self.channels: list[Channel] = channels or [] + self.meta = meta or {} + self.ignore_scaling = ignore_scaling + self.styles = styles or {} + + @property + def data_key(self): + """Return unique hash of this object.""" + return str( + hash((self.__class__.__name__, self.data_type, tuple(self.xvals), tuple(self.yvals))) + ) + + def __repr__(self): + return f"{self.__class__.__name__}(type={self.data_type}, key={self.data_key})" + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.data_key == other.data_key + + +class LineData(ElementaryData): + """Drawing object to represent object appears as a line. + + This is the counterpart of `matplotlib.pyplot.plot`. + """ + + def __init__( + self, + data_type: str | Enum, + xvals: np.ndarray | list[types.Coordinate], + yvals: np.ndarray | list[types.Coordinate], + fill: bool = False, + channels: Channel | list[Channel] | None = None, + meta: dict[str, Any] | None = None, + ignore_scaling: bool = False, + styles: dict[str, Any] | None = None, + ): + """Create new drawing. + + Args: + data_type: String representation of this drawing. + channels: Pulse channel object bound to this drawing. + xvals: Series of horizontal coordinate that the object is drawn. + yvals: Series of vertical coordinate that the object is drawn. + fill: Set ``True`` to fill the area under curve. + meta: Meta data dictionary of the object. + ignore_scaling: Set ``True`` to disable scaling. + styles: Style keyword args of the object. This conforms to `matplotlib`. + """ + self.fill = fill + + super().__init__( + data_type=data_type, + xvals=xvals, + yvals=yvals, + channels=channels, + meta=meta, + ignore_scaling=ignore_scaling, + styles=styles, + ) + + +class TextData(ElementaryData): + """Drawing object to represent object appears as a text. + + This is the counterpart of `matplotlib.pyplot.text`. + """ + + def __init__( + self, + data_type: str | Enum, + xvals: np.ndarray | list[types.Coordinate], + yvals: np.ndarray | list[types.Coordinate], + text: str, + latex: str | None = None, + channels: Channel | list[Channel] | None = None, + meta: dict[str, Any] | None = None, + ignore_scaling: bool = False, + styles: dict[str, Any] | None = None, + ): + """Create new drawing. + + Args: + data_type: String representation of this drawing. + channels: Pulse channel object bound to this drawing. + xvals: Series of horizontal coordinate that the object is drawn. + yvals: Series of vertical coordinate that the object is drawn. + text: String to show in the canvas. + latex: Latex representation of the text (if backend supports latex drawing). + meta: Meta data dictionary of the object. + ignore_scaling: Set ``True`` to disable scaling. + styles: Style keyword args of the object. This conforms to `matplotlib`. + """ + self.text = text + self.latex = latex or "" + + super().__init__( + data_type=data_type, + xvals=xvals, + yvals=yvals, + channels=channels, + meta=meta, + ignore_scaling=ignore_scaling, + styles=styles, + ) + + +class BoxData(ElementaryData): + """Drawing object that represents box shape. + + This is the counterpart of `matplotlib.patches.Rectangle`. + """ + + def __init__( + self, + data_type: str | Enum, + xvals: np.ndarray | list[types.Coordinate], + yvals: np.ndarray | list[types.Coordinate], + channels: Channel | list[Channel] | None = None, + meta: dict[str, Any] | None = None, + ignore_scaling: bool = False, + styles: dict[str, Any] | None = None, + ): + """Create new box. + + Args: + data_type: String representation of this drawing. + xvals: Left and right coordinate that the object is drawn. + yvals: Top and bottom coordinate that the object is drawn. + channels: Pulse channel object bound to this drawing. + meta: Meta data dictionary of the object. + ignore_scaling: Set ``True`` to disable scaling. + styles: Style keyword args of the object. This conforms to `matplotlib`. + + Raises: + VisualizationError: When number of data points are not equals to 2. + """ + if len(xvals) != 2 or len(yvals) != 2: + raise VisualizationError("Length of data points are not equals to 2.") + + super().__init__( + data_type=data_type, + xvals=xvals, + yvals=yvals, + channels=channels, + meta=meta, + ignore_scaling=ignore_scaling, + styles=styles, + ) diff --git a/qiskit_dynamics/pulse/visualization/events.py b/qiskit_dynamics/pulse/visualization/events.py new file mode 100644 index 000000000..74da24b1d --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/events.py @@ -0,0 +1,254 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +r""" +Channel event manager for pulse schedules. + +This module provides a `ChannelEvents` class that manages a series of instructions for a +pulse channel. Channel-wise filtering of the pulse program makes +the arrangement of channels easier in the core drawer function. +The `ChannelEvents` class is expected to be called by other programs (not by end-users). + +The `ChannelEvents` class instance is created with the class method ``load_program``: + +.. code-block:: python + + event = ChannelEvents.load_program(sched, DriveChannel(0)) + +The `ChannelEvents` is created for a specific pulse channel and loosely assorts pulse +instructions within the channel with different visualization purposes. + +Phase and frequency related instructions are loosely grouped as frame changes. +The instantaneous value of those operands are combined and provided as ``PhaseFreqTuple``. +Instructions that have finite duration are grouped as waveforms. + +The grouped instructions are returned as an iterator by the corresponding method call: + +.. code-block:: python + + for t0, frame, instruction in event.get_waveforms(): + ... + + for t0, frame_change, instructions in event.get_frame_changes(): + ... + +The class method ``get_waveforms`` returns the iterator of waveform type instructions with +the ``PhaseFreqTuple`` (frame) at the time when instruction is issued. +This is because a pulse envelope of ``Waveform`` may be modulated with a +phase factor $exp(-i \omega t - \phi)$ with frequency $\omega$ and phase $\phi$ and +appear on the canvas. Thus, it is better to tell users in which phase and frequency +the pulse envelope is modulated from a viewpoint of program debugging. + +On the other hand, the class method ``get_frame_changes`` returns a ``PhaseFreqTuple`` that +represents a total amount of change at that time because it is convenient to know +the operand value itself when we debug a program. + +Because frame change type instructions are usually zero duration, multiple instructions +can be issued at the same time and those operand values should be appropriately +combined. In Qiskit Pulse we have set and shift type instructions for the frame control, +the set type instruction will be converted into the relevant shift amount for visualization. +Note that these instructions are not interchangeable and the order should be kept. +For example: + +.. code-block:: python + + sched1 = Schedule() + sched1 = sched1.insert(0, ShiftPhase(-1.57, DriveChannel(0)) + sched1 = sched1.insert(0, SetPhase(3.14, DriveChannel(0)) + + sched2 = Schedule() + sched2 = sched2.insert(0, SetPhase(3.14, DriveChannel(0)) + sched2 = sched2.insert(0, ShiftPhase(-1.57, DriveChannel(0)) + +In this example, ``sched1`` and ``sched2`` will have different frames. +On the drawer canvas, the total frame change amount of +3.14 should be shown for ``sched1``, +while ``sched2`` is +1.57. Since the `SetPhase` and the `ShiftPhase` instruction behave +differently, we cannot simply sum up the operand values in visualization output. + +It should be also noted that zero duration instructions issued at the same time will be +overlapped on the canvas. Thus it is convenient to plot a total frame change amount rather +than plotting each operand value bound to the instruction. +""" +from __future__ import annotations +from collections import defaultdict +from collections.abc import Iterator + +from qiskit import pulse, circuit +from qiskit.visualization.pulse_v2.types import PhaseFreqTuple, PulseInstruction + + +class ChannelEvents: + """Channel event manager.""" + + _waveform_group = ( + pulse.instructions.Play, + pulse.instructions.Delay, + pulse.instructions.Acquire, + ) + _frame_group = ( + pulse.instructions.SetFrequency, + pulse.instructions.ShiftFrequency, + pulse.instructions.SetPhase, + pulse.instructions.ShiftPhase, + ) + + def __init__( + self, + waveforms: dict[int, pulse.Instruction], + frames: dict[int, list[pulse.Instruction]], + channel: pulse.channels.Channel, + ): + """Create new event manager. + + Args: + waveforms: List of waveforms shown in this channel. + frames: List of frame change type instructions shown in this channel. + channel: Channel object associated with this manager. + """ + self._waveforms = waveforms + self._frames = frames + self.channel = channel + + # initial frame + self._init_phase = 0.0 + self._init_frequency = 0.0 + + # time resolution + self._dt = 0.0 + + @classmethod + def load_program(cls, program: pulse.Schedule, channel: pulse.channels.Channel): + """Load a pulse program represented by ``Schedule``. + + Args: + program: Target ``Schedule`` to visualize. + channel: The channel managed by this instance. + + Returns: + ChannelEvents: The channel event manager for the specified channel. + """ + waveforms = {} + frames = defaultdict(list) + + # parse instructions + for t0, inst in program.filter(channels=[channel]).instructions: + if isinstance(inst, cls._waveform_group): + if inst.duration == 0: + # special case, duration of delay can be zero + continue + waveforms[t0] = inst + elif isinstance(inst, cls._frame_group): + frames[t0].append(inst) + + return ChannelEvents(waveforms, frames, channel) + + def set_config(self, dt: float, init_frequency: float, init_phase: float): + """Setup system status. + + Args: + dt: Time resolution in sec. + init_frequency: Modulation frequency in Hz. + init_phase: Initial phase in rad. + """ + self._dt = dt or 1.0 + self._init_frequency = init_frequency or 0.0 + self._init_phase = init_phase or 0.0 + + def get_waveforms(self) -> Iterator[PulseInstruction]: + """Return waveform type instructions with frame.""" + sorted_frame_changes = sorted(self._frames.items(), key=lambda x: x[0], reverse=True) + sorted_waveforms = sorted(self._waveforms.items(), key=lambda x: x[0]) + + # bind phase and frequency with instruction + phase = self._init_phase + frequency = self._init_frequency + for t0, inst in sorted_waveforms: + is_opaque = False + + while len(sorted_frame_changes) > 0 and sorted_frame_changes[-1][0] <= t0: + _, frame_changes = sorted_frame_changes.pop() + phase, frequency = ChannelEvents._calculate_current_frame( + frame_changes=frame_changes, phase=phase, frequency=frequency + ) + + # Convert parameter expression into float + if isinstance(phase, circuit.ParameterExpression): + phase = float(phase.bind({param: 0 for param in phase.parameters})) + if isinstance(frequency, circuit.ParameterExpression): + frequency = float(frequency.bind({param: 0 for param in frequency.parameters})) + + frame = PhaseFreqTuple(phase, frequency) + + # Check if pulse has unbound parameters + if isinstance(inst, pulse.Play): + is_opaque = inst.pulse.is_parameterized() + + yield PulseInstruction(t0, self._dt, frame, inst, is_opaque) + + def get_frame_changes(self) -> Iterator[PulseInstruction]: + """Return frame change type instructions with total frame change amount.""" + # TODO parse parametrized FCs correctly + + sorted_frame_changes = sorted(self._frames.items(), key=lambda x: x[0]) + + phase = self._init_phase + frequency = self._init_frequency + for t0, frame_changes in sorted_frame_changes: + is_opaque = False + + pre_phase = phase + pre_frequency = frequency + phase, frequency = ChannelEvents._calculate_current_frame( + frame_changes=frame_changes, phase=phase, frequency=frequency + ) + + # keep parameter expression to check either phase or frequency is parameterized + frame = PhaseFreqTuple(phase - pre_phase, frequency - pre_frequency) + + # remove parameter expressions to find if next frame is parameterized + if isinstance(phase, circuit.ParameterExpression): + phase = float(phase.bind({param: 0 for param in phase.parameters})) + is_opaque = True + if isinstance(frequency, circuit.ParameterExpression): + frequency = float(frequency.bind({param: 0 for param in frequency.parameters})) + is_opaque = True + + yield PulseInstruction(t0, self._dt, frame, frame_changes, is_opaque) + + @classmethod + def _calculate_current_frame( + cls, frame_changes: list[pulse.instructions.Instruction], phase: float, frequency: float + ) -> tuple[float, float]: + """Calculate the current frame from the previous frame. + + If parameter is unbound phase or frequency accumulation with this instruction is skipped. + + Args: + frame_changes: List of frame change instructions at a specific time. + phase: Phase of previous frame. + frequency: Frequency of previous frame. + + Returns: + Phase and frequency of new frame. + """ + + for frame_change in frame_changes: + if isinstance(frame_change, pulse.instructions.SetFrequency): + frequency = frame_change.frequency + elif isinstance(frame_change, pulse.instructions.ShiftFrequency): + frequency += frame_change.frequency + elif isinstance(frame_change, pulse.instructions.SetPhase): + phase = frame_change.phase + elif isinstance(frame_change, pulse.instructions.ShiftPhase): + phase += frame_change.phase + + return phase, frequency diff --git a/qiskit_dynamics/pulse/visualization/generators/__init__.py b/qiskit_dynamics/pulse/visualization/generators/__init__.py new file mode 100644 index 000000000..fd671857e --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/generators/__init__.py @@ -0,0 +1,40 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +""" +Customizable object generators for pulse drawer. +""" + +from qiskit.visualization.pulse_v2.generators.barrier import gen_barrier + +from qiskit.visualization.pulse_v2.generators.chart import ( + gen_baseline, + gen_channel_freqs, + gen_chart_name, + gen_chart_scale, +) + +from qiskit.visualization.pulse_v2.generators.frame import ( + gen_formatted_frame_values, + gen_formatted_freq_mhz, + gen_formatted_phase, + gen_frame_symbol, + gen_raw_operand_values_compact, +) + +from qiskit.visualization.pulse_v2.generators.snapshot import gen_snapshot_name, gen_snapshot_symbol + +from qiskit.visualization.pulse_v2.generators.waveform import ( + gen_filled_waveform_stepwise, + gen_ibmq_latex_waveform_name, + gen_waveform_max_value, +) diff --git a/qiskit_dynamics/pulse/visualization/generators/barrier.py b/qiskit_dynamics/pulse/visualization/generators/barrier.py new file mode 100644 index 000000000..85f8271cf --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/generators/barrier.py @@ -0,0 +1,76 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=unused-argument + +"""Barrier generators. + +A collection of functions that generate drawings from formatted input data. +See py:mod:`qiskit.visualization.pulse_v2.types` for more info on the required data. + +In this module the input data is `types.BarrierInstruction`. + +An end-user can write arbitrary functions that generate custom drawings. +Generators in this module are called with the `formatter` and `device` kwargs. +These data provides stylesheet configuration and backend system configuration. + +The format of generator is restricted to: + + ```python + + def my_object_generator(data: BarrierInstruction, + formatter: Dict[str, Any], + device: DrawerBackendInfo) -> List[ElementaryData]: + pass + ``` + +Arbitrary generator function satisfying the above format can be accepted. +Returned `ElementaryData` can be arbitrary subclasses that are implemented in +the plotter API. +""" +from typing import Dict, Any, List + +from qiskit.visualization.pulse_v2 import drawings, types, device_info + + +def gen_barrier( + data: types.BarrierInstruction, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.LineData]: + """Generate the barrier from provided relative barrier instruction. + + Stylesheets: + - The `barrier` style is applied. + + Args: + data: Barrier instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + Returns: + List of `LineData` drawings. + """ + style = { + "alpha": formatter["alpha.barrier"], + "zorder": formatter["layer.barrier"], + "linewidth": formatter["line_width.barrier"], + "linestyle": formatter["line_style.barrier"], + "color": formatter["color.barrier"], + } + + line = drawings.LineData( + data_type=types.LineType.BARRIER, + channels=data.channels, + xvals=[data.t0, data.t0], + yvals=[types.AbstractCoordinate.BOTTOM, types.AbstractCoordinate.TOP], + styles=style, + ) + + return [line] diff --git a/qiskit_dynamics/pulse/visualization/generators/chart.py b/qiskit_dynamics/pulse/visualization/generators/chart.py new file mode 100644 index 000000000..26a92fe36 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/generators/chart.py @@ -0,0 +1,208 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=unused-argument + +"""Chart axis generators. + +A collection of functions that generate drawings from formatted input data. +See py:mod:`qiskit.visualization.pulse_v2.types` for more info on the required data. + +In this module the input data is `types.ChartAxis`. + +An end-user can write arbitrary functions that generate custom drawings. +Generators in this module are called with the `formatter` and `device` kwargs. +These data provides stylesheet configuration and backend system configuration. + +The format of generator is restricted to: + + ```python + + def my_object_generator(data: ChartAxis, + formatter: Dict[str, Any], + device: DrawerBackendInfo) -> List[ElementaryData]: + pass + ``` + +Arbitrary generator function satisfying the above format can be accepted. +Returned `ElementaryData` can be arbitrary subclasses that are implemented in +the plotter API. +""" +from typing import Dict, Any, List + +from qiskit.visualization.pulse_v2 import drawings, types, device_info + + +def gen_baseline( + data: types.ChartAxis, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.LineData]: + """Generate the baseline associated with the chart. + + Stylesheets: + - The `baseline` style is applied. + + Args: + data: Chart axis data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `LineData` drawings. + """ + style = { + "alpha": formatter["alpha.baseline"], + "zorder": formatter["layer.baseline"], + "linewidth": formatter["line_width.baseline"], + "linestyle": formatter["line_style.baseline"], + "color": formatter["color.baseline"], + } + + baseline = drawings.LineData( + data_type=types.LineType.BASELINE, + channels=data.channels, + xvals=[types.AbstractCoordinate.LEFT, types.AbstractCoordinate.RIGHT], + yvals=[0, 0], + ignore_scaling=True, + styles=style, + ) + + return [baseline] + + +def gen_chart_name( + data: types.ChartAxis, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the name of chart. + + Stylesheets: + - The `axis_label` style is applied. + + Args: + data: Chart axis data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + style = { + "zorder": formatter["layer.axis_label"], + "color": formatter["color.axis_label"], + "size": formatter["text_size.axis_label"], + "va": "center", + "ha": "right", + } + + text = drawings.TextData( + data_type=types.LabelType.CH_NAME, + channels=data.channels, + xvals=[types.AbstractCoordinate.LEFT], + yvals=[0], + text=data.name, + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_chart_scale( + data: types.ChartAxis, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the current scaling value of the chart. + + Stylesheets: + - The `axis_label` style is applied. + - The `annotate` style is partially applied for the font size. + + Args: + data: Chart axis data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + style = { + "zorder": formatter["layer.axis_label"], + "color": formatter["color.axis_label"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "right", + } + + scale_val = f"x{types.DynamicString.SCALE}" + + text = drawings.TextData( + data_type=types.LabelType.CH_INFO, + channels=data.channels, + xvals=[types.AbstractCoordinate.LEFT], + yvals=[-formatter["label_offset.chart_info"]], + text=scale_val, + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_channel_freqs( + data: types.ChartAxis, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the frequency values of associated channels. + + Stylesheets: + - The `axis_label` style is applied. + - The `annotate` style is partially applied for the font size. + + Args: + data: Chart axis data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + style = { + "zorder": formatter["layer.axis_label"], + "color": formatter["color.axis_label"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "right", + } + + if len(data.channels) > 1: + sources = [] + for chan in data.channels: + freq = device.get_channel_frequency(chan) + if not freq: + continue + sources.append(f"{chan.name.upper()}: {freq / 1e9:.2f} GHz") + freq_text = ", ".join(sources) + else: + freq = device.get_channel_frequency(data.channels[0]) + if freq: + freq_text = f"{freq / 1e9:.2f} GHz" + else: + freq_text = "" + + text = drawings.TextData( + data_type=types.LabelType.CH_INFO, + channels=data.channels, + xvals=[types.AbstractCoordinate.LEFT], + yvals=[-formatter["label_offset.chart_info"]], + text=freq_text or "no freq.", + ignore_scaling=True, + styles=style, + ) + + return [text] diff --git a/qiskit_dynamics/pulse/visualization/generators/frame.py b/qiskit_dynamics/pulse/visualization/generators/frame.py new file mode 100644 index 000000000..8b71b8596 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/generators/frame.py @@ -0,0 +1,436 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=unused-argument + +"""Frame change generators. + +A collection of functions that generate drawings from formatted input data. +See py:mod:`qiskit.visualization.pulse_v2.types` for more info on the required data. + +In this module the input data is `types.PulseInstruction`. + +An end-user can write arbitrary functions that generate custom drawings. +Generators in this module are called with the `formatter` and `device` kwargs. +These data provides stylesheet configuration and backend system configuration. + +The format of generator is restricted to: + + ```python + + def my_object_generator(data: PulseInstruction, + formatter: Dict[str, Any], + device: DrawerBackendInfo) -> List[ElementaryData]: + pass + ``` + +Arbitrary generator function satisfying the above format can be accepted. +Returned `ElementaryData` can be arbitrary subclasses that are implemented in +the plotter API. +""" +from fractions import Fraction +from typing import Dict, Any, List, Tuple + +import numpy as np +from qiskit.pulse import instructions +from qiskit.visualization.exceptions import VisualizationError +from qiskit.visualization.pulse_v2 import drawings, types, device_info + + +def gen_formatted_phase( + data: types.PulseInstruction, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the formatted virtual Z rotation label from provided frame instruction. + + Rotation angle is expressed in units of pi. + If the denominator of fraction is larger than 10, the angle is expressed in units of radian. + + For example: + - A value -3.14 is converted into `VZ(\\pi)` + - A value 1.57 is converted into `VZ(-\\frac{\\pi}{2})` + - A value 0.123 is converted into `VZ(-0.123 rad.)` + + Stylesheets: + - The `frame_change` style is applied. + - The `annotate` style is applied for font size. + + Notes: + The phase operand of `PhaseShift` instruction has opposite sign to the Z gate definition. + Thus the sign of rotation angle is inverted. + + Args: + data: Frame change instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + _max_denom = 10 + + style = { + "zorder": formatter["layer.frame_change"], + "color": formatter["color.frame_change"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "center", + } + + plain_phase, latex_phase = _phase_to_text( + formatter=formatter, phase=data.frame.phase, max_denom=_max_denom, flip=True + ) + + text = drawings.TextData( + data_type=types.LabelType.FRAME, + channels=data.inst[0].channel, + xvals=[data.t0], + yvals=[formatter["label_offset.frame_change"]], + text=f"VZ({plain_phase})", + latex=rf"{{\rm VZ}}({latex_phase})", + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_formatted_freq_mhz( + data: types.PulseInstruction, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the formatted frequency change label from provided frame instruction. + + Frequency change is expressed in units of MHz. + + For example: + - A value 1,234,567 is converted into `\\Delta f = 1.23 MHz` + + Stylesheets: + - The `frame_change` style is applied. + - The `annotate` style is applied for font size. + + Args: + data: Frame change instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + _unit = "MHz" + + style = { + "zorder": formatter["layer.frame_change"], + "color": formatter["color.frame_change"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "center", + } + + plain_freq, latex_freq = _freq_to_text(formatter=formatter, freq=data.frame.freq, unit=_unit) + + text = drawings.TextData( + data_type=types.LabelType.FRAME, + channels=data.inst[0].channel, + xvals=[data.t0], + yvals=[formatter["label_offset.frame_change"]], + text=f"\u0394f = {plain_freq}", + latex=rf"\Delta f = {latex_freq}", + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_formatted_frame_values( + data: types.PulseInstruction, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the formatted virtual Z rotation label and the frequency change label + from provided frame instruction. + + Phase value is placed on top of the symbol, and frequency value is placed below the symbol. + See :py:func:`gen_formatted_phase` and :py:func:`gen_formatted_freq_mhz` for details. + + Stylesheets: + - The `frame_change` style is applied. + - The `annotate` style is applied for font size. + + Args: + data: Frame change instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + texts = [] + + _max_denom = 10 + _unit = "MHz" + + style = { + "zorder": formatter["layer.frame_change"], + "color": formatter["color.frame_change"], + "size": formatter["text_size.annotate"], + "ha": "center", + } + + # phase value + if data.frame.phase != 0: + plain_phase, latex_phase = _phase_to_text( + formatter=formatter, phase=data.frame.phase, max_denom=_max_denom, flip=True + ) + phase_style = {"va": "center"} + phase_style.update(style) + + phase = drawings.TextData( + data_type=types.LabelType.FRAME, + channels=data.inst[0].channel, + xvals=[data.t0], + yvals=[formatter["label_offset.frame_change"]], + text=f"VZ({plain_phase})", + latex=rf"{{\rm VZ}}({latex_phase})", + ignore_scaling=True, + styles=phase_style, + ) + texts.append(phase) + + # frequency value + if data.frame.freq != 0: + plain_freq, latex_freq = _freq_to_text( + formatter=formatter, freq=data.frame.freq, unit=_unit + ) + freq_style = {"va": "center"} + freq_style.update(style) + + freq = drawings.TextData( + data_type=types.LabelType.FRAME, + channels=data.inst[0].channel, + xvals=[data.t0], + yvals=[2 * formatter["label_offset.frame_change"]], + text=f"\u0394f = {plain_freq}", + latex=rf"\Delta f = {latex_freq}", + ignore_scaling=True, + styles=freq_style, + ) + texts.append(freq) + + return texts + + +def gen_raw_operand_values_compact( + data: types.PulseInstruction, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate the formatted virtual Z rotation label and the frequency change label + from provided frame instruction. + + Raw operand values are shown in compact form. Frequency change is expressed + in scientific notation. Values are shown in two lines. + + For example: + - A phase change 1.57 and frequency change 1,234,567 are written by `1.57\\n1.2e+06` + + Stylesheets: + - The `frame_change` style is applied. + - The `annotate` style is applied for font size. + + Args: + data: Frame change instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + + style = { + "zorder": formatter["layer.frame_change"], + "color": formatter["color.frame_change"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "center", + } + + if data.frame.freq == 0: + freq_sci_notation = "0.0" + else: + abs_freq = np.abs(data.frame.freq) + base = data.frame.freq / (10 ** int(np.floor(np.log10(abs_freq)))) + exponent = int(np.floor(np.log10(abs_freq))) + freq_sci_notation = f"{base:.1f}e{exponent:d}" + frame_info = f"{data.frame.phase:.2f}\n{freq_sci_notation}" + + text = drawings.TextData( + data_type=types.LabelType.FRAME, + channels=data.inst[0].channel, + xvals=[data.t0], + yvals=[1.2 * formatter["label_offset.frame_change"]], + text=frame_info, + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_frame_symbol( + data: types.PulseInstruction, formatter: Dict[str, Any], device: device_info.DrawerBackendInfo +) -> List[drawings.TextData]: + """Generate a frame change symbol with instruction meta data from provided frame instruction. + + Stylesheets: + - The `frame_change` style is applied. + - The symbol type in unicode is specified in `formatter.unicode_symbol.frame_change`. + - The symbol type in latex is specified in `formatter.latex_symbol.frame_change`. + + Args: + data: Frame change instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + if data.frame.phase == 0 and data.frame.freq == 0: + return [] + + style = { + "zorder": formatter["layer.frame_change"], + "color": formatter["color.frame_change"], + "size": formatter["text_size.frame_change"], + "va": "center", + "ha": "center", + } + + program = [] + for inst in data.inst: + if isinstance(inst, (instructions.SetFrequency, instructions.ShiftFrequency)): + try: + program.append(f"{inst.__class__.__name__}({inst.frequency:.2e} Hz)") + except TypeError: + # parameter expression + program.append(f"{inst.__class__.__name__}({inst.frequency})") + elif isinstance(inst, (instructions.SetPhase, instructions.ShiftPhase)): + try: + program.append(f"{inst.__class__.__name__}({inst.phase:.2f} rad.)") + except TypeError: + # parameter expression + program.append(f"{inst.__class__.__name__}({inst.phase})") + + meta = { + "total phase change": data.frame.phase, + "total frequency change": data.frame.freq, + "program": ", ".join(program), + "t0 (cycle time)": data.t0, + "t0 (sec)": data.t0 * data.dt if data.dt else "N/A", + } + + text = drawings.TextData( + data_type=types.SymbolType.FRAME, + channels=data.inst[0].channel, + xvals=[data.t0], + yvals=[0], + text=formatter["unicode_symbol.frame_change"], + latex=formatter["latex_symbol.frame_change"], + ignore_scaling=True, + meta=meta, + styles=style, + ) + + return [text] + + +def _phase_to_text( + formatter: Dict[str, Any], phase: float, max_denom: int = 10, flip: bool = True +) -> Tuple[str, str]: + """A helper function to convert a float value to text with pi. + + Args: + formatter: Dictionary of stylesheet settings. + phase: A phase value in units of rad. + max_denom: Maximum denominator. Return raw value if exceed. + flip: Set `True` to flip the sign. + + Returns: + Standard text and latex text of phase value. + """ + try: + phase = float(phase) + except TypeError: + # unbound parameter + return ( + formatter["unicode_symbol.phase_parameter"], + formatter["latex_symbol.phase_parameter"], + ) + + frac = Fraction(np.abs(phase) / np.pi) + + if phase == 0: + return "0", r"0" + + num = frac.numerator + denom = frac.denominator + if denom > max_denom: + # denominator is too large + latex = rf"{np.abs(phase):.2f}" + plain = f"{np.abs(phase):.2f}" + else: + if num == 1: + if denom == 1: + latex = r"\pi" + plain = "pi" + else: + latex = rf"\pi/{denom:d}" + plain = f"pi/{denom:d}" + else: + latex = rf"{num:d}/{denom:d} \pi" + plain = f"{num:d}/{denom:d} pi" + + if flip: + sign = "-" if phase > 0 else "" + else: + sign = "-" if phase < 0 else "" + + return sign + plain, sign + latex + + +def _freq_to_text(formatter: Dict[str, Any], freq: float, unit: str = "MHz") -> Tuple[str, str]: + """A helper function to convert a freq value to text with supplementary unit. + + Args: + formatter: Dictionary of stylesheet settings. + freq: A frequency value in units of Hz. + unit: Supplementary unit. THz, GHz, MHz, kHz, Hz are supported. + + Returns: + Standard text and latex text of phase value. + + Raises: + VisualizationError: When unsupported unit is specified. + """ + try: + freq = float(freq) + except TypeError: + # unbound parameter + return formatter["unicode_symbol.freq_parameter"], formatter["latex_symbol.freq_parameter"] + + unit_table = {"THz": 1e12, "GHz": 1e9, "MHz": 1e6, "kHz": 1e3, "Hz": 1} + + try: + value = freq / unit_table[unit] + except KeyError as ex: + raise VisualizationError(f"Unit {unit} is not supported.") from ex + + latex = rf"{value:.2f}~{{\rm {unit}}}" + plain = f"{value:.2f} {unit}" + + return plain, latex diff --git a/qiskit_dynamics/pulse/visualization/generators/snapshot.py b/qiskit_dynamics/pulse/visualization/generators/snapshot.py new file mode 100644 index 000000000..d86c89997 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/generators/snapshot.py @@ -0,0 +1,133 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=unused-argument + +"""Snapshot generators. + +A collection of functions that generate drawings from formatted input data. +See py:mod:`qiskit.visualization.pulse_v2.types` for more info on the required data. + +In this module the input data is `types.SnapshotInstruction`. + +An end-user can write arbitrary functions that generate custom drawings. +Generators in this module are called with the `formatter` and `device` kwargs. +These data provides stylesheet configuration and backend system configuration. + +The format of generator is restricted to: + + ```python + + def my_object_generator(data: SnapshotInstruction, + formatter: Dict[str, Any], + device: DrawerBackendInfo) -> List[ElementaryData]: + pass + ``` + +Arbitrary generator function satisfying the above format can be accepted. +Returned `ElementaryData` can be arbitrary subclasses that are implemented in +the plotter API. +""" +from typing import Dict, Any, List + +from qiskit.visualization.pulse_v2 import drawings, types, device_info + + +def gen_snapshot_name( + data: types.SnapshotInstruction, + formatter: Dict[str, Any], + device: device_info.DrawerBackendInfo, +) -> List[drawings.TextData]: + """Generate the name of snapshot. + + Stylesheets: + - The `snapshot` style is applied for snapshot symbol. + - The `annotate` style is applied for label font size. + + Args: + data: Snapshot instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + style = { + "zorder": formatter["layer.snapshot"], + "color": formatter["color.snapshot"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "center", + } + + text = drawings.TextData( + data_type=types.LabelType.SNAPSHOT, + channels=data.inst.channel, + xvals=[data.t0], + yvals=[formatter["label_offset.snapshot"]], + text=data.inst.name, + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_snapshot_symbol( + data: types.SnapshotInstruction, + formatter: Dict[str, Any], + device: device_info.DrawerBackendInfo, +) -> List[drawings.TextData]: + """Generate a snapshot symbol with instruction meta data from provided snapshot instruction. + + Stylesheets: + - The `snapshot` style is applied for snapshot symbol. + - The symbol type in unicode is specified in `formatter.unicode_symbol.snapshot`. + - The symbol type in latex is specified in `formatter.latex_symbol.snapshot`. + + Args: + data: Snapshot instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + style = { + "zorder": formatter["layer.snapshot"], + "color": formatter["color.snapshot"], + "size": formatter["text_size.snapshot"], + "va": "bottom", + "ha": "center", + } + + meta = { + "snapshot type": data.inst.type, + "t0 (cycle time)": data.t0, + "t0 (sec)": data.t0 * data.dt if data.dt else "N/A", + "name": data.inst.name, + "label": data.inst.label, + } + + text = drawings.TextData( + data_type=types.SymbolType.SNAPSHOT, + channels=data.inst.channel, + xvals=[data.t0], + yvals=[0], + text=formatter["unicode_symbol.snapshot"], + latex=formatter["latex_symbol.snapshot"], + ignore_scaling=True, + meta=meta, + styles=style, + ) + + return [text] diff --git a/qiskit_dynamics/pulse/visualization/generators/waveform.py b/qiskit_dynamics/pulse/visualization/generators/waveform.py new file mode 100644 index 000000000..e770f271c --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/generators/waveform.py @@ -0,0 +1,645 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=unused-argument + +"""Waveform generators. + +A collection of functions that generate drawings from formatted input data. +See py:mod:`qiskit.visualization.pulse_v2.types` for more info on the required data. + +In this module the input data is `types.PulseInstruction`. + +An end-user can write arbitrary functions that generate custom drawings. +Generators in this module are called with the `formatter` and `device` kwargs. +These data provides stylesheet configuration and backend system configuration. + +The format of generator is restricted to: + + ```python + + def my_object_generator(data: PulseInstruction, + formatter: Dict[str, Any], + device: DrawerBackendInfo) -> List[ElementaryData]: + pass + ``` + +Arbitrary generator function satisfying the above format can be accepted. +Returned `ElementaryData` can be arbitrary subclasses that are implemented in +the plotter API. +""" + +from __future__ import annotations +import re +from fractions import Fraction +from typing import Any + +import numpy as np + +from qiskit import pulse, circuit +from qiskit.pulse import instructions, library +from qiskit.visualization.exceptions import VisualizationError +from qiskit.visualization.pulse_v2 import drawings, types, device_info + + +def gen_filled_waveform_stepwise( + data: types.PulseInstruction, formatter: dict[str, Any], device: device_info.DrawerBackendInfo +) -> list[drawings.LineData | drawings.BoxData | drawings.TextData]: + """Generate filled area objects of the real and the imaginary part of waveform envelope. + + The curve of envelope is not interpolated nor smoothed and presented + as stepwise function at each data point. + + Stylesheets: + - The `fill_waveform` style is applied. + + Args: + data: Waveform instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `LineData`, `BoxData`, or `TextData` drawings. + + Raises: + VisualizationError: When the instruction parser returns invalid data format. + """ + # generate waveform data + waveform_data = _parse_waveform(data) + channel = data.inst.channel + + # update metadata + meta = waveform_data.meta + qind = device.get_qubit_index(channel) + meta.update({"qubit": qind if qind is not None else "N/A"}) + + if isinstance(waveform_data, types.ParsedInstruction): + # Draw waveform with fixed shape + + xdata = waveform_data.xvals + ydata = waveform_data.yvals + + # phase modulation + if formatter["control.apply_phase_modulation"]: + ydata = np.asarray(ydata, dtype=complex) * np.exp(1j * data.frame.phase) + else: + ydata = np.asarray(ydata, dtype=complex) + + return _draw_shaped_waveform( + xdata=xdata, ydata=ydata, meta=meta, channel=channel, formatter=formatter + ) + + elif isinstance(waveform_data, types.OpaqueShape): + # Draw parametric pulse with unbound parameters + + # parameter name + unbound_params = [] + for pname, pval in data.inst.pulse.parameters.items(): + if isinstance(pval, circuit.ParameterExpression): + unbound_params.append(pname) + + pulse_data = data.inst.pulse + if isinstance(pulse_data, library.SymbolicPulse): + pulse_shape = pulse_data.pulse_type + else: + pulse_shape = "Waveform" + + return _draw_opaque_waveform( + init_time=data.t0, + duration=waveform_data.duration, + pulse_shape=pulse_shape, + pnames=unbound_params, + meta=meta, + channel=channel, + formatter=formatter, + ) + + else: + raise VisualizationError("Invalid data format is provided.") + + +def gen_ibmq_latex_waveform_name( + data: types.PulseInstruction, formatter: dict[str, Any], device: device_info.DrawerBackendInfo +) -> list[drawings.TextData]: + r"""Generate the formatted instruction name associated with the waveform. + + Channel name and ID string are removed and the rotation angle is expressed in units of pi. + The controlled rotation angle associated with the CR pulse name is divided by 2. + + Note that in many scientific articles the controlled rotation angle implies + the actual rotation angle, but in IQX backend the rotation angle represents + the difference between rotation angles with different control qubit states. + + For example: + - 'X90p_d0_abcdefg' is converted into 'X(\frac{\pi}{2})' + - 'CR90p_u0_abcdefg` is converted into 'CR(\frac{\pi}{4})' + + Stylesheets: + - The `annotate` style is applied. + + Notes: + This generator can convert pulse names used in the IQX backends. + If pulses are provided by the third party providers or the user defined, + the generator output may be the as-is pulse name. + + Args: + data: Waveform instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + if data.is_opaque: + return [] + + style = { + "zorder": formatter["layer.annotate"], + "color": formatter["color.annotate"], + "size": formatter["text_size.annotate"], + "va": "center", + "ha": "center", + } + + if isinstance(data.inst, pulse.instructions.Acquire): + systematic_name = "Acquire" + latex_name = None + elif isinstance(data.inst, instructions.Delay): + systematic_name = data.inst.name or "Delay" + latex_name = None + else: + pulse_data = data.inst.pulse + if pulse_data.name: + systematic_name = pulse_data.name + else: + if isinstance(pulse_data, library.SymbolicPulse): + systematic_name = pulse_data.pulse_type + else: + systematic_name = "Waveform" + + template = r"(?P[A-Z]+)(?P[0-9]+)?(?P[pm])_(?P[dum])[0-9]+" + match_result = re.match(template, systematic_name) + if match_result is not None: + match_dict = match_result.groupdict() + sign = "" if match_dict["sign"] == "p" else "-" + if match_dict["op"] == "CR": + # cross resonance + if match_dict["ch"] == "u": + op_name = r"{\rm CR}" + else: + op_name = r"\overline{\rm CR}" + # IQX name def is not standard. Echo CR is annotated with pi/4 rather than pi/2 + angle_val = match_dict["angle"] + frac = Fraction(int(int(angle_val) / 2), 180) + if frac.numerator == 1: + angle = rf"\pi/{frac.denominator:d}" + else: + angle = rf"{frac.numerator:d}/{frac.denominator:d} \pi" + else: + # single qubit pulse + # pylint: disable-next=consider-using-f-string + op_name = r"{{\rm {}}}".format(match_dict["op"]) + angle_val = match_dict["angle"] + if angle_val is None: + angle = r"\pi" + else: + frac = Fraction(int(angle_val), 180) + if frac.numerator == 1: + angle = rf"\pi/{frac.denominator:d}" + else: + angle = rf"{frac.numerator:d}/{frac.denominator:d} \pi" + latex_name = rf"{op_name}({sign}{angle})" + else: + latex_name = None + + text = drawings.TextData( + data_type=types.LabelType.PULSE_NAME, + channels=data.inst.channel, + xvals=[data.t0 + 0.5 * data.inst.duration], + yvals=[-formatter["label_offset.pulse_name"]], + text=systematic_name, + latex=latex_name, + ignore_scaling=True, + styles=style, + ) + + return [text] + + +def gen_waveform_max_value( + data: types.PulseInstruction, formatter: dict[str, Any], device: device_info.DrawerBackendInfo +) -> list[drawings.TextData]: + """Generate the annotation for the maximum waveform height for + the real and the imaginary part of the waveform envelope. + + Maximum values smaller than the vertical resolution limit is ignored. + + Stylesheets: + - The `annotate` style is applied. + + Args: + data: Waveform instruction data to draw. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Returns: + List of `TextData` drawings. + """ + if data.is_opaque: + return [] + + style = { + "zorder": formatter["layer.annotate"], + "color": formatter["color.annotate"], + "size": formatter["text_size.annotate"], + "ha": "center", + } + + # only pulses. + if isinstance(data.inst, instructions.Play): + # pulse + operand = data.inst.pulse + if isinstance(operand, pulse.SymbolicPulse): + pulse_data = operand.get_waveform() + else: + pulse_data = operand + xdata = np.arange(pulse_data.duration) + data.t0 + ydata = pulse_data.samples + else: + return [] + + # phase modulation + if formatter["control.apply_phase_modulation"]: + ydata = np.asarray(ydata, dtype=complex) * np.exp(1j * data.frame.phase) + else: + ydata = np.asarray(ydata, dtype=complex) + + texts = [] + + # max of real part + re_maxind = np.argmax(np.abs(ydata.real)) + if np.abs(ydata.real[re_maxind]) > 0.01: + # generator shows only 2 digits after the decimal point. + if ydata.real[re_maxind] > 0: + max_val = f"{ydata.real[re_maxind]:.2f}\n\u25BE" + re_style = {"va": "bottom"} + else: + max_val = f"\u25B4\n{ydata.real[re_maxind]:.2f}" + re_style = {"va": "top"} + re_style.update(style) + re_text = drawings.TextData( + data_type=types.LabelType.PULSE_INFO, + channels=data.inst.channel, + xvals=[xdata[re_maxind]], + yvals=[ydata.real[re_maxind]], + text=max_val, + styles=re_style, + ) + texts.append(re_text) + + # max of imag part + im_maxind = np.argmax(np.abs(ydata.imag)) + if np.abs(ydata.imag[im_maxind]) > 0.01: + # generator shows only 2 digits after the decimal point. + if ydata.imag[im_maxind] > 0: + max_val = f"{ydata.imag[im_maxind]:.2f}\n\u25BE" + im_style = {"va": "bottom"} + else: + max_val = f"\u25B4\n{ydata.imag[im_maxind]:.2f}" + im_style = {"va": "top"} + im_style.update(style) + im_text = drawings.TextData( + data_type=types.LabelType.PULSE_INFO, + channels=data.inst.channel, + xvals=[xdata[im_maxind]], + yvals=[ydata.imag[im_maxind]], + text=max_val, + styles=im_style, + ) + texts.append(im_text) + + return texts + + +def _draw_shaped_waveform( + xdata: np.ndarray, + ydata: np.ndarray, + meta: dict[str, Any], + channel: pulse.channels.PulseChannel, + formatter: dict[str, Any], +) -> list[drawings.LineData | drawings.BoxData | drawings.TextData]: + """A private function that generates drawings of stepwise pulse lines. + + Args: + xdata: Array of horizontal coordinate of waveform envelope. + ydata: Array of vertical coordinate of waveform envelope. + meta: Metadata dictionary of the waveform. + channel: Channel associated with the waveform to draw. + formatter: Dictionary of stylesheet settings. + + Returns: + List of drawings. + + Raises: + VisualizationError: When the waveform color for channel is not defined. + """ + fill_objs: list[drawings.LineData | drawings.BoxData | drawings.TextData] = [] + + resolution = formatter["general.vertical_resolution"] + + # stepwise interpolation + xdata: np.ndarray = np.concatenate((xdata, [xdata[-1] + 1])) + ydata = np.repeat(ydata, 2) + re_y = np.real(ydata) + im_y = np.imag(ydata) + time: np.ndarray = np.concatenate(([xdata[0]], np.repeat(xdata[1:-1], 2), [xdata[-1]])) + + # setup style options + style = { + "alpha": formatter["alpha.fill_waveform"], + "zorder": formatter["layer.fill_waveform"], + "linewidth": formatter["line_width.fill_waveform"], + "linestyle": formatter["line_style.fill_waveform"], + } + + try: + color_real, color_imag = formatter["color.waveforms"][channel.prefix.upper()] + except KeyError as ex: + raise VisualizationError( + f"Waveform color for channel type {channel.prefix} is not defined" + ) from ex + + # create real part + if np.any(re_y): + # data compression + re_valid_inds = _find_consecutive_index(re_y, resolution) + # stylesheet + re_style = {"color": color_real} + re_style.update(style) + # metadata + re_meta = {"data": "real"} + re_meta.update(meta) + # active xy data + re_xvals = time[re_valid_inds] + re_yvals = re_y[re_valid_inds] + + # object + real = drawings.LineData( + data_type=types.WaveformType.REAL, + channels=channel, + xvals=re_xvals, + yvals=re_yvals, + fill=formatter["control.fill_waveform"], + meta=re_meta, + styles=re_style, + ) + fill_objs.append(real) + + # create imaginary part + if np.any(im_y): + # data compression + im_valid_inds = _find_consecutive_index(im_y, resolution) + # stylesheet + im_style = {"color": color_imag} + im_style.update(style) + # metadata + im_meta = {"data": "imag"} + im_meta.update(meta) + # active xy data + im_xvals = time[im_valid_inds] + im_yvals = im_y[im_valid_inds] + + # object + imag = drawings.LineData( + data_type=types.WaveformType.IMAG, + channels=channel, + xvals=im_xvals, + yvals=im_yvals, + fill=formatter["control.fill_waveform"], + meta=im_meta, + styles=im_style, + ) + fill_objs.append(imag) + + return fill_objs + + +def _draw_opaque_waveform( + init_time: int, + duration: int, + pulse_shape: str, + pnames: list[str], + meta: dict[str, Any], + channel: pulse.channels.PulseChannel, + formatter: dict[str, Any], +) -> list[drawings.LineData | drawings.BoxData | drawings.TextData]: + """A private function that generates drawings of stepwise pulse lines. + + Args: + init_time: Time when the opaque waveform starts. + duration: Duration of opaque waveform. This can be None or ParameterExpression. + pulse_shape: String that represents pulse shape. + pnames: List of parameter names. + meta: Metadata dictionary of the waveform. + channel: Channel associated with the waveform to draw. + formatter: Dictionary of stylesheet settings. + + Returns: + List of drawings. + """ + fill_objs: list[drawings.LineData | drawings.BoxData | drawings.TextData] = [] + + fc, ec = formatter["color.opaque_shape"] + # setup style options + box_style = { + "zorder": formatter["layer.fill_waveform"], + "alpha": formatter["alpha.opaque_shape"], + "linewidth": formatter["line_width.opaque_shape"], + "linestyle": formatter["line_style.opaque_shape"], + "facecolor": fc, + "edgecolor": ec, + } + + if duration is None or isinstance(duration, circuit.ParameterExpression): + duration = formatter["box_width.opaque_shape"] + + box_obj = drawings.BoxData( + data_type=types.WaveformType.OPAQUE, + channels=channel, + xvals=[init_time, init_time + duration], + yvals=[ + -0.5 * formatter["box_height.opaque_shape"], + 0.5 * formatter["box_height.opaque_shape"], + ], + meta=meta, + ignore_scaling=True, + styles=box_style, + ) + fill_objs.append(box_obj) + + # parameter name + func_repr = f"{pulse_shape}({', '.join(pnames)})" + + text_style = { + "zorder": formatter["layer.annotate"], + "color": formatter["color.annotate"], + "size": formatter["text_size.annotate"], + "va": "bottom", + "ha": "center", + } + + text_obj = drawings.TextData( + data_type=types.LabelType.OPAQUE_BOXTEXT, + channels=channel, + xvals=[init_time + 0.5 * duration], + yvals=[0.5 * formatter["box_height.opaque_shape"]], + text=func_repr, + ignore_scaling=True, + styles=text_style, + ) + + fill_objs.append(text_obj) + + return fill_objs + + +def _find_consecutive_index(data_array: np.ndarray, resolution: float) -> np.ndarray: + """A helper function to return non-consecutive index from the given list. + + This drastically reduces memory footprint to represent a drawing, + especially for samples of very long flat-topped Gaussian pulses. + Tiny value fluctuation smaller than `resolution` threshold is removed. + + Args: + data_array: The array of numbers. + resolution: Minimum resolution of sample values. + + Returns: + The compressed data array. + """ + try: + vector = np.asarray(data_array, dtype=float) + diff = np.diff(vector) + diff[np.where(np.abs(diff) < resolution)] = 0 + # keep left and right edges + consecutive_l = np.insert(diff.astype(bool), 0, True) + consecutive_r = np.append(diff.astype(bool), True) + return consecutive_l | consecutive_r + + except ValueError: + return np.ones_like(data_array).astype(bool) + + +def _parse_waveform( + data: types.PulseInstruction, +) -> types.ParsedInstruction | types.OpaqueShape: + """A helper function that generates an array for the waveform with + instruction metadata. + + Args: + data: Instruction data set + + Raises: + VisualizationError: When invalid instruction type is loaded. + + Returns: + A data source to generate a drawing. + """ + inst = data.inst + + meta: dict[str, Any] = {} + if isinstance(inst, instructions.Play): + # pulse + operand = inst.pulse + if isinstance(operand, pulse.SymbolicPulse): + # parametric pulse + params = operand.parameters + duration = params.pop("duration", None) + if isinstance(duration, circuit.Parameter): + duration = None + + if isinstance(operand, library.SymbolicPulse): + pulse_shape = operand.pulse_type + else: + pulse_shape = "Waveform" + meta["waveform shape"] = pulse_shape + + meta.update( + { + key: val.name if isinstance(val, circuit.Parameter) else val + for key, val in params.items() + } + ) + if data.is_opaque: + # parametric pulse with unbound parameter + if duration: + meta.update( + { + "duration (cycle time)": inst.duration, + "duration (sec)": inst.duration * data.dt if data.dt else "N/A", + } + ) + else: + meta.update({"duration (cycle time)": "N/A", "duration (sec)": "N/A"}) + + meta.update( + { + "t0 (cycle time)": data.t0, + "t0 (sec)": data.t0 * data.dt if data.dt else "N/A", + "phase": data.frame.phase, + "frequency": data.frame.freq, + "name": inst.name, + } + ) + + return types.OpaqueShape(duration=duration, meta=meta) + else: + # fixed shape parametric pulse + pulse_data = operand.get_waveform() + else: + # waveform + pulse_data = operand + xdata = np.arange(pulse_data.duration) + data.t0 + ydata = pulse_data.samples + elif isinstance(inst, instructions.Delay): + # delay + xdata = np.arange(inst.duration) + data.t0 + ydata = np.zeros(inst.duration) + elif isinstance(inst, instructions.Acquire): + # acquire + xdata = np.arange(inst.duration) + data.t0 + ydata = np.ones(inst.duration) + acq_data = { + "memory slot": inst.mem_slot.name, + "register slot": inst.reg_slot.name if inst.reg_slot else "N/A", + "discriminator": inst.discriminator.name if inst.discriminator else "N/A", + "kernel": inst.kernel.name if inst.kernel else "N/A", + } + meta.update(acq_data) + else: + raise VisualizationError( + f"Unsupported instruction {inst.__class__.__name__} by " "filled envelope." + ) + + meta.update( + { + "duration (cycle time)": inst.duration, + "duration (sec)": inst.duration * data.dt if data.dt else "N/A", + "t0 (cycle time)": data.t0, + "t0 (sec)": data.t0 * data.dt if data.dt else "N/A", + "phase": data.frame.phase, + "frequency": data.frame.freq, + "name": inst.name, + } + ) + + return types.ParsedInstruction(xvals=xdata, yvals=ydata, meta=meta) diff --git a/qiskit_dynamics/pulse/visualization/interface.py b/qiskit_dynamics/pulse/visualization/interface.py new file mode 100644 index 000000000..75370905d --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/interface.py @@ -0,0 +1,456 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Qiskit pulse drawer. + +This module provides a common user interface for the pulse drawer. +The `draw` function takes a pulse program to visualize with a stylesheet and +backend information along with several control arguments. +The drawer canvas object is internally initialized from the input data and +the configured canvas is passed to the one of plotter APIs to generate visualization data. +""" + +from typing import Union, Optional, Dict, Any, Tuple, List + +from qiskit.providers import Backend +from qiskit.pulse import Waveform, SymbolicPulse, Schedule, ScheduleBlock +from qiskit.pulse.channels import Channel +from qiskit.visualization.exceptions import VisualizationError +from qiskit.visualization.pulse_v2 import core, device_info, stylesheet, types +from qiskit.exceptions import MissingOptionalLibraryError +from qiskit.utils import deprecate_arg + + +@deprecate_arg("show_barriers", new_alias="plot_barriers", since="1.1.0", pending=True) +def draw( + program: Union[Waveform, SymbolicPulse, Schedule, ScheduleBlock], + style: Optional[Dict[str, Any]] = None, + backend: Optional[Backend] = None, + time_range: Optional[Tuple[int, int]] = None, + time_unit: str = types.TimeUnits.CYCLES.value, + disable_channels: Optional[List[Channel]] = None, + show_snapshot: bool = True, + show_framechange: bool = True, + show_waveform_info: bool = True, + plot_barrier: bool = True, + plotter: str = types.Plotter.Mpl2D.value, + axis: Optional[Any] = None, + show_barrier: bool = True, +): + """Generate visualization data for pulse programs. + + Args: + program: Program to visualize. This program can be arbitrary Qiskit Pulse program, + such as :py:class:`~qiskit.pulse.Waveform`, :py:class:`~qiskit.pulse.SymbolicPulse`, + :py:class:`~qiskit.pulse.Schedule` and :py:class:`~qiskit.pulse.ScheduleBlock`. + style: Stylesheet options. This can be dictionary or preset stylesheet classes. See + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXStandard`, + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXSimple`, and + :py:class:`~qiskit.visualization.pulse_v2.stylesheets.IQXDebugging` for details of + preset stylesheets. See also the stylesheet section for details of configuration keys. + backend: Backend object to play the input pulse program. If provided, the plotter + may use to make the visualization hardware aware. + time_range: Set horizontal axis limit. Tuple ``(tmin, tmax)``. + time_unit: The unit of specified time range either ``dt`` or ``ns``. + The unit of ``ns`` is available only when ``backend`` object is provided. + disable_channels: A control property to show specific pulse channel. + Pulse channel instances provided as a list is not shown in the output image. + show_snapshot: Show snapshot instructions. + show_framechange: Show frame change instructions. The frame change represents + instructions that modulate phase or frequency of pulse channels. + show_waveform_info: Show waveform annotations, i.e. name, of waveforms. + Set ``True`` to show additional information about waveforms. + plot_barrier: Show barrier lines. + plotter: Name of plotter API to generate an output image. + One of following APIs should be specified:: + + mpl2d: Matplotlib API for 2D image generation. + Matplotlib API to generate 2D image. Charts are placed along y axis with + vertical offset. This API takes matplotlib.axes.Axes as `axis` input. + + `axis` and `style` kwargs may depend on the plotter. + axis: Arbitrary object passed to the plotter. If this object is provided, + the plotters use a given ``axis`` instead of internally initializing + a figure object. This object format depends on the plotter. + See plotter argument for details. + show_barrier: DEPRECATED. Show barrier lines. + + Returns: + Visualization output data. + The returned data type depends on the `plotter`. + If matplotlib family is specified, this will be a `matplotlib.pyplot.Figure` data. + The returned data is generated by the :meth:`get_image` method of the specified plotter API. + + .. _style-dict-doc: + + **Style Dict Details** + + The stylesheet kwarg contains numerous options that define the style of the + output pulse visualization. + The stylesheet options can be classified into `formatter`, `generator` and `layout`. + Those options available in the stylesheet are defined below: + + Args: + formatter.general.fig_width: Width of output image (default `13`). + formatter.general.fig_chart_height: Height of output image per chart. + The height of each chart is multiplied with this factor and the + sum of all chart heights becomes the height of output image (default `1.5`). + formatter.general.vertical_resolution: Vertical resolution of the pulse envelope. + The change of data points below this limit is ignored (default `1e-6`). + formatter.general.max_scale: Maximum scaling factor of each chart. This factor is + considered when chart auto-scaling is enabled (default `100`). + formatter.color.waveforms: A dictionary of the waveform colors to use for + each element type in the output visualization. The default values are:: + + { + 'W': `['#648fff', '#002999']`, + 'D': `['#648fff', '#002999']`, + 'U': `['#ffb000', '#994A00']`, + 'M': `['#dc267f', '#760019']`, + 'A': `['#dc267f', '#760019']` + } + + formatter.color.baseline: Color code of lines of zero line of each chart + (default `'#000000'`). + formatter.color.barrier: Color code of lines of barrier (default `'#222222'`). + formatter.color.background: Color code of the face color of canvas + (default `'#f2f3f4'`). + formatter.color.fig_title: Color code of the figure title text + (default `'#000000'`). + formatter.color.annotate: Color code of annotation texts in the canvas + (default `'#222222'`). + formatter.color.frame_change: Color code of the symbol for frame changes + (default `'#000000'`). + formatter.color.snapshot: Color code of the symbol for snapshot + (default `'#000000'`) + formatter.color.opaque_shape: Color code of the face and edge of opaque shape box + (default `['#fffacd', '#000000']`) + formatter.color.axis_label: Color code of axis labels (default `'#000000'`). + formatter.alpha.fill_waveform: Transparency of waveforms. A value in the range from + `0` to `1`. The value `0` gives completely transparent waveforms (default `0.3`). + formatter.alpha.baseline: Transparency of base lines. A value in the range from + `0` to `1`. The value `0` gives completely transparent base lines (default `1.0`). + formatter.alpha.barrier: Transparency of barrier lines. A value in the range from + `0` to `1`. The value `0` gives completely transparent barrier lines (default `0.7`). + formatter.alpha.opaque_shape: Transparency of opaque shape box. A value in the range from + `0` to `1`. The value `0` gives completely transparent barrier lines (default `0.7`). + formatter.layer.fill_waveform: Layer index of waveforms. Larger number comes + in the front of the output image (default `2`). + formatter.layer.baseline: Layer index of baselines. Larger number comes + in the front of the output image (default `1`). + formatter.layer.barrier: Layer index of barrier lines. Larger number comes + in the front of the output image (default `1`). + formatter.layer.annotate: Layer index of annotations. Larger number comes + in the front of the output image (default `5`). + formatter.layer.axis_label: Layer index of axis labels. Larger number comes + in the front of the output image (default `5`). + formatter.layer.frame_change: Layer index of frame change symbols. Larger number comes + in the front of the output image (default `4`). + formatter.layer.snapshot: Layer index of snapshot symbols. Larger number comes + in the front of the output image (default `3`). + formatter.layer.fig_title: Layer index of the figure title. Larger number comes + in the front of the output image (default `6`). + formatter.margin.top: Margin from the top boundary of the figure canvas to + the surface of the first chart (default `0.5`). + formatter.margin.bottom: Margin from the bottom boundary of the figure canvas to + the surface of the last chart (default `0.5`). + formatter.margin.left_percent: Margin from the left boundary of the figure canvas to + the zero point of the horizontal axis. The value is in units of percentage of + the whole program duration. If the duration is 100 and the value of 0.5 is set, + this keeps left margin of 5 (default `0.05`). + formatter.margin.right_percent: Margin from the right boundary of the figure canvas to + the left limit of the horizontal axis. The value is in units of percentage of + the whole program duration. If the duration is 100 and the value of 0.5 is set, + this keeps right margin of 5 (default `0.05`). + formatter.margin.between_channel: Vertical margin between charts (default `0.2`). + formatter.label_offset.pulse_name: Offset of pulse name annotations from the + chart baseline (default `0.3`). + formatter.label_offset.chart_info: Offset of chart info annotations from the + chart baseline (default `0.3`). + formatter.label_offset.frame_change: Offset of frame change annotations from the + chart baseline (default `0.3`). + formatter.label_offset.snapshot: Offset of snapshot annotations from the + chart baseline (default `0.3`). + formatter.text_size.axis_label: Text size of axis labels (default `15`). + formatter.text_size.annotate: Text size of annotations (default `12`). + formatter.text_size.frame_change: Text size of frame change symbols (default `20`). + formatter.text_size.snapshot: Text size of snapshot symbols (default `20`). + formatter.text_size.fig_title: Text size of the figure title (default `15`). + formatter.text_size.axis_break_symbol: Text size of axis break symbols (default `15`). + formatter.line_width.fill_waveform: Line width of the fringe of filled waveforms + (default `0`). + formatter.line_width.axis_break: Line width of axis breaks. + The axis break line paints over other drawings with the background + face color (default `6`). + formatter.line_width.baseline: Line width of base lines (default `1`) + formatter.line_width.barrier: Line width of barrier lines (default `1`). + formatter.line_width.opaque_shape: Line width of opaque shape box (default `1`). + formatter.line_style.fill_waveform: Line style of the fringe of filled waveforms. This + conforms to the line style spec of matplotlib (default `'-'`). + formatter.line_style.baseline: Line style of base lines. This + conforms to the line style spec of matplotlib (default `'-'`). + formatter.line_style.barrier: Line style of barrier lines. This + conforms to the line style spec of matplotlib (default `':'`). + formatter.line_style.opaque_shape: Line style of opaque shape box. This + conforms to the line style spec of matplotlib (default `'--'`). + formatter.channel_scaling.drive: Default scaling value of drive channel + waveforms (default `1.0`). + formatter.channel_scaling.control: Default scaling value of control channel + waveforms (default `1.0`). + formatter.channel_scaling.measure: Default scaling value of measure channel + waveforms (default `1.0`). + formatter.channel_scaling.acquire: Default scaling value of acquire channel + waveforms (default `1.0`). + formatter.channel_scaling.pos_spacing: Minimum height of chart above the baseline. + Chart top is determined based on the maximum height of waveforms associated + with the chart. If the maximum height is below this value, this value is set + as the chart top (default 0.1). + formatter.channel_scaling.neg_spacing: Minimum height of chart below the baseline. + Chart bottom is determined based on the minimum height of waveforms associated + with the chart. If the minimum height is above this value, this value is set + as the chart bottom (default -0.1). + formatter.box_width.opaque_shape: Default box length of the waveform representation + when the instruction is parameterized and duration is not bound or not defined. + Value is units in dt (default: 150). + formatter.box_height.opaque_shape: Default box height of the waveform representation + when the instruction is parameterized (default: 0.4). + formatter.axis_break.length: Waveform or idle time duration that axis break is + applied. Intervals longer than this value are truncated. + The value is in units of data points (default `3000`). + formatter.axis_break.max_length: Length of new waveform or idle time duration + after axis break is applied. Longer intervals are truncated to this length + (default `1000`). + formatter.control.fill_waveform: Set `True` to fill waveforms with face color + (default `True`). When you disable this option, you should set finite line width + to `formatter.line_width.fill_waveform`, otherwise nothing will appear in the graph. + formatter.control.apply_phase_modulation: Set `True` to apply phase modulation + to the waveforms (default `True`). + formatter.control.show_snapshot_channel: Set `True` to show snapshot instructions + (default `True`). + formatter.control.show_acquire_channel: Set `True` to show acquire channels + (default `True`). + formatter.control.show_empty_channel: Set `True` to show charts without any waveforms + (default `True`). + formatter.control.auto_chart_scaling: Set `True` to apply auto-scaling to charts + (default `True`). + formatter.control.axis_break: Set `True` to apply axis break for long intervals + (default `True`). + formatter.unicode_symbol.frame_change: Text that represents the symbol of + frame change. This text is used when the plotter doesn't support latex + (default u'\u21BA'). + formatter.unicode_symbol.snapshot: Text that represents the symbol of + snapshot. This text is used when the plotter doesn't support latex + (default u'\u21AF'). + formatter.unicode_symbol.phase_parameter: Text that represents the symbol of + parameterized phase value. This text is used when the plotter doesn't support latex + (default u'\u03b8'). + formatter.unicode_symbol.freq_parameter: Text that represents the symbol of + parameterized frequency value. This text is used when the plotter doesn't support latex + (default 'f'). + formatter.latex_symbol.frame_change: Latex text that represents the symbol of + frame change (default r'\\circlearrowleft'). + formatter.latex_symbol.snapshot: Latex text that represents the symbol of + snapshot (default ''). + formatter.latex_symbol.phase_parameter: Latex text that represents the symbol of + parameterized phase value (default r'\theta'). + formatter.latex_symbol.freq_parameter: Latex text that represents the symbol of + parameterized frequency value (default 'f'). + generator.waveform: List of callback functions that generates drawing + for waveforms. Arbitrary callback functions satisfying the generator format + can be set here. There are some default generators in the pulse drawer. + See :py:mod:`~qiskit.visualization.pulse_v2.generators.waveform` for more details. + No default generator is set. + generator.frame: List of callback functions that generates drawing + for frame changes. Arbitrary callback functions satisfying the generator format + can be set here. There are some default generators in the pulse drawer. + See :py:mod:`~qiskit.visualization.pulse_v2.generators.frame` for more details. + No default generator is set. + generator.chart: List of callback functions that generates drawing + for charts. Arbitrary callback functions satisfying the generator format + can be set here. There are some default generators in the pulse drawer. + See :py:mod:`~qiskit.visualization.pulse_v2.generators.chart` for more details. + No default generator is set. + generator.snapshot: List of callback functions that generates drawing + for snapshots. Arbitrary callback functions satisfying the generator format + can be set here. There are some default generators in the pulse drawer. + See :py:mod:`~qiskit.visualization.pulse_v2.generators.snapshot` for more details. + No default generator is set. + generator.barrier: List of callback functions that generates drawing + for barriers. Arbitrary callback functions satisfying the generator format + can be set here. There are some default generators in the pulse drawer. + See :py:mod:`~qiskit.visualization.pulse_v2.generators.barrier` for more details. + No default generator is set. + layout.chart_channel_map: Callback function that determines the relationship + between pulse channels and charts. + See :py:mod:`~qiskit.visualization.pulse_v2.layout` for more details. + No default layout is set. + layout.time_axis_map: Callback function that determines the layout of + horizontal axis labels. + See :py:mod:`~qiskit.visualization.pulse_v2.layout` for more details. + No default layout is set. + layout.figure_title: Callback function that generates a string for + the figure title. + See :py:mod:`~qiskit.visualization.pulse_v2.layout` for more details. + No default layout is set. + + Examples: + To visualize a pulse program, you can call this function with set of + control arguments. Most of appearance of the output image can be controlled by the + stylesheet. + + Drawing with the default stylesheet. + + .. plot:: + :include-source: + + from qiskit import QuantumCircuit, transpile, schedule + from qiskit.visualization.pulse_v2 import draw + from qiskit.providers.fake_provider import GenericBackendV2 + + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + qc = transpile(qc, GenericBackendV2(5), layout_method='trivial') + sched = schedule(qc, GenericBackendV2(5)) + + draw(sched, backend=GenericBackendV2(5)) + + Drawing with the stylesheet suited for publication. + + .. plot:: + :include-source: + + from qiskit import QuantumCircuit, transpile, schedule + from qiskit.visualization.pulse_v2 import draw, IQXSimple + from qiskit.providers.fake_provider import GenericBackendV2 + + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + qc = transpile(qc, GenericBackendV2(5), layout_method='trivial') + sched = schedule(qc, GenericBackendV2(5)) + + draw(sched, style=IQXSimple(), backend=GenericBackendV2(5)) + + Drawing with the stylesheet suited for program debugging. + + .. plot:: + :include-source: + + from qiskit import QuantumCircuit, transpile, schedule + from qiskit.visualization.pulse_v2 import draw, IQXDebugging + from qiskit.providers.fake_provider import GenericBackendV2 + + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + qc = transpile(qc, GenericBackendV2(5), layout_method='trivial') + sched = schedule(qc, GenericBackendV2(5)) + + draw(sched, style=IQXDebugging(), backend=GenericBackendV2(5)) + + You can partially customize a preset stylesheet when initializing it. + + .. code-block:: python + + my_style = { + 'formatter.channel_scaling.drive': 5, + 'formatter.channel_scaling.control': 1, + 'formatter.channel_scaling.measure': 5 + } + style = IQXStandard(**my_style) + # draw + draw(sched, style=style, backend=GenericBackendV2(5)) + + In the same way as above, you can create custom generator or layout functions + and update the existing stylesheet with custom functions. + This feature enables you to customize most of the appearance of the output image + without modifying the codebase. + + Raises: + MissingOptionalLibraryError: When required visualization package is not installed. + VisualizationError: When invalid plotter API or invalid time range is specified. + """ + del show_barrier + temp_style = stylesheet.QiskitPulseStyle() + temp_style.update(style or stylesheet.IQXStandard()) + + if backend: + device = device_info.OpenPulseBackendInfo.create_from_backend(backend) + else: + device = device_info.OpenPulseBackendInfo() + + # create empty canvas and load program + canvas = core.DrawerCanvas(stylesheet=temp_style, device=device) + canvas.load_program(program=program) + + # + # update configuration + # + + # time range + if time_range: + if time_unit == types.TimeUnits.CYCLES.value: + canvas.set_time_range(*time_range, seconds=False) + elif time_unit == types.TimeUnits.NS.value: + canvas.set_time_range(*time_range, seconds=True) + else: + raise VisualizationError(f"Invalid time unit {time_unit} is specified.") + + # channels not shown + if disable_channels: + for chan in disable_channels: + canvas.set_disable_channel(chan, remove=True) + + # show snapshots + if not show_snapshot: + canvas.set_disable_type(types.SymbolType.SNAPSHOT, remove=True) + canvas.set_disable_type(types.LabelType.SNAPSHOT, remove=True) + + # show frame changes + if not show_framechange: + canvas.set_disable_type(types.SymbolType.FRAME, remove=True) + canvas.set_disable_type(types.LabelType.FRAME, remove=True) + + # show waveform info + if not show_waveform_info: + canvas.set_disable_type(types.LabelType.PULSE_INFO, remove=True) + canvas.set_disable_type(types.LabelType.PULSE_NAME, remove=True) + + # show barrier + if not plot_barrier: + canvas.set_disable_type(types.LineType.BARRIER, remove=True) + + canvas.update() + + # + # Call plotter API and generate image + # + + if plotter == types.Plotter.Mpl2D.value: + try: + from qiskit.visualization.pulse_v2.plotters import Mpl2DPlotter + except ImportError as ex: + raise MissingOptionalLibraryError( + libname="Matplotlib", + name="plot_histogram", + pip_install="pip install matplotlib", + ) from ex + plotter_api = Mpl2DPlotter(canvas=canvas, axis=axis) + plotter_api.draw() + else: + raise VisualizationError(f"Plotter API {plotter} is not supported.") + + return plotter_api.get_image() diff --git a/qiskit_dynamics/pulse/visualization/layouts.py b/qiskit_dynamics/pulse/visualization/layouts.py new file mode 100644 index 000000000..13b42e394 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/layouts.py @@ -0,0 +1,387 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=unused-argument + +""" +A collection of functions that decide the layout of an output image. +See :py:mod:`~qiskit.visualization.pulse_v2.types` for more info on the required data. + +There are 3 types of layout functions in this module. + +1. layout.chart_channel_map + +An end-user can write arbitrary functions that output the custom channel ordering +associated with group name. Layout function in this module are called with the +`formatter` and `device` kwargs. These data provides stylesheet configuration +and backend system configuration. + +The layout function is restricted to: + + + ```python + def my_channel_layout(channels: List[pulse.channels.Channel], + formatter: Dict[str, Any], + device: DrawerBackendInfo + ) -> Iterator[Tuple[str, List[pulse.channels.Channel]]]: + ordered_channels = [] + # arrange order of channels + + for key, channels in my_ordering_dict.items(): + yield key, channels + ``` + +2. layout.time_axis_map + +An end-user can write arbitrary functions that output the `HorizontalAxis` data set that +will be later consumed by the plotter API to update the horizontal axis appearance. +Layout function in this module are called with the `time_window`, `axis_breaks`, and `dt` kwargs. +These data provides horizontal axis limit, axis break position, and time resolution, respectively. + +See py:mod:`qiskit.visualization.pulse_v2.types` for more info on the required +data. + + ```python + def my_horizontal_axis(time_window: Tuple[int, int], + axis_breaks: List[Tuple[int, int]], + dt: Optional[float] = None) -> HorizontalAxis: + # write horizontal axis configuration + + return horizontal_axis + ``` + +3. layout.figure_title + +An end-user can write arbitrary functions that output the string data that +will be later consumed by the plotter API to output the figure title. +Layout functions in this module are called with the `program` and `device` kwargs. +This data provides input program and backend system configurations. + + ```python + def my_figure_title(program: Union[pulse.Waveform, pulse.Schedule], + device: DrawerBackendInfo) -> str: + + return 'title' + ``` + +An arbitrary layout function satisfying the above format can be accepted. +""" + +from collections import defaultdict +from typing import List, Dict, Any, Tuple, Iterator, Optional, Union + +import numpy as np +from qiskit import pulse +from qiskit.visualization.pulse_v2 import types +from qiskit.visualization.pulse_v2.device_info import DrawerBackendInfo + + +def channel_type_grouped_sort( + channels: List[pulse.channels.Channel], formatter: Dict[str, Any], device: DrawerBackendInfo +) -> Iterator[Tuple[str, List[pulse.channels.Channel]]]: + """Layout function for the channel assignment to the chart instance. + + Assign single channel per chart. Channels are grouped by type and + sorted by index in ascending order. + + Stylesheet key: + `chart_channel_map` + + For example: + [D0, D2, C0, C2, M0, M2, A0, A2] -> [D0, D2, C0, C2, M0, M2, A0, A2] + + Args: + channels: Channels to show. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Yields: + Tuple of chart name and associated channels. + """ + chan_type_dict = defaultdict(list) + + for chan in channels: + chan_type_dict[type(chan)].append(chan) + + ordered_channels = [] + + # drive channels + d_chans = chan_type_dict.get(pulse.DriveChannel, []) + ordered_channels.extend(sorted(d_chans, key=lambda x: x.index)) + + # control channels + c_chans = chan_type_dict.get(pulse.ControlChannel, []) + ordered_channels.extend(sorted(c_chans, key=lambda x: x.index)) + + # measure channels + m_chans = chan_type_dict.get(pulse.MeasureChannel, []) + ordered_channels.extend(sorted(m_chans, key=lambda x: x.index)) + + # acquire channels + if formatter["control.show_acquire_channel"]: + a_chans = chan_type_dict.get(pulse.AcquireChannel, []) + ordered_channels.extend(sorted(a_chans, key=lambda x: x.index)) + + for chan in ordered_channels: + yield chan.name.upper(), [chan] + + +def channel_index_grouped_sort( + channels: List[pulse.channels.Channel], formatter: Dict[str, Any], device: DrawerBackendInfo +) -> Iterator[Tuple[str, List[pulse.channels.Channel]]]: + """Layout function for the channel assignment to the chart instance. + + Assign single channel per chart. Channels are grouped by the same index and + sorted by type. + + Stylesheet key: + `chart_channel_map` + + For example: + [D0, D2, C0, C2, M0, M2, A0, A2] -> [D0, D2, C0, C2, M0, M2, A0, A2] + + Args: + channels: Channels to show. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Yields: + Tuple of chart name and associated channels. + """ + chan_type_dict = defaultdict(list) + inds = set() + + for chan in channels: + chan_type_dict[type(chan)].append(chan) + inds.add(chan.index) + + d_chans = chan_type_dict.get(pulse.DriveChannel, []) + d_chans = sorted(d_chans, key=lambda x: x.index, reverse=True) + + u_chans = chan_type_dict.get(pulse.ControlChannel, []) + u_chans = sorted(u_chans, key=lambda x: x.index, reverse=True) + + m_chans = chan_type_dict.get(pulse.MeasureChannel, []) + m_chans = sorted(m_chans, key=lambda x: x.index, reverse=True) + + a_chans = chan_type_dict.get(pulse.AcquireChannel, []) + a_chans = sorted(a_chans, key=lambda x: x.index, reverse=True) + + ordered_channels = [] + + for ind in sorted(inds): + # drive channel + if len(d_chans) > 0 and d_chans[-1].index == ind: + ordered_channels.append(d_chans.pop()) + # control channel + if len(u_chans) > 0 and u_chans[-1].index == ind: + ordered_channels.append(u_chans.pop()) + # measure channel + if len(m_chans) > 0 and m_chans[-1].index == ind: + ordered_channels.append(m_chans.pop()) + # acquire channel + if formatter["control.show_acquire_channel"]: + if len(a_chans) > 0 and a_chans[-1].index == ind: + ordered_channels.append(a_chans.pop()) + + for chan in ordered_channels: + yield chan.name.upper(), [chan] + + +def channel_index_grouped_sort_u( + channels: List[pulse.channels.Channel], formatter: Dict[str, Any], device: DrawerBackendInfo +) -> Iterator[Tuple[str, List[pulse.channels.Channel]]]: + """Layout function for the channel assignment to the chart instance. + + Assign single channel per chart. Channels are grouped by the same index and + sorted by type except for control channels. Control channels are added to the + end of other channels. + + Stylesheet key: + `chart_channel_map` + + For example: + [D0, D2, C0, C2, M0, M2, A0, A2] -> [D0, D2, C0, C2, M0, M2, A0, A2] + + Args: + channels: Channels to show. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Yields: + Tuple of chart name and associated channels. + """ + chan_type_dict = defaultdict(list) + inds = set() + + for chan in channels: + chan_type_dict[type(chan)].append(chan) + inds.add(chan.index) + + d_chans = chan_type_dict.get(pulse.DriveChannel, []) + d_chans = sorted(d_chans, key=lambda x: x.index, reverse=True) + + m_chans = chan_type_dict.get(pulse.MeasureChannel, []) + m_chans = sorted(m_chans, key=lambda x: x.index, reverse=True) + + a_chans = chan_type_dict.get(pulse.AcquireChannel, []) + a_chans = sorted(a_chans, key=lambda x: x.index, reverse=True) + + u_chans = chan_type_dict.get(pulse.ControlChannel, []) + u_chans = sorted(u_chans, key=lambda x: x.index) + + ordered_channels = [] + + for ind in sorted(inds): + # drive channel + if len(d_chans) > 0 and d_chans[-1].index == ind: + ordered_channels.append(d_chans.pop()) + # measure channel + if len(m_chans) > 0 and m_chans[-1].index == ind: + ordered_channels.append(m_chans.pop()) + # acquire channel + if formatter["control.show_acquire_channel"]: + if len(a_chans) > 0 and a_chans[-1].index == ind: + ordered_channels.append(a_chans.pop()) + + # control channels + ordered_channels.extend(u_chans) + + for chan in ordered_channels: + yield chan.name.upper(), [chan] + + +def qubit_index_sort( + channels: List[pulse.channels.Channel], formatter: Dict[str, Any], device: DrawerBackendInfo +) -> Iterator[Tuple[str, List[pulse.channels.Channel]]]: + """Layout function for the channel assignment to the chart instance. + + Assign multiple channels per chart. Channels associated with the same qubit + are grouped in the same chart and sorted by qubit index in ascending order. + + Acquire channels are not shown. + + Stylesheet key: + `chart_channel_map` + + For example: + [D0, D2, C0, C2, M0, M2, A0, A2] -> [Q0, Q1, Q2] + + Args: + channels: Channels to show. + formatter: Dictionary of stylesheet settings. + device: Backend configuration. + + Yields: + Tuple of chart name and associated channels. + """ + _removed = ( + pulse.channels.AcquireChannel, + pulse.channels.MemorySlot, + pulse.channels.RegisterSlot, + ) + + qubit_channel_map = defaultdict(list) + + for chan in channels: + if isinstance(chan, _removed): + continue + qubit_channel_map[device.get_qubit_index(chan)].append(chan) + + sorted_map = sorted(qubit_channel_map.items(), key=lambda x: x[0]) + + for qind, chans in sorted_map: + yield f"Q{qind:d}", chans + + +def time_map_in_ns( + time_window: Tuple[int, int], axis_breaks: List[Tuple[int, int]], dt: Optional[float] = None +) -> types.HorizontalAxis: + """Layout function for the horizontal axis formatting. + + Calculate axis break and map true time to axis labels. Generate equispaced + 6 horizontal axis ticks. Convert into seconds if ``dt`` is provided. + + Args: + time_window: Left and right edge of this graph. + axis_breaks: List of axis break period. + dt: Time resolution of system. + + Returns: + Axis formatter object. + """ + # shift time axis + t0, t1 = time_window + t0_shift = t0 + t1_shift = t1 + + axis_break_pos = [] + offset_accumulation = 0 + for t0b, t1b in axis_breaks: + if t1b < t0 or t0b > t1: + continue + if t0 > t1b: + t0_shift -= t1b - t0b + if t1 > t1b: + t1_shift -= t1b - t0b + axis_break_pos.append(t0b - offset_accumulation) + offset_accumulation += t1b - t0b + + # axis label + axis_loc = np.linspace(max(t0_shift, 0), t1_shift, 6) + axis_label = axis_loc.copy() + + for t0b, t1b in axis_breaks: + offset = t1b - t0b + axis_label = np.where(axis_label > t0b, axis_label + offset, axis_label) + + # consider time resolution + if dt: + label = "Time (ns)" + axis_label *= dt * 1e9 + else: + label = "System cycle time (dt)" + + formatted_label = [f"{val:.0f}" for val in axis_label] + + return types.HorizontalAxis( + window=(t0_shift, t1_shift), + axis_map=dict(zip(axis_loc, formatted_label)), + axis_break_pos=axis_break_pos, + label=label, + ) + + +def detail_title(program: Union[pulse.Waveform, pulse.Schedule], device: DrawerBackendInfo) -> str: + """Layout function for generating figure title. + + This layout writes program name, program duration, and backend name in the title. + """ + title_str = [] + + # add program name + title_str.append(f"Name: {program.name}") + + # add program duration + dt = device.dt * 1e9 if device.dt else 1.0 + title_str.append(f"Duration: {program.duration * dt:.1f} {'ns' if device.dt else 'dt'}") + + # add device name + if device.backend_name != "no-backend": + title_str.append(f"Backend: {device.backend_name}") + + return ", ".join(title_str) + + +def empty_title(program: Union[pulse.Waveform, pulse.Schedule], device: DrawerBackendInfo) -> str: + """Layout function for generating an empty figure title.""" + return "" diff --git a/qiskit_dynamics/pulse/visualization/plotters/__init__.py b/qiskit_dynamics/pulse/visualization/plotters/__init__.py new file mode 100644 index 000000000..90c67065e --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/plotters/__init__.py @@ -0,0 +1,17 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +""" +Plotter API for pulse drawer. +""" + +from qiskit.visualization.pulse_v2.plotters.matplotlib import Mpl2DPlotter diff --git a/qiskit_dynamics/pulse/visualization/plotters/base_plotter.py b/qiskit_dynamics/pulse/visualization/plotters/base_plotter.py new file mode 100644 index 000000000..ee6f79235 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/plotters/base_plotter.py @@ -0,0 +1,53 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Base plotter API.""" + +from abc import ABC, abstractmethod +from typing import Any + +from qiskit.visualization.pulse_v2 import core + + +class BasePlotter(ABC): + """Base class of Qiskit plotter.""" + + def __init__(self, canvas: core.DrawerCanvas): + """Create new plotter. + + Args: + canvas: Configured drawer canvas object. + """ + self.canvas = canvas + + @abstractmethod + def initialize_canvas(self): + """Format appearance of the canvas.""" + raise NotImplementedError + + @abstractmethod + def draw(self): + """Output drawing objects stored in canvas object.""" + raise NotImplementedError + + @abstractmethod + def get_image(self, interactive: bool = False) -> Any: + """Get image data to return. + + Args: + interactive: When set `True` show the circuit in a new window. + This depends on the matplotlib backend being used supporting this. + + Returns: + Image data. This depends on the plotter API. + """ + raise NotImplementedError diff --git a/qiskit_dynamics/pulse/visualization/plotters/matplotlib.py b/qiskit_dynamics/pulse/visualization/plotters/matplotlib.py new file mode 100644 index 000000000..1788a1254 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/plotters/matplotlib.py @@ -0,0 +1,201 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Matplotlib plotter API.""" + +from typing import Optional + +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import Rectangle + +from qiskit.visualization.exceptions import VisualizationError +from qiskit.visualization.pulse_v2 import core, drawings, types +from qiskit.visualization.pulse_v2.plotters.base_plotter import BasePlotter +from qiskit.visualization.utils import matplotlib_close_if_inline + + +class Mpl2DPlotter(BasePlotter): + """Matplotlib API for pulse drawer. + + This plotter places canvas charts along y axis of 2D canvas with vertical offset. + Each chart is map to X-Y axis of the canvas. + """ + + def __init__(self, canvas: core.DrawerCanvas, axis: Optional[plt.Axes] = None): + """Create new plotter. + + Args: + canvas: Configured drawer canvas object. Canvas object should be updated + with `.update` method before set to the plotter API. + axis: Matplotlib axis object. When `axis` is provided, the plotter updates + given axis instead of creating and returning new matplotlib figure. + """ + super().__init__(canvas=canvas) + + # calculate height of all charts + canvas_height = 0 + for chart in self.canvas.charts: + if not chart.is_active and not self.canvas.formatter["control.show_empty_channel"]: + continue + canvas_height += chart.vmax - chart.vmin + # set min canvas_height size + canvas_height = max(canvas_height, 0.1) + + if axis is None: + fig_h = canvas_height * self.canvas.formatter["general.fig_chart_height"] + fig_w = self.canvas.formatter["general.fig_width"] + + self.figure = plt.figure(figsize=(fig_w, fig_h)) + self.ax = self.figure.add_subplot(1, 1, 1) + else: + self.figure = axis.figure + self.ax = axis + + self.initialize_canvas() + + def initialize_canvas(self): + """Format appearance of matplotlib canvas.""" + self.ax.set_facecolor(self.canvas.formatter["color.background"]) + + # axis labels + self.ax.set_yticklabels([]) + self.ax.yaxis.set_tick_params(left=False) + + def draw(self): + """Output drawings stored in canvas object.""" + # axis configuration + axis_config = self.canvas.layout["time_axis_map"]( + time_window=self.canvas.time_range, + axis_breaks=self.canvas.time_breaks, + dt=self.canvas.device.dt, + ) + + current_y = 0 + margin_y = self.canvas.formatter["margin.between_channel"] + for chart in self.canvas.charts: + if not chart.is_active and not self.canvas.formatter["control.show_empty_channel"]: + continue + current_y -= chart.vmax + for _, data in chart.collections: + # calculate scaling factor + if not data.ignore_scaling: + # product of channel-wise scaling and chart level scaling + scale = max(self.canvas.chan_scales.get(chan, 1.0) for chan in data.channels) + scale *= chart.scale + else: + scale = 1.0 + + x = data.xvals + y = scale * data.yvals + current_y + + if isinstance(data, drawings.LineData): + # line object + if data.fill: + self.ax.fill_between(x, y1=y, y2=current_y * np.ones_like(y), **data.styles) + else: + self.ax.plot(x, y, **data.styles) + elif isinstance(data, drawings.TextData): + # text object + text = rf"${data.latex}$" if data.latex else data.text + # replace dynamic text + text = text.replace(types.DynamicString.SCALE, f"{chart.scale:.1f}") + self.ax.text(x=x[0], y=y[0], s=text, **data.styles) + elif isinstance(data, drawings.BoxData): + xy = x[0], y[0] + box = Rectangle( + xy, width=x[1] - x[0], height=y[1] - y[0], fill=True, **data.styles + ) + self.ax.add_patch(box) + else: + raise VisualizationError( + f"Data {data} is not supported " f"by {self.__class__.__name__}" + ) + # axis break + for pos in axis_config.axis_break_pos: + self.ax.text( + x=pos, + y=current_y, + s="//", + ha="center", + va="center", + zorder=self.canvas.formatter["layer.axis_label"], + fontsize=self.canvas.formatter["text_size.axis_break_symbol"], + rotation=180, + ) + + # shift chart position + current_y += chart.vmin - margin_y + + # remove the last margin + current_y += margin_y + + y_max = self.canvas.formatter["margin.top"] + y_min = current_y - self.canvas.formatter["margin.bottom"] + + # plot axis break line + for pos in axis_config.axis_break_pos: + self.ax.plot( + [pos, pos], + [y_min, y_max], + zorder=self.canvas.formatter["layer.fill_waveform"] + 1, + linewidth=self.canvas.formatter["line_width.axis_break"], + color=self.canvas.formatter["color.background"], + ) + + # label + self.ax.set_xticks(list(axis_config.axis_map.keys())) + self.ax.set_xticklabels( + list(axis_config.axis_map.values()), + fontsize=self.canvas.formatter["text_size.axis_label"], + ) + self.ax.set_xlabel( + axis_config.label, fontsize=self.canvas.formatter["text_size.axis_label"] + ) + + # boundary + if axis_config.window == (0, 0): + self.ax.set_xlim(0, 1) + else: + self.ax.set_xlim(*axis_config.window) + self.ax.set_ylim(y_min, y_max) + + # title + if self.canvas.fig_title: + self.ax.text( + x=axis_config.window[0], + y=y_max, + s=self.canvas.fig_title, + ha="left", + va="bottom", + zorder=self.canvas.formatter["layer.fig_title"], + color=self.canvas.formatter["color.fig_title"], + size=self.canvas.formatter["text_size.fig_title"], + ) + + def get_image(self, interactive: bool = False) -> matplotlib.pyplot.Figure: + """Get image data to return. + + Args: + interactive: When set `True` show the circuit in a new window. + This depends on the matplotlib backend being used supporting this. + + Returns: + Matplotlib figure data. + """ + matplotlib_close_if_inline(self.figure) + + if self.figure and interactive: + self.figure.show() + + return self.figure diff --git a/qiskit_dynamics/pulse/visualization/stylesheet.py b/qiskit_dynamics/pulse/visualization/stylesheet.py new file mode 100644 index 000000000..1a37756f4 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/stylesheet.py @@ -0,0 +1,312 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +r""" +Stylesheet for pulse drawer. + +The stylesheet `QiskitPulseStyle` is initialized with the hard-corded default values in +`default_style`. This instance is generated when the pulse drawer module is loaded so that +every lower modules can access to the information. + +The `QiskitPulseStyle` is a wrapper class of python dictionary with the structured keys +such as `formatter.color.fill_waveform_d` to represent a color code of the drive channel. +This key representation and initialization framework are the imitative of +`rcParams` of `matplotlib`. However, the `QiskitPulseStyle` is not compatible with the `rcParams` +because the pulse stylesheet is heavily specialized to the context of the pulse program. + +The settings of stylesheet are broadly separated into `formatter`, `generator` and `layout`. +The formatter is a nested dictionary of drawing parameters to control the appearance of +each visualization element. This takes similar data structure to the `rcParams` of `matplotlib`. +The generator is a list of callback functions that generates drawing objects from +given program and device data. The layout is a callback function that determines +the appearance of the output image. +""" + +from typing import Dict, Any, Mapping +from qiskit.visualization.pulse_v2 import generators, layouts + + +class QiskitPulseStyle(dict): + """Stylesheet for pulse drawer.""" + + def __init__(self): + super().__init__() + # to inform which stylesheet is applied. some plotter may not support specific style. + self.stylesheet = None + self.update(default_style()) + + def update(self, __m: Mapping[str, Any], **kwargs) -> None: + super().update(__m, **kwargs) + for key, value in __m.items(): + self.__setitem__(key, value) + self.stylesheet = __m.__class__.__name__ + + @property + def formatter(self): + """Return formatter field of style dictionary.""" + sub_dict = {} + for key, value in self.items(): + sub_keys = key.split(".") + if sub_keys[0] == "formatter": + sub_dict[".".join(sub_keys[1:])] = value + return sub_dict + + @property + def generator(self): + """Return generator field of style dictionary.""" + sub_dict = {} + for key, value in self.items(): + sub_keys = key.split(".") + if sub_keys[0] == "generator": + sub_dict[".".join(sub_keys[1:])] = value + return sub_dict + + @property + def layout(self): + """Return layout field of style dictionary.""" + sub_dict = {} + for key, value in self.items(): + sub_keys = key.split(".") + if sub_keys[0] == "layout": + sub_dict[".".join(sub_keys[1:])] = value + return sub_dict + + +class IQXStandard(dict): + """Standard pulse stylesheet. + + - Generate stepwise waveform envelope with latex pulse names. + - Apply phase modulation to waveforms. + - Plot frame change symbol with formatted operand values. + - Show chart name with scaling factor. + - Show snapshot and barrier. + - Do not show acquire channels. + - Channels are sorted by index and control channels are added to the end. + """ + + def __init__(self, **kwargs): + super().__init__() + style = { + "formatter.control.apply_phase_modulation": True, + "formatter.control.show_snapshot_channel": True, + "formatter.control.show_acquire_channel": False, + "formatter.control.show_empty_channel": False, + "formatter.control.auto_chart_scaling": True, + "formatter.control.axis_break": True, + "generator.waveform": [ + generators.gen_filled_waveform_stepwise, + generators.gen_ibmq_latex_waveform_name, + ], + "generator.frame": [generators.gen_frame_symbol, generators.gen_formatted_frame_values], + "generator.chart": [ + generators.gen_chart_name, + generators.gen_baseline, + generators.gen_channel_freqs, + ], + "generator.snapshot": [generators.gen_snapshot_symbol], + "generator.barrier": [generators.gen_barrier], + "layout.chart_channel_map": layouts.channel_index_grouped_sort_u, + "layout.time_axis_map": layouts.time_map_in_ns, + "layout.figure_title": layouts.detail_title, + } + style.update(**kwargs) + self.update(style) + + def __repr__(self): + return "Standard Pulse style sheet." + + +class IQXSimple(dict): + """Simple pulse stylesheet without channel notation. + + - Generate stepwise waveform envelope with latex pulse names. + - Apply phase modulation to waveforms. + - Do not show frame changes. + - Show chart name. + - Do not show snapshot and barrier. + - Do not show acquire channels. + - Channels are sorted by qubit index. + """ + + def __init__(self, **kwargs): + super().__init__() + style = { + "formatter.general.fig_chart_height": 5, + "formatter.control.apply_phase_modulation": True, + "formatter.control.show_snapshot_channel": True, + "formatter.control.show_acquire_channel": False, + "formatter.control.show_empty_channel": False, + "formatter.control.auto_chart_scaling": False, + "formatter.control.axis_break": True, + "generator.waveform": [ + generators.gen_filled_waveform_stepwise, + generators.gen_ibmq_latex_waveform_name, + ], + "generator.frame": [], + "generator.chart": [generators.gen_chart_name, generators.gen_baseline], + "generator.snapshot": [], + "generator.barrier": [], + "layout.chart_channel_map": layouts.qubit_index_sort, + "layout.time_axis_map": layouts.time_map_in_ns, + "layout.figure_title": layouts.empty_title, + } + style.update(**kwargs) + self.update(style) + + def __repr__(self): + return "Simple pulse style sheet for publication." + + +class IQXDebugging(dict): + """Pulse stylesheet for pulse programmers. Show details of instructions. + + # TODO: add more generators + + - Generate stepwise waveform envelope with latex pulse names. + - Generate annotation for waveform height. + - Apply phase modulation to waveforms. + - Plot frame change symbol with raw operand values. + - Show chart name and channel frequency. + - Show snapshot and barrier. + - Show acquire channels. + - Channels are sorted by index and control channels are added to the end. + """ + + def __init__(self, **kwargs): + super().__init__() + style = { + "formatter.control.apply_phase_modulation": True, + "formatter.control.show_snapshot_channel": True, + "formatter.control.show_acquire_channel": True, + "formatter.control.show_empty_channel": False, + "formatter.control.auto_chart_scaling": True, + "formatter.control.axis_break": True, + "generator.waveform": [ + generators.gen_filled_waveform_stepwise, + generators.gen_ibmq_latex_waveform_name, + generators.gen_waveform_max_value, + ], + "generator.frame": [ + generators.gen_frame_symbol, + generators.gen_raw_operand_values_compact, + ], + "generator.chart": [ + generators.gen_chart_name, + generators.gen_baseline, + generators.gen_channel_freqs, + ], + "generator.snapshot": [generators.gen_snapshot_symbol, generators.gen_snapshot_name], + "generator.barrier": [generators.gen_barrier], + "layout.chart_channel_map": layouts.channel_index_grouped_sort_u, + "layout.time_axis_map": layouts.time_map_in_ns, + "layout.figure_title": layouts.detail_title, + } + style.update(**kwargs) + self.update(style) + + def __repr__(self): + return "Pulse style sheet for pulse programmers." + + +def default_style() -> Dict[str, Any]: + """Define default values of the pulse stylesheet.""" + return { + "formatter.general.fig_width": 13, + "formatter.general.fig_chart_height": 1.5, + "formatter.general.vertical_resolution": 1e-6, + "formatter.general.max_scale": 100, + "formatter.color.waveforms": { + "W": ["#648fff", "#002999"], + "D": ["#648fff", "#002999"], + "U": ["#ffb000", "#994A00"], + "M": ["#dc267f", "#760019"], + "A": ["#dc267f", "#760019"], + }, + "formatter.color.baseline": "#000000", + "formatter.color.barrier": "#222222", + "formatter.color.background": "#f2f3f4", + "formatter.color.fig_title": "#000000", + "formatter.color.annotate": "#222222", + "formatter.color.frame_change": "#000000", + "formatter.color.snapshot": "#000000", + "formatter.color.axis_label": "#000000", + "formatter.color.opaque_shape": ["#f2f3f4", "#000000"], + "formatter.alpha.fill_waveform": 0.3, + "formatter.alpha.baseline": 1.0, + "formatter.alpha.barrier": 0.7, + "formatter.alpha.opaque_shape": 0.7, + "formatter.layer.fill_waveform": 2, + "formatter.layer.baseline": 1, + "formatter.layer.barrier": 1, + "formatter.layer.annotate": 5, + "formatter.layer.axis_label": 5, + "formatter.layer.frame_change": 4, + "formatter.layer.snapshot": 3, + "formatter.layer.fig_title": 6, + "formatter.margin.top": 0.5, + "formatter.margin.bottom": 0.5, + "formatter.margin.left_percent": 0.05, + "formatter.margin.right_percent": 0.05, + "formatter.margin.between_channel": 0.5, + "formatter.label_offset.pulse_name": 0.3, + "formatter.label_offset.chart_info": 0.3, + "formatter.label_offset.frame_change": 0.3, + "formatter.label_offset.snapshot": 0.3, + "formatter.text_size.axis_label": 15, + "formatter.text_size.annotate": 12, + "formatter.text_size.frame_change": 20, + "formatter.text_size.snapshot": 20, + "formatter.text_size.fig_title": 15, + "formatter.text_size.axis_break_symbol": 15, + "formatter.line_width.fill_waveform": 0, + "formatter.line_width.axis_break": 6, + "formatter.line_width.baseline": 1, + "formatter.line_width.barrier": 1, + "formatter.line_width.opaque_shape": 1, + "formatter.line_style.fill_waveform": "-", + "formatter.line_style.baseline": "-", + "formatter.line_style.barrier": ":", + "formatter.line_style.opaque_shape": "--", + "formatter.channel_scaling.drive": 1.0, + "formatter.channel_scaling.control": 1.0, + "formatter.channel_scaling.measure": 1.0, + "formatter.channel_scaling.acquire": 1.0, + "formatter.channel_scaling.pos_spacing": 0.1, + "formatter.channel_scaling.neg_spacing": -0.1, + "formatter.box_width.opaque_shape": 150, + "formatter.box_height.opaque_shape": 0.5, + "formatter.axis_break.length": 3000, + "formatter.axis_break.max_length": 1000, + "formatter.control.fill_waveform": True, + "formatter.control.apply_phase_modulation": True, + "formatter.control.show_snapshot_channel": True, + "formatter.control.show_acquire_channel": True, + "formatter.control.show_empty_channel": True, + "formatter.control.auto_chart_scaling": True, + "formatter.control.axis_break": True, + "formatter.unicode_symbol.frame_change": "\u21BA", + "formatter.unicode_symbol.snapshot": "\u21AF", + "formatter.unicode_symbol.phase_parameter": "\u03b8", + "formatter.unicode_symbol.freq_parameter": "f", + "formatter.latex_symbol.frame_change": r"\circlearrowleft", + "formatter.latex_symbol.snapshot": "", + "formatter.latex_symbol.phase_parameter": r"\theta", + "formatter.latex_symbol.freq_parameter": "f", + "generator.waveform": [], + "generator.frame": [], + "generator.chart": [], + "generator.snapshot": [], + "generator.barrier": [], + "layout.chart_channel_map": None, + "layout.time_axis_map": None, + "layout.figure_title": None, + } diff --git a/qiskit_dynamics/pulse/visualization/types.py b/qiskit_dynamics/pulse/visualization/types.py new file mode 100644 index 000000000..44cf52615 --- /dev/null +++ b/qiskit_dynamics/pulse/visualization/types.py @@ -0,0 +1,242 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +# pylint: disable=invalid-name + +""" +Special data types. +""" +from __future__ import annotations + +from enum import Enum +from typing import NamedTuple, Union, Optional, NewType, Any, List + +import numpy as np +from qiskit import pulse + + +class PhaseFreqTuple(NamedTuple): + phase: float + freq: float + + +PhaseFreqTuple.__doc__ = "Data to represent a set of frequency and phase values." +PhaseFreqTuple.phase.__doc__ = "Phase value in rad." +PhaseFreqTuple.freq.__doc__ = "Frequency value in Hz." + + +PulseInstruction = NamedTuple( + "InstructionTuple", + [ + ("t0", int), + ("dt", Union[float, None]), + ("frame", PhaseFreqTuple), + ("inst", Union[pulse.Instruction, List[pulse.Instruction]]), + ("is_opaque", bool), + ], +) +PulseInstruction.__doc__ = "Data to represent pulse instruction for visualization." +PulseInstruction.t0.__doc__ = "A time when the instruction is issued." +PulseInstruction.dt.__doc__ = "System cycle time." +PulseInstruction.frame.__doc__ = "A reference frame to run instruction." +PulseInstruction.inst.__doc__ = "Pulse instruction." +PulseInstruction.is_opaque.__doc__ = "If there is any unbound parameters." + + +BarrierInstruction = NamedTuple( + "Barrier", [("t0", int), ("dt", Optional[float]), ("channels", List[pulse.channels.Channel])] +) +BarrierInstruction.__doc__ = "Data to represent special pulse instruction of barrier." +BarrierInstruction.t0.__doc__ = "A time when the instruction is issued." +BarrierInstruction.dt.__doc__ = "System cycle time." +BarrierInstruction.channels.__doc__ = "A list of channel associated with this barrier." + + +SnapshotInstruction = NamedTuple( + "Snapshots", [("t0", int), ("dt", Optional[float]), ("inst", pulse.instructions.Snapshot)] +) +SnapshotInstruction.__doc__ = "Data to represent special pulse instruction of snapshot." +SnapshotInstruction.t0.__doc__ = "A time when the instruction is issued." +SnapshotInstruction.dt.__doc__ = "System cycle time." +SnapshotInstruction.inst.__doc__ = "Snapshot instruction." + + +class ChartAxis(NamedTuple): + name: str + channels: list[pulse.channels.Channel] + + +ChartAxis.__doc__ = "Data to represent an axis information of chart." +ChartAxis.name.__doc__ = "Name of chart." +ChartAxis.channels.__doc__ = "Channels associated with chart." + + +class ParsedInstruction(NamedTuple): + xvals: np.ndarray + yvals: np.ndarray + meta: dict[str, Any] + + +ParsedInstruction.__doc__ = "Data to represent a parsed pulse instruction for object generation." +ParsedInstruction.xvals.__doc__ = "Numpy array of x axis data." +ParsedInstruction.yvals.__doc__ = "Numpy array of y axis data." +ParsedInstruction.meta.__doc__ = "Dictionary containing instruction details." + + +class OpaqueShape(NamedTuple): + duration: np.ndarray + meta: dict[str, Any] + + +OpaqueShape.__doc__ = "Data to represent a pulse instruction with parameterized shape." +OpaqueShape.duration.__doc__ = "Duration of instruction." +OpaqueShape.meta.__doc__ = "Dictionary containing instruction details." + + +class HorizontalAxis(NamedTuple): + window: tuple[int, int] + axis_map: dict[float, float | str] + axis_break_pos: list[int] + label: str + + +HorizontalAxis.__doc__ = "Data to represent configuration of horizontal axis." +HorizontalAxis.window.__doc__ = "Left and right edge of graph." +HorizontalAxis.axis_map.__doc__ = "Mapping of apparent coordinate system and actual location." +HorizontalAxis.axis_break_pos.__doc__ = "Locations of axis break." +HorizontalAxis.label.__doc__ = "Label of horizontal axis." + + +class WaveformType(str, Enum): + """ + Waveform data type. + + REAL: Assigned to objects that represent real part of waveform. + IMAG: Assigned to objects that represent imaginary part of waveform. + OPAQUE: Assigned to objects that represent waveform with unbound parameters. + """ + + REAL = "Waveform.Real" + IMAG = "Waveform.Imag" + OPAQUE = "Waveform.Opaque" + + +class LabelType(str, Enum): + """ + Label data type. + + PULSE_NAME: Assigned to objects that represent name of waveform. + PULSE_INFO: Assigned to objects that represent extra info about waveform. + OPAQUE_BOXTEXT: Assigned to objects that represent box text of opaque shapes. + CH_NAME: Assigned to objects that represent name of channel. + CH_SCALE: Assigned to objects that represent scaling factor of channel. + FRAME: Assigned to objects that represent value of frame. + SNAPSHOT: Assigned to objects that represent label of snapshot. + """ + + PULSE_NAME = "Label.Pulse.Name" + PULSE_INFO = "Label.Pulse.Info" + OPAQUE_BOXTEXT = "Label.Opaque.Boxtext" + CH_NAME = "Label.Channel.Name" + CH_INFO = "Label.Channel.Info" + FRAME = "Label.Frame.Value" + SNAPSHOT = "Label.Snapshot" + + +class SymbolType(str, Enum): + """ + Symbol data type. + + FRAME: Assigned to objects that represent symbol of frame. + SNAPSHOT: Assigned to objects that represent symbol of snapshot. + """ + + FRAME = "Symbol.Frame" + SNAPSHOT = "Symbol.Snapshot" + + +class LineType(str, Enum): + """ + Line data type. + + BASELINE: Assigned to objects that represent zero line of channel. + BARRIER: Assigned to objects that represent barrier line. + """ + + BASELINE = "Line.Baseline" + BARRIER = "Line.Barrier" + + +class AbstractCoordinate(str, Enum): + """Abstract coordinate that the exact value depends on the user preference. + + RIGHT: The horizontal coordinate at t0 shifted by the left margin. + LEFT: The horizontal coordinate at tf shifted by the right margin. + TOP: The vertical coordinate at the top of chart. + BOTTOM: The vertical coordinate at the bottom of chart. + """ + + RIGHT = "RIGHT" + LEFT = "LEFT" + TOP = "TOP" + BOTTOM = "BOTTOM" + + +class DynamicString(str, Enum): + """The string which is dynamically updated at the time of drawing. + + SCALE: A temporal value of chart scaling factor. + """ + + SCALE = "@scale" + + +class WaveformChannel(pulse.channels.PulseChannel): + """Dummy channel that doesn't belong to specific pulse channel.""" + + prefix = "w" + + def __init__(self): + """Create new waveform channel.""" + super().__init__(0) + + +class Plotter(str, Enum): + """Name of pulse plotter APIs. + + Mpl2D: Matplotlib plotter interface. Show charts in 2D canvas. + """ + + Mpl2D = "mpl2d" + + +class TimeUnits(str, Enum): + """Representation of time units. + + SYSTEM_CYCLE_TIME: System time dt. + NANO_SEC: Nano seconds. + """ + + CYCLES = "dt" + NS = "ns" + + +# convenient type to represent union of drawing data +# TODO: https://github.com/Qiskit/qiskit-terra/issues/9591 +# NewType means that a value of type Original cannot be used in places +# where a value of type Derived is expected +# (see https://docs.python.org/3/library/typing.html#newtype) +# This breaks a lot of type checking. +DataTypes = NewType("DataType", Union[WaveformType, LabelType, LineType, SymbolType]) + +# convenient type to represent union of values to represent a coordinate +Coordinate = NewType("Coordinate", Union[float, AbstractCoordinate]) diff --git a/qiskit_dynamics/scheduler/__init__.py b/qiskit_dynamics/scheduler/__init__.py new file mode 100644 index 000000000..a99faac95 --- /dev/null +++ b/qiskit_dynamics/scheduler/__init__.py @@ -0,0 +1,40 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +""" +=========================================== +Circuit Scheduler (:mod:`qiskit.scheduler`) +=========================================== + +.. currentmodule:: qiskit.scheduler + +A circuit scheduler compiles a circuit program to a pulse program. + +Core API +======== + +.. autoclass:: ScheduleConfig + +.. currentmodule:: qiskit.scheduler.schedule_circuit +.. autofunction:: schedule_circuit +.. currentmodule:: qiskit.scheduler + +Pulse scheduling methods +======================== + +.. currentmodule:: qiskit.scheduler.methods +.. autofunction:: as_soon_as_possible +.. autofunction:: as_late_as_possible +.. currentmodule:: qiskit.scheduler +""" +from qiskit_dynamics.scheduler.schedule_circuit import schedule, schedule_circuit +from qiskit_dynamics.scheduler.config import ScheduleConfig diff --git a/qiskit_dynamics/scheduler/config.py b/qiskit_dynamics/scheduler/config.py new file mode 100644 index 000000000..0a87d3d05 --- /dev/null +++ b/qiskit_dynamics/scheduler/config.py @@ -0,0 +1,35 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +"""Scheduling container classes.""" + +from typing import List + +from qiskit_dynamics.pulse.instruction_schedule_map import InstructionScheduleMap +from qiskit_dynamics.pulse.utils import format_meas_map + + +class ScheduleConfig: + """Configuration for pulse scheduling.""" + + def __init__(self, inst_map: InstructionScheduleMap, meas_map: List[List[int]], dt: float): + """ + Container for information needed to schedule a QuantumCircuit into a pulse Schedule. + + Args: + inst_map: The schedule definition of all gates supported on a backend. + meas_map: A list of groups of qubits which have to be measured together. + dt: Sample duration. + """ + self.inst_map = inst_map + self.meas_map = format_meas_map(meas_map) + self.dt = dt diff --git a/qiskit_dynamics/scheduler/lowering.py b/qiskit_dynamics/scheduler/lowering.py new file mode 100644 index 000000000..90fe71867 --- /dev/null +++ b/qiskit_dynamics/scheduler/lowering.py @@ -0,0 +1,187 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2020. +# +# 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. + +"""Lower gates to schedules. The relative timing within gates is respected. This +module handles the translation, but does not handle timing. +""" +from collections import namedtuple +from typing import Dict, List, Optional, Union + +from qiskit.circuit.barrier import Barrier +from qiskit.circuit.delay import Delay +from qiskit.circuit.duration import convert_durations_to_dt +from qiskit.circuit.measure import Measure +from qiskit.circuit.quantumcircuit import QuantumCircuit +from qiskit.exceptions import QiskitError +from qiskit_dynamics.pulse import Schedule +from qiskit_dynamics.pulse import instructions as pulse_inst +from qiskit_dynamics.pulse.channels import AcquireChannel, MemorySlot, DriveChannel +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.macros import measure +from qiskit.scheduler.config import ScheduleConfig +from qiskit.providers import BackendV1, BackendV2 + +CircuitPulseDef = namedtuple( + "CircuitPulseDef", + ["schedule", "qubits"], # The schedule which implements the quantum circuit command +) # The labels of the qubits involved in the command according to the circuit + + +def lower_gates( + circuit: QuantumCircuit, + schedule_config: ScheduleConfig, + backend: Optional[Union[BackendV1, BackendV2]] = None, +) -> List[CircuitPulseDef]: + """ + Return a list of Schedules and the qubits they operate on, for each element encountered in the + input circuit. + + Without concern for the final schedule, extract and return a list of Schedules and the qubits + they operate on, for each element encountered in the input circuit. Measures are grouped when + possible, so ``qc.measure(q0, c0)`` or ``qc.measure(q1, c1)`` will generate a synchronous + measurement pulse. + + Args: + circuit: The quantum circuit to translate. + schedule_config: Backend specific parameters used for building the Schedule. + backend: Pass in the backend used to build the Schedule, the backend could be BackendV1 + or BackendV2 + + Returns: + A list of CircuitPulseDefs: the pulse definition for each circuit element. + + Raises: + QiskitError: If circuit uses a command that isn't defined in config.inst_map. + """ + from qiskit_dynamics.pulse.transforms.base_transforms import target_qobj_transform + + circ_pulse_defs = [] + + inst_map = schedule_config.inst_map + qubit_mem_slots = {} # Map measured qubit index to classical bit index + + # convert the unit of durations from SI to dt before lowering + circuit = convert_durations_to_dt(circuit, dt_in_sec=schedule_config.dt, inplace=False) + + def get_measure_schedule(qubit_mem_slots: Dict[int, int]) -> CircuitPulseDef: + """Create a schedule to measure the qubits queued for measuring.""" + sched = Schedule() + # Exclude acquisition on these qubits, since they are handled by the user calibrations + acquire_excludes = {} + if Measure().name in circuit.calibrations.keys(): + qubits = tuple(sorted(qubit_mem_slots.keys())) + params = () + for qubit in qubits: + try: + meas_q = circuit.calibrations[Measure().name][((qubit,), params)] + meas_q = target_qobj_transform(meas_q) + acquire_q = meas_q.filter(channels=[AcquireChannel(qubit)]) + mem_slot_index = [ + chan.index for chan in acquire_q.channels if isinstance(chan, MemorySlot) + ][0] + if mem_slot_index != qubit_mem_slots[qubit]: + raise KeyError( + "The measurement calibration is not defined on " + "the requested classical bits" + ) + sched |= meas_q + del qubit_mem_slots[qubit] + acquire_excludes[qubit] = mem_slot_index + except KeyError: + pass + + if qubit_mem_slots: + qubits = list(qubit_mem_slots.keys()) + qubit_mem_slots.update(acquire_excludes) + meas_sched = measure( + qubits=qubits, + backend=backend, + inst_map=inst_map, + meas_map=schedule_config.meas_map, + qubit_mem_slots=qubit_mem_slots, + ) + meas_sched = target_qobj_transform(meas_sched) + meas_sched = meas_sched.exclude( + channels=[AcquireChannel(qubit) for qubit in acquire_excludes] + ) + sched |= meas_sched + qubit_mem_slots.clear() + return CircuitPulseDef( + schedule=sched, + qubits=[chan.index for chan in sched.channels if isinstance(chan, AcquireChannel)], + ) + + qubit_indices = {bit: idx for idx, bit in enumerate(circuit.qubits)} + clbit_indices = {bit: idx for idx, bit in enumerate(circuit.clbits)} + + for instruction in circuit.data: + inst_qubits = [qubit_indices[qubit] for qubit in instruction.qubits] + + if any(q in qubit_mem_slots for q in inst_qubits): + # If we are operating on a qubit that was scheduled to be measured, process that first + circ_pulse_defs.append(get_measure_schedule(qubit_mem_slots)) + + if isinstance(instruction.operation, Barrier): + circ_pulse_defs.append( + CircuitPulseDef(schedule=instruction.operation, qubits=inst_qubits) + ) + elif isinstance(instruction.operation, Delay): + sched = Schedule(name=instruction.operation.name) + for qubit in inst_qubits: + for channel in [DriveChannel]: + sched += pulse_inst.Delay( + duration=instruction.operation.duration, channel=channel(qubit) + ) + circ_pulse_defs.append(CircuitPulseDef(schedule=sched, qubits=inst_qubits)) + elif isinstance(instruction.operation, Measure): + if len(inst_qubits) != 1 and len(instruction.clbits) != 1: + raise QiskitError( + f"Qubit '{inst_qubits}' or classical bit '{instruction.clbits}' errored because the " + "circuit Measure instruction only takes one of " + "each." + ) + qubit_mem_slots[inst_qubits[0]] = clbit_indices[instruction.clbits[0]] + else: + try: + gate_cals = circuit.calibrations[instruction.operation.name] + schedule = gate_cals[ + ( + tuple(inst_qubits), + tuple( + p if getattr(p, "parameters", None) else float(p) + for p in instruction.operation.params + ), + ) + ] + schedule = target_qobj_transform(schedule) + circ_pulse_defs.append(CircuitPulseDef(schedule=schedule, qubits=inst_qubits)) + continue + except KeyError: + pass # Calibration not defined for this operation + + try: + schedule = inst_map.get( + instruction.operation, inst_qubits, *instruction.operation.params + ) + schedule = target_qobj_transform(schedule) + circ_pulse_defs.append(CircuitPulseDef(schedule=schedule, qubits=inst_qubits)) + except PulseError as ex: + raise QiskitError( + f"Operation '{instruction.operation.name}' on qubit(s) {inst_qubits} " + "not supported by the backend command definition. Did you remember to " + "transpile your input circuit for the same backend?" + ) from ex + + if qubit_mem_slots: + circ_pulse_defs.append(get_measure_schedule(qubit_mem_slots)) + + return circ_pulse_defs diff --git a/qiskit_dynamics/scheduler/methods/__init__.py b/qiskit_dynamics/scheduler/methods/__init__.py new file mode 100644 index 000000000..aa31cbc4e --- /dev/null +++ b/qiskit_dynamics/scheduler/methods/__init__.py @@ -0,0 +1,15 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +"""Scheduling methods.""" + +from qiskit_dynamics.scheduler.methods.basic import as_soon_as_possible, as_late_as_possible diff --git a/qiskit_dynamics/scheduler/methods/basic.py b/qiskit_dynamics/scheduler/methods/basic.py new file mode 100644 index 000000000..0c20d3fb2 --- /dev/null +++ b/qiskit_dynamics/scheduler/methods/basic.py @@ -0,0 +1,137 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +""" +The most straightforward scheduling methods: scheduling **as early** or **as late** as possible. +""" +from collections import defaultdict +from typing import List, Optional, Union + +from qiskit.circuit.quantumcircuit import QuantumCircuit +from qiskit.circuit.barrier import Barrier +from qiskit_dynamics.pulse.schedule import Schedule + +from qiskit_dynamics.scheduler.config import ScheduleConfig +from qiskit_dynamics.scheduler.lowering import lower_gates +from qiskit.providers import BackendV1, BackendV2 + + +def as_soon_as_possible( + circuit: QuantumCircuit, + schedule_config: ScheduleConfig, + backend: Optional[Union[BackendV1, BackendV2]] = None, +) -> Schedule: + """ + Return the pulse Schedule which implements the input circuit using an "as soon as possible" + (asap) scheduling policy. + + Circuit instructions are first each mapped to equivalent pulse + Schedules according to the command definition given by the schedule_config. Then, this circuit + instruction-equivalent Schedule is appended at the earliest time at which all qubits involved + in the instruction are available. + + Args: + circuit: The quantum circuit to translate. + schedule_config: Backend specific parameters used for building the Schedule. + backend: A backend used to build the Schedule, the backend could be BackendV1 + or BackendV2. + + Returns: + A schedule corresponding to the input ``circuit`` with pulses occurring as early as + possible. + """ + qubit_time_available = defaultdict(int) + + def update_times(inst_qubits: List[int], time: int = 0) -> None: + """Update the time tracker for all inst_qubits to the given time.""" + for q in inst_qubits: + qubit_time_available[q] = time + + start_times = [] + circ_pulse_defs = lower_gates(circuit, schedule_config, backend) + for circ_pulse_def in circ_pulse_defs: + start_time = max(qubit_time_available[q] for q in circ_pulse_def.qubits) + stop_time = start_time + if not isinstance(circ_pulse_def.schedule, Barrier): + stop_time += circ_pulse_def.schedule.duration + + start_times.append(start_time) + update_times(circ_pulse_def.qubits, stop_time) + + timed_schedules = [ + (time, cpd.schedule) + for time, cpd in zip(start_times, circ_pulse_defs) + if not isinstance(cpd.schedule, Barrier) + ] + schedule = Schedule.initialize_from(circuit) + for time, inst in timed_schedules: + schedule.insert(time, inst, inplace=True) + return schedule + + +def as_late_as_possible( + circuit: QuantumCircuit, + schedule_config: ScheduleConfig, + backend: Optional[Union[BackendV1, BackendV2]] = None, +) -> Schedule: + """ + Return the pulse Schedule which implements the input circuit using an "as late as possible" + (alap) scheduling policy. + + Circuit instructions are first each mapped to equivalent pulse + Schedules according to the command definition given by the schedule_config. Then, this circuit + instruction-equivalent Schedule is appended at the latest time that it can be without allowing + unnecessary time between instructions or allowing instructions with common qubits to overlap. + + This method should improves the outcome fidelity over ASAP scheduling, because we may + maximize the time that the qubit remains in the ground state. + + Args: + circuit: The quantum circuit to translate. + schedule_config: Backend specific parameters used for building the Schedule. + backend: A backend used to build the Schedule, the backend could be BackendV1 + or BackendV2. + + Returns: + A schedule corresponding to the input ``circuit`` with pulses occurring as late as + possible. + """ + qubit_time_available = defaultdict(int) + + def update_times(inst_qubits: List[int], time: int = 0) -> None: + """Update the time tracker for all inst_qubits to the given time.""" + for q in inst_qubits: + qubit_time_available[q] = time + + rev_stop_times = [] + circ_pulse_defs = lower_gates(circuit, schedule_config, backend) + for circ_pulse_def in reversed(circ_pulse_defs): + start_time = max(qubit_time_available[q] for q in circ_pulse_def.qubits) + stop_time = start_time + if not isinstance(circ_pulse_def.schedule, Barrier): + stop_time += circ_pulse_def.schedule.duration + + rev_stop_times.append(stop_time) + update_times(circ_pulse_def.qubits, stop_time) + + last_stop = max(t for t in qubit_time_available.values()) if qubit_time_available else 0 + start_times = [last_stop - t for t in reversed(rev_stop_times)] + + timed_schedules = [ + (time, cpd.schedule) + for time, cpd in zip(start_times, circ_pulse_defs) + if not isinstance(cpd.schedule, Barrier) + ] + schedule = Schedule.initialize_from(circuit) + for time, inst in timed_schedules: + schedule.insert(time, inst, inplace=True) + return schedule diff --git a/qiskit_dynamics/scheduler/schedule_circuit.py b/qiskit_dynamics/scheduler/schedule_circuit.py new file mode 100644 index 000000000..6913c10a5 --- /dev/null +++ b/qiskit_dynamics/scheduler/schedule_circuit.py @@ -0,0 +1,149 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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. + +"""QuantumCircuit to Pulse scheduler.""" +import logging +from typing import Optional, Union, List +from time import time +from qiskit.circuit.quantumcircuit import QuantumCircuit +from qiskit.exceptions import QiskitError +from qiskit.utils import parallel_map + +from qiskit_dynamics.pulse import InstructionScheduleMap +from qiskit_dynamics.pulse.schedule import Schedule +from qiskit_dynamics.scheduler.config import ScheduleConfig +from qiskit_dynamics.scheduler.methods import as_soon_as_possible, as_late_as_possible +from qiskit.providers import BackendV1, BackendV2, Backend + +logger = logging.getLogger(__name__) + + +def _log_schedule_time(start_time, end_time): + log_msg = f"Total Scheduling Time - {((end_time - start_time) * 1000):.5f} (ms)" + logger.info(log_msg) + + +def schedule( + circuits: Union[QuantumCircuit, List[QuantumCircuit]], + backend: Optional[Backend] = None, + inst_map: Optional[InstructionScheduleMap] = None, + meas_map: Optional[List[List[int]]] = None, + dt: Optional[float] = None, + method: Optional[Union[str, List[str]]] = None, +) -> Union[Schedule, List[Schedule]]: + """ + Schedule a circuit to a pulse ``Schedule``, using the backend, according to any specified + methods. Supported methods are documented in :py:mod:`qiskit.scheduler.schedule_circuit`. + + Args: + circuits: The quantum circuit or circuits to translate + backend: A backend instance, which contains hardware-specific data required for scheduling + inst_map: Mapping of circuit operations to pulse schedules. If ``None``, defaults to the + ``backend``\'s ``instruction_schedule_map`` + meas_map: List of sets of qubits that must be measured together. If ``None``, defaults to + the ``backend``\'s ``meas_map`` + dt: The output sample rate of backend control electronics. For scheduled circuits + which contain time information, dt is required. If not provided, it will be + obtained from the backend configuration + method: Optionally specify a particular scheduling method + + Returns: + A pulse ``Schedule`` that implements the input circuit + + Raises: + QiskitError: If ``inst_map`` and ``meas_map`` are not passed and ``backend`` is not passed + """ + arg_circuits_list = isinstance(circuits, list) + start_time = time() + if backend and getattr(backend, "version", 0) > 1: + if inst_map is None: + inst_map = backend.instruction_schedule_map + if meas_map is None: + meas_map = backend.meas_map + if dt is None: + dt = backend.dt + else: + if inst_map is None: + if backend is None: + raise QiskitError( + "Must supply either a backend or InstructionScheduleMap for scheduling passes." + ) + defaults = backend.defaults() + if defaults is None: + raise QiskitError( + "The backend defaults are unavailable. The backend may not support pulse." + ) + inst_map = defaults.instruction_schedule_map + if meas_map is None: + if backend is None: + raise QiskitError( + "Must supply either a backend or a meas_map for scheduling passes." + ) + meas_map = backend.configuration().meas_map + if dt is None: + if backend is not None: + dt = backend.configuration().dt + + schedule_config = ScheduleConfig(inst_map=inst_map, meas_map=meas_map, dt=dt) + circuits = circuits if isinstance(circuits, list) else [circuits] + schedules = parallel_map(schedule_circuit, circuits, (schedule_config, method, backend)) + end_time = time() + _log_schedule_time(start_time, end_time) + if arg_circuits_list: + return schedules + else: + return schedules[0] + + +def schedule_circuit( + circuit: QuantumCircuit, + schedule_config: ScheduleConfig, + method: Optional[str] = None, + backend: Optional[Union[BackendV1, BackendV2]] = None, +) -> Schedule: + """ + Basic scheduling pass from a circuit to a pulse Schedule, using the backend. If no method is + specified, then a basic, as late as possible scheduling pass is performed, i.e. pulses are + scheduled to occur as late as possible. + + Supported methods: + + * ``'as_soon_as_possible'``: Schedule pulses greedily, as early as possible on a + qubit resource. (alias: ``'asap'``) + * ``'as_late_as_possible'``: Schedule pulses late-- keep qubits in the ground state when + possible. (alias: ``'alap'``) + + Args: + circuit: The quantum circuit to translate. + schedule_config: Backend specific parameters used for building the Schedule. + method: The scheduling pass method to use. + backend: A backend used to build the Schedule, the backend could be BackendV1 + or BackendV2. + + Returns: + Schedule corresponding to the input circuit. + + Raises: + QiskitError: If method isn't recognized. + """ + methods = { + "as_soon_as_possible": as_soon_as_possible, + "asap": as_soon_as_possible, + "as_late_as_possible": as_late_as_possible, + "alap": as_late_as_possible, + } + if method is None: + method = "as_late_as_possible" + try: + return methods[method](circuit, schedule_config, backend) + except KeyError as ex: + raise QiskitError(f"Scheduling method {method} isn't recognized.") from ex diff --git a/qiskit_dynamics/scheduler/sequence.py b/qiskit_dynamics/scheduler/sequence.py new file mode 100644 index 000000000..2c67fd416 --- /dev/null +++ b/qiskit_dynamics/scheduler/sequence.py @@ -0,0 +1,102 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +""" +Mapping a scheduled QuantumCircuit to a pulse Schedule. +""" +from collections import defaultdict + +from typing import Optional, Union +from qiskit.circuit.barrier import Barrier +from qiskit.circuit.measure import Measure +from qiskit.circuit.quantumcircuit import QuantumCircuit +from qiskit.exceptions import QiskitError +from qiskit_dynamics.pulse.schedule import Schedule +from qiskit_dynamics.pulse.transforms import pad +from qiskit_dynamics.scheduler.config import ScheduleConfig +from qiskit_dynamics.scheduler.lowering import lower_gates +from qiskit.providers import BackendV1, BackendV2 + + +def sequence( + scheduled_circuit: QuantumCircuit, + schedule_config: ScheduleConfig, + backend: Optional[Union[BackendV1, BackendV2]] = None, +) -> Schedule: + """ + Return the pulse Schedule which implements the input scheduled circuit. + + Assume all measurements are done at once at the last of the circuit. + Schedules according to the command definition given by the schedule_config. + + Args: + scheduled_circuit: The scheduled quantum circuit to translate. + schedule_config: Backend specific parameters used for building the Schedule. + backend: A backend used to build the Schedule, the backend could be BackendV1 + or BackendV2 + + Returns: + A schedule corresponding to the input ``circuit``. + + Raises: + QiskitError: If invalid scheduled circuit is supplied. + """ + circ_pulse_defs = lower_gates(scheduled_circuit, schedule_config, backend) + + # find the measurement start time (assume measurement once) + def _meas_start_time(): + _qubit_time_available = defaultdict(int) + for instruction in scheduled_circuit.data: + if isinstance(instruction.operation, Measure): + return _qubit_time_available[instruction.qubits[0]] + for q in instruction.qubits: + _qubit_time_available[q] += instruction.operation.duration + return None + + meas_time = _meas_start_time() + + # restore start times + qubit_time_available = {} + start_times = [] + out_circ_pulse_defs = [] + for circ_pulse_def in circ_pulse_defs: + active_qubits = [q for q in circ_pulse_def.qubits if q in qubit_time_available] + + start_time = max((qubit_time_available[q] for q in active_qubits), default=0) + + for q in active_qubits: + if qubit_time_available[q] != start_time: + # print(q, ":", qubit_time_available[q], "!=", start_time) + raise QiskitError("Invalid scheduled circuit.") + + stop_time = start_time + if not isinstance(circ_pulse_def.schedule, Barrier): + stop_time += circ_pulse_def.schedule.duration + + delay_overlaps_meas = False + for q in circ_pulse_def.qubits: + qubit_time_available[q] = stop_time + if ( + meas_time is not None + and circ_pulse_def.schedule.name == "delay" + and stop_time > meas_time + ): + qubit_time_available[q] = meas_time + delay_overlaps_meas = True + # skip to delays overlapping measures and barriers + if not delay_overlaps_meas and not isinstance(circ_pulse_def.schedule, Barrier): + start_times.append(start_time) + out_circ_pulse_defs.append(circ_pulse_def) + + timed_schedules = [(time, cpd.schedule) for time, cpd in zip(start_times, out_circ_pulse_defs)] + sched = Schedule(*timed_schedules, name=scheduled_circuit.name) + return pad(sched) diff --git a/releasenotes/notes/qiskit_pulse_migration-9955ec41f6dcf49b.yaml b/releasenotes/notes/qiskit_pulse_migration-9955ec41f6dcf49b.yaml new file mode 100644 index 000000000..346f541f1 --- /dev/null +++ b/releasenotes/notes/qiskit_pulse_migration-9955ec41f6dcf49b.yaml @@ -0,0 +1,16 @@ +--- +prelude: > + Replace this text with content to appear at the top of the section for this + release. All of the prelude content is merged together and then rendered + separately from the items listed in other parts of the file, so the text + needs to be worded so that both the prelude and the other items make sense + when read independently. This may mean repeating some details. Not every + release note requires a prelude. Usually only notes describing major + features or adding release theme details should have a prelude. +features: + - | + Qiskit Pulse has been migrated to Qiskit Dynamics following its deprecation in Qiskit 1.4.0. + This module can now be imported through the following import statement: + + ```python + from qiskit_dynamics import pulse diff --git a/setup.py b/setup.py index 984d38ef4..9603b0b03 100644 --- a/setup.py +++ b/setup.py @@ -22,22 +22,21 @@ "qiskit", "multiset>=3.0.1", "sympy>=1.12", - "arraylias" + "arraylias", ] -jax_extras = ['jax', 'jaxlib'] +jax_extras = ["jax", "jaxlib"] -PACKAGES = setuptools.find_packages(exclude=['test*']) +PACKAGES = setuptools.find_packages(exclude=["test*"]) version_path = os.path.abspath( - os.path.join(os.path.dirname(__file__), 'qiskit_dynamics', - 'VERSION.txt')) + os.path.join(os.path.dirname(__file__), "qiskit_dynamics", "VERSION.txt") +) -with open(version_path, 'r') as fd: +with open(version_path, "r") as fd: version = fd.read().rstrip() -README_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), - 'README.md') +README_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md") with open(README_PATH) as readme_file: README = readme_file.read() @@ -47,7 +46,7 @@ packages=PACKAGES, description="Qiskit ODE solver", long_description=README, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", url="https://github.com/Qiskit-Community/qiskit-dynamics", author="Qiskit Development Team", author_email="qiskit@us.ibm.com", @@ -75,8 +74,6 @@ install_requires=requirements, include_package_data=True, python_requires=">=3.10", - extras_require={ - "jax": jax_extras - }, - zip_safe=False + extras_require={"jax": jax_extras}, + zip_safe=False, ) diff --git a/test/dynamics/backend/test_dynamics_backend.py b/test/dynamics/backend/test_dynamics_backend.py index 51eb91b17..0955b05ce 100644 --- a/test/dynamics/backend/test_dynamics_backend.py +++ b/test/dynamics/backend/test_dynamics_backend.py @@ -23,7 +23,7 @@ from scipy.sparse import csr_matrix from scipy.linalg import expm -from qiskit import QiskitError, pulse, QuantumCircuit, QuantumRegister, ClassicalRegister +from qiskit import QiskitError, QuantumCircuit, QuantumRegister, ClassicalRegister from qiskit.circuit.library import XGate, UnitaryGate, Measure from qiskit.transpiler import Target, InstructionProperties from qiskit.quantum_info import Statevector, DensityMatrix, Operator, SuperOp @@ -31,7 +31,7 @@ from qiskit.providers.models.backendconfiguration import UchannelLO from qiskit.providers.backend import QubitProperties -from qiskit_dynamics import Solver, DynamicsBackend +from qiskit_dynamics import Solver, DynamicsBackend, pulse from qiskit_dynamics.backend import default_experiment_result_function from qiskit_dynamics.backend.dynamics_backend import ( _get_acquire_instruction_timings, diff --git a/test/dynamics/backend/test_qiskit_experiments.py b/test/dynamics/backend/test_qiskit_experiments.py index 60dcc4f49..091b80610 100644 --- a/test/dynamics/backend/test_qiskit_experiments.py +++ b/test/dynamics/backend/test_qiskit_experiments.py @@ -17,7 +17,7 @@ import numpy as np -from qiskit import pulse +from qiskit_dynamics import pulse from qiskit.circuit import Parameter from qiskit.circuit.library import XGate, SXGate from qiskit.transpiler import Target diff --git a/test/dynamics/pulse/legacy_cmaps.py b/test/dynamics/pulse/legacy_cmaps.py new file mode 100644 index 000000000..84f4be451 --- /dev/null +++ b/test/dynamics/pulse/legacy_cmaps.py @@ -0,0 +1,576 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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. + +"""Coupling maps for fake backend generation and transpiler testing.""" + +# 5 qubits +# bidirectional +BOGOTA_CMAP = [[0, 1], [1, 0], [1, 2], [2, 1], [2, 3], [3, 2], [3, 4], [4, 3]] +LIMA_CMAP = [[0, 1], [1, 0], [1, 2], [1, 3], [2, 1], [3, 1], [3, 4], [4, 3]] +YORKTOWN_CMAP = [ + [0, 1], + [0, 2], + [1, 0], + [1, 2], + [2, 0], + [2, 1], + [2, 3], + [2, 4], + [3, 2], + [3, 4], + [4, 2], + [4, 3], +] +# directional +TENERIFE_CMAP = [[1, 0], [2, 0], [2, 1], [3, 2], [3, 4], [4, 2]] + +# 7 qubits +LAGOS_CMAP = [ + [0, 1], + [1, 0], + [1, 2], + [1, 3], + [2, 1], + [3, 1], + [3, 5], + [4, 5], + [5, 3], + [5, 4], + [5, 6], + [6, 5], +] + +# 16 qubits +RUESCHLIKON_CMAP = [ + [1, 0], + [1, 2], + [2, 3], + [3, 4], + [3, 14], + [5, 4], + [6, 5], + [6, 7], + [6, 11], + [7, 10], + [8, 7], + [9, 8], + [9, 10], + [11, 10], + [12, 5], + [12, 11], + [12, 13], + [13, 4], + [13, 14], + [15, 0], + [15, 2], + [15, 14], +] + +MELBOURNE_CMAP = [ + [1, 0], + [1, 2], + [2, 3], + [4, 3], + [4, 10], + [5, 4], + [5, 6], + [5, 9], + [6, 8], + [7, 8], + [9, 8], + [9, 10], + [11, 3], + [11, 10], + [11, 12], + [12, 2], + [13, 1], + [13, 12], +] + +# 20 qubits +ALMADEN_CMAP = [ + [0, 1], + [1, 0], + [1, 2], + [1, 6], + [2, 1], + [2, 3], + [3, 2], + [3, 4], + [3, 8], + [4, 3], + [5, 6], + [5, 10], + [6, 1], + [6, 5], + [6, 7], + [7, 6], + [7, 8], + [7, 12], + [8, 3], + [8, 7], + [8, 9], + [9, 8], + [9, 14], + [10, 5], + [10, 11], + [11, 10], + [11, 12], + [11, 16], + [12, 7], + [12, 11], + [12, 13], + [13, 12], + [13, 14], + [13, 18], + [14, 9], + [14, 13], + [15, 16], + [16, 11], + [16, 15], + [16, 17], + [17, 16], + [17, 18], + [18, 13], + [18, 17], + [18, 19], + [19, 18], +] + +TOKYO_CMAP = [ + [0, 1], + [0, 5], + [1, 0], + [1, 2], + [1, 6], + [1, 7], + [2, 1], + [2, 6], + [3, 8], + [4, 8], + [4, 9], + [5, 0], + [5, 6], + [5, 10], + [5, 11], + [6, 1], + [6, 2], + [6, 5], + [6, 7], + [6, 10], + [6, 11], + [7, 1], + [7, 6], + [7, 8], + [7, 12], + [8, 3], + [8, 4], + [8, 7], + [8, 9], + [8, 12], + [8, 13], + [9, 4], + [9, 8], + [10, 5], + [10, 6], + [10, 11], + [10, 15], + [11, 5], + [11, 6], + [11, 10], + [11, 12], + [11, 16], + [11, 17], + [12, 7], + [12, 8], + [12, 11], + [12, 13], + [12, 16], + [13, 8], + [13, 12], + [13, 14], + [13, 18], + [13, 19], + [14, 13], + [14, 18], + [14, 19], + [15, 10], + [15, 16], + [16, 11], + [16, 12], + [16, 15], + [16, 17], + [17, 11], + [17, 16], + [17, 18], + [18, 13], + [18, 14], + [18, 17], + [19, 13], + [19, 14], +] + +# 27 qubits +MUMBAI_CMAP = [ + [0, 1], + [1, 0], + [1, 2], + [1, 4], + [2, 1], + [2, 3], + [3, 2], + [3, 5], + [4, 1], + [4, 7], + [5, 3], + [5, 8], + [6, 7], + [7, 4], + [7, 6], + [7, 10], + [8, 5], + [8, 9], + [8, 11], + [9, 8], + [10, 7], + [10, 12], + [11, 8], + [11, 14], + [12, 10], + [12, 13], + [12, 15], + [13, 12], + [13, 14], + [14, 11], + [14, 13], + [14, 16], + [15, 12], + [15, 18], + [16, 14], + [16, 19], + [17, 18], + [18, 15], + [18, 17], + [18, 21], + [19, 16], + [19, 20], + [19, 22], + [20, 19], + [21, 18], + [21, 23], + [22, 19], + [22, 25], + [23, 21], + [23, 24], + [24, 23], + [24, 25], + [25, 22], + [25, 24], + [25, 26], + [26, 25], +] + +# 65 qubits +MANHATTAN_CMAP = [ + [0, 1], + [0, 10], + [1, 0], + [1, 2], + [2, 1], + [2, 3], + [3, 2], + [3, 4], + [4, 3], + [4, 5], + [4, 11], + [5, 4], + [5, 6], + [6, 5], + [6, 7], + [7, 6], + [7, 8], + [8, 7], + [8, 9], + [8, 12], + [9, 8], + [10, 0], + [10, 13], + [11, 4], + [11, 17], + [12, 8], + [12, 21], + [13, 10], + [13, 14], + [14, 13], + [14, 15], + [15, 14], + [15, 16], + [15, 24], + [16, 15], + [16, 17], + [17, 11], + [17, 16], + [17, 18], + [18, 17], + [18, 19], + [19, 18], + [19, 20], + [19, 25], + [20, 19], + [20, 21], + [21, 12], + [21, 20], + [21, 22], + [22, 21], + [22, 23], + [23, 22], + [23, 26], + [24, 15], + [24, 29], + [25, 19], + [25, 33], + [26, 23], + [26, 37], + [27, 28], + [27, 38], + [28, 27], + [28, 29], + [29, 24], + [29, 28], + [29, 30], + [30, 29], + [30, 31], + [31, 30], + [31, 32], + [31, 39], + [32, 31], + [32, 33], + [33, 25], + [33, 32], + [33, 34], + [34, 33], + [34, 35], + [35, 34], + [35, 36], + [35, 40], + [36, 35], + [36, 37], + [37, 26], + [37, 36], + [38, 27], + [38, 41], + [39, 31], + [39, 45], + [40, 35], + [40, 49], + [41, 38], + [41, 42], + [42, 41], + [42, 43], + [43, 42], + [43, 44], + [43, 52], + [44, 43], + [44, 45], + [45, 39], + [45, 44], + [45, 46], + [46, 45], + [46, 47], + [47, 46], + [47, 48], + [47, 53], + [48, 47], + [48, 49], + [49, 40], + [49, 48], + [49, 50], + [50, 49], + [50, 51], + [51, 50], + [51, 54], + [52, 43], + [52, 56], + [53, 47], + [53, 60], + [54, 51], + [54, 64], + [55, 56], + [56, 52], + [56, 55], + [56, 57], + [57, 56], + [57, 58], + [58, 57], + [58, 59], + [59, 58], + [59, 60], + [60, 53], + [60, 59], + [60, 61], + [61, 60], + [61, 62], + [62, 61], + [62, 63], + [63, 62], + [63, 64], + [64, 54], + [64, 63], +] + +# 127 qubits +KYOTO_CMAP = [ + [0, 14], + [1, 0], + [1, 2], + [3, 2], + [4, 3], + [4, 5], + [6, 5], + [7, 6], + [8, 7], + [8, 9], + [8, 16], + [9, 10], + [11, 10], + [11, 12], + [12, 13], + [15, 4], + [16, 26], + [17, 12], + [17, 30], + [18, 14], + [18, 19], + [19, 20], + [21, 20], + [22, 15], + [22, 21], + [22, 23], + [23, 24], + [25, 24], + [25, 26], + [27, 26], + [27, 28], + [28, 29], + [28, 35], + [30, 29], + [30, 31], + [31, 32], + [32, 36], + [33, 20], + [33, 39], + [34, 24], + [34, 43], + [37, 38], + [38, 39], + [39, 40], + [40, 41], + [42, 41], + [43, 42], + [44, 43], + [44, 45], + [46, 45], + [47, 35], + [47, 46], + [48, 47], + [49, 48], + [49, 55], + [50, 49], + [50, 51], + [51, 36], + [52, 37], + [53, 41], + [53, 60], + [54, 45], + [54, 64], + [55, 68], + [56, 52], + [57, 56], + [57, 58], + [59, 58], + [59, 60], + [61, 60], + [62, 61], + [62, 63], + [63, 64], + [64, 65], + [65, 66], + [67, 66], + [67, 68], + [68, 69], + [70, 69], + [71, 58], + [72, 62], + [73, 66], + [73, 85], + [74, 70], + [75, 90], + [76, 75], + [76, 77], + [77, 71], + [77, 78], + [79, 78], + [79, 91], + [80, 79], + [81, 72], + [81, 80], + [82, 81], + [82, 83], + [83, 92], + [84, 83], + [84, 85], + [86, 85], + [87, 86], + [87, 93], + [88, 87], + [89, 74], + [89, 88], + [93, 106], + [94, 90], + [94, 95], + [96, 95], + [96, 97], + [96, 109], + [97, 98], + [98, 91], + [98, 99], + [99, 100], + [101, 100], + [101, 102], + [102, 92], + [102, 103], + [104, 103], + [104, 111], + [105, 104], + [105, 106], + [106, 107], + [107, 108], + [109, 114], + [110, 100], + [112, 108], + [113, 114], + [115, 114], + [116, 115], + [117, 116], + [118, 110], + [118, 117], + [119, 118], + [120, 119], + [121, 120], + [122, 111], + [122, 121], + [122, 123], + [124, 123], + [125, 124], + [125, 126], + [126, 112], +] diff --git a/test/dynamics/pulse/test_block.py b/test/dynamics/pulse/test_block.py new file mode 100644 index 000000000..ed29e3603 --- /dev/null +++ b/test/dynamics/pulse/test_block.py @@ -0,0 +1,942 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. + +# pylint: disable=invalid-name + +"""Test cases for the pulse schedule block.""" +import re +from typing import List, Any +from qiskit_dynamics import pulse +from qiskit import circuit +from qiskit_dynamics.pulse import transforms +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit.providers.fake_provider import FakeOpenPulse2Q +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class BaseTestBlock(QiskitTestCase): + """ScheduleBlock tests.""" + + def setUp(self): + super().setUp() + + with self.assertWarns(DeprecationWarning): + self.backend = FakeOpenPulse2Q() + + self.test_waveform0 = pulse.Constant(100, 0.1) + self.test_waveform1 = pulse.Constant(200, 0.1) + + self.d0 = pulse.DriveChannel(0) + self.d1 = pulse.DriveChannel(1) + + self.left_context = transforms.AlignLeft() + self.right_context = transforms.AlignRight() + self.sequential_context = transforms.AlignSequential() + self.equispaced_context = transforms.AlignEquispaced(duration=1000) + + def _align_func(j): + return {1: 0.1, 2: 0.25, 3: 0.7, 4: 0.85}.get(j) + + self.func_context = transforms.AlignFunc(duration=1000, func=_align_func) + + def assertScheduleEqual(self, target, reference): + """Check if two block are equal schedule representation.""" + self.assertEqual(transforms.target_qobj_transform(target), reference) + + +class TestTransformation(BaseTestBlock): + """Test conversion of ScheduleBlock to Schedule.""" + + def test_left_alignment(self): + """Test left alignment context.""" + block = pulse.ScheduleBlock(alignment_context=self.left_context) + block = block.append(pulse.Play(self.test_waveform0, self.d0)) + block = block.append(pulse.Play(self.test_waveform1, self.d1)) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform1, self.d1)) + + self.assertScheduleEqual(block, ref_sched) + + def test_right_alignment(self): + """Test right alignment context.""" + block = pulse.ScheduleBlock(alignment_context=self.right_context) + block = block.append(pulse.Play(self.test_waveform0, self.d0)) + block = block.append(pulse.Play(self.test_waveform1, self.d1)) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(100, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform1, self.d1)) + + self.assertScheduleEqual(block, ref_sched) + + def test_sequential_alignment(self): + """Test sequential alignment context.""" + block = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block = block.append(pulse.Play(self.test_waveform0, self.d0)) + block = block.append(pulse.Play(self.test_waveform1, self.d1)) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(100, pulse.Play(self.test_waveform1, self.d1)) + + self.assertScheduleEqual(block, ref_sched) + + def test_equispace_alignment(self): + """Test equispace alignment context.""" + block = pulse.ScheduleBlock(alignment_context=self.equispaced_context) + for _ in range(4): + block = block.append(pulse.Play(self.test_waveform0, self.d0)) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(300, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(600, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(900, pulse.Play(self.test_waveform0, self.d0)) + + self.assertScheduleEqual(block, ref_sched) + + def test_func_alignment(self): + """Test func alignment context.""" + block = pulse.ScheduleBlock(alignment_context=self.func_context) + for _ in range(4): + block = block.append(pulse.Play(self.test_waveform0, self.d0)) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(50, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(200, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(650, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(800, pulse.Play(self.test_waveform0, self.d0)) + + self.assertScheduleEqual(block, ref_sched) + + def test_nested_alignment(self): + """Test nested block scheduling.""" + block_sub = pulse.ScheduleBlock(alignment_context=self.right_context) + block_sub = block_sub.append(pulse.Play(self.test_waveform0, self.d0)) + block_sub = block_sub.append(pulse.Play(self.test_waveform1, self.d1)) + + block_main = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block_main = block_main.append(block_sub) + block_main = block_main.append(pulse.Delay(10, self.d0)) + block_main = block_main.append(block_sub) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform1, self.d1)) + ref_sched = ref_sched.insert(100, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(200, pulse.Delay(10, self.d0)) + ref_sched = ref_sched.insert(210, pulse.Play(self.test_waveform1, self.d1)) + ref_sched = ref_sched.insert(310, pulse.Play(self.test_waveform0, self.d0)) + + self.assertScheduleEqual(block_main, ref_sched) + + +class TestBlockOperation(BaseTestBlock): + """Test fundamental operation on schedule block. + + Because ScheduleBlock adapts to the lazy scheduling, no uniitest for + overlap constraints is necessary. Test scheme becomes simpler than the schedule. + + Some tests have dependency on schedule conversion. + This operation should be tested in `test.python.pulse.test_block.TestTransformation`. + """ + + def setUp(self): + super().setUp() + + self.test_blocks = [ + pulse.Play(self.test_waveform0, self.d0), + pulse.Play(self.test_waveform1, self.d1), + pulse.Delay(50, self.d0), + pulse.Play(self.test_waveform1, self.d0), + ] + + def test_append_an_instruction_to_empty_block(self): + """Test append instructions to an empty block.""" + block = pulse.ScheduleBlock() + block = block.append(pulse.Play(self.test_waveform0, self.d0)) + + self.assertEqual(block.blocks[0], pulse.Play(self.test_waveform0, self.d0)) + + def test_append_an_instruction_to_empty_block_sugar(self): + """Test append instructions to an empty block with syntax sugar.""" + block = pulse.ScheduleBlock() + block += pulse.Play(self.test_waveform0, self.d0) + + self.assertEqual(block.blocks[0], pulse.Play(self.test_waveform0, self.d0)) + + def test_append_an_instruction_to_empty_block_inplace(self): + """Test append instructions to an empty block with inplace.""" + block = pulse.ScheduleBlock() + block.append(pulse.Play(self.test_waveform0, self.d0), inplace=True) + + self.assertEqual(block.blocks[0], pulse.Play(self.test_waveform0, self.d0)) + + def test_append_a_block_to_empty_block(self): + """Test append another ScheduleBlock to empty block.""" + block = pulse.ScheduleBlock() + block.append(pulse.Play(self.test_waveform0, self.d0), inplace=True) + + block_main = pulse.ScheduleBlock() + block_main = block_main.append(block) + + self.assertEqual(block_main.blocks[0], block) + + def test_append_an_instruction_to_block(self): + """Test append instructions to a non-empty block.""" + block = pulse.ScheduleBlock() + block = block.append(pulse.Delay(100, self.d0)) + + block = block.append(pulse.Delay(100, self.d0)) + + self.assertEqual(len(block.blocks), 2) + + def test_append_an_instruction_to_block_inplace(self): + """Test append instructions to a non-empty block with inplace.""" + block = pulse.ScheduleBlock() + block = block.append(pulse.Delay(100, self.d0)) + + block.append(pulse.Delay(100, self.d0), inplace=True) + + self.assertEqual(len(block.blocks), 2) + + def test_duration(self): + """Test if correct duration is returned with implicit scheduling.""" + block = pulse.ScheduleBlock() + for inst in self.test_blocks: + block.append(inst) + + self.assertEqual(block.duration, 350) + + def test_channels(self): + """Test if all channels are returned.""" + block = pulse.ScheduleBlock() + for inst in self.test_blocks: + block.append(inst) + + self.assertEqual(len(block.channels), 2) + + def test_instructions(self): + """Test if all instructions are returned.""" + block = pulse.ScheduleBlock() + for inst in self.test_blocks: + block.append(inst) + + self.assertEqual(block.blocks, tuple(self.test_blocks)) + + def test_channel_duraction(self): + """Test if correct durations is calculated for each channel.""" + block = pulse.ScheduleBlock() + for inst in self.test_blocks: + block.append(inst) + + self.assertEqual(block.ch_duration(self.d0), 350) + self.assertEqual(block.ch_duration(self.d1), 200) + + def test_cannot_append_schedule(self): + """Test schedule cannot be appended. Schedule should be input as Call instruction.""" + block = pulse.ScheduleBlock() + + sched = pulse.Schedule() + sched += pulse.Delay(10, self.d0) + + with self.assertRaises(PulseError): + block.append(sched) + + def test_replace(self): + """Test replacing specific instruction.""" + block = pulse.ScheduleBlock() + for inst in self.test_blocks: + block.append(inst) + + replaced = pulse.Play(pulse.Constant(300, 0.1), self.d1) + target = pulse.Delay(50, self.d0) + + block_replaced = block.replace(target, replaced, inplace=False) + + # original schedule is not destroyed + self.assertListEqual(list(block.blocks), self.test_blocks) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform1, self.d1)) + ref_sched = ref_sched.insert(200, replaced) + ref_sched = ref_sched.insert(100, pulse.Play(self.test_waveform1, self.d0)) + + self.assertScheduleEqual(block_replaced, ref_sched) + + def test_replace_inplace(self): + """Test replacing specific instruction with inplace.""" + block = pulse.ScheduleBlock() + for inst in self.test_blocks: + block.append(inst) + + replaced = pulse.Play(pulse.Constant(300, 0.1), self.d1) + target = pulse.Delay(50, self.d0) + + block.replace(target, replaced, inplace=True) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform0, self.d0)) + ref_sched = ref_sched.insert(0, pulse.Play(self.test_waveform1, self.d1)) + ref_sched = ref_sched.insert(200, replaced) + ref_sched = ref_sched.insert(100, pulse.Play(self.test_waveform1, self.d0)) + + self.assertScheduleEqual(block, ref_sched) + + def test_replace_block_by_instruction(self): + """Test replacing block with instruction.""" + sub_block1 = pulse.ScheduleBlock() + sub_block1 = sub_block1.append(pulse.Delay(50, self.d0)) + sub_block1 = sub_block1.append(pulse.Play(self.test_waveform0, self.d0)) + + sub_block2 = pulse.ScheduleBlock() + sub_block2 = sub_block2.append(pulse.Delay(50, self.d0)) + sub_block2 = sub_block2.append(pulse.Play(self.test_waveform1, self.d1)) + + main_block = pulse.ScheduleBlock() + main_block = main_block.append(pulse.Delay(50, self.d0)) + main_block = main_block.append(pulse.Play(self.test_waveform0, self.d0)) + main_block = main_block.append(sub_block1) + main_block = main_block.append(sub_block2) + main_block = main_block.append(pulse.Play(self.test_waveform0, self.d1)) + + replaced = main_block.replace(sub_block1, pulse.Delay(100, self.d0)) + + ref_blocks = [ + pulse.Delay(50, self.d0), + pulse.Play(self.test_waveform0, self.d0), + pulse.Delay(100, self.d0), + sub_block2, + pulse.Play(self.test_waveform0, self.d1), + ] + + self.assertListEqual(list(replaced.blocks), ref_blocks) + + def test_replace_instruction_by_block(self): + """Test replacing instruction with block.""" + sub_block1 = pulse.ScheduleBlock() + sub_block1 = sub_block1.append(pulse.Delay(50, self.d0)) + sub_block1 = sub_block1.append(pulse.Play(self.test_waveform0, self.d0)) + + sub_block2 = pulse.ScheduleBlock() + sub_block2 = sub_block2.append(pulse.Delay(50, self.d0)) + sub_block2 = sub_block2.append(pulse.Play(self.test_waveform1, self.d1)) + + main_block = pulse.ScheduleBlock() + main_block = main_block.append(pulse.Delay(50, self.d0)) + main_block = main_block.append(pulse.Play(self.test_waveform0, self.d0)) + main_block = main_block.append(pulse.Delay(100, self.d0)) + main_block = main_block.append(sub_block2) + main_block = main_block.append(pulse.Play(self.test_waveform0, self.d1)) + + replaced = main_block.replace(pulse.Delay(100, self.d0), sub_block1) + + ref_blocks = [ + pulse.Delay(50, self.d0), + pulse.Play(self.test_waveform0, self.d0), + sub_block1, + sub_block2, + pulse.Play(self.test_waveform0, self.d1), + ] + + self.assertListEqual(list(replaced.blocks), ref_blocks) + + def test_len(self): + """Test __len__ method""" + block = pulse.ScheduleBlock() + self.assertEqual(len(block), 0) + + for j in range(1, 10): + block = block.append(pulse.Delay(10, self.d0)) + self.assertEqual(len(block), j) + + def test_inherit_from(self): + """Test creating schedule with another schedule.""" + ref_metadata = {"test": "value"} + ref_name = "test" + + base_sched = pulse.ScheduleBlock(name=ref_name, metadata=ref_metadata) + new_sched = pulse.ScheduleBlock.initialize_from(base_sched) + + self.assertEqual(new_sched.name, ref_name) + self.assertDictEqual(new_sched.metadata, ref_metadata) + + +class TestBlockEquality(BaseTestBlock): + """Test equality of blocks. + + Equality of instruction ordering is compared on DAG representation. + This should be tested for each transform. + """ + + def test_different_channels(self): + """Test equality is False if different channels.""" + block1 = pulse.ScheduleBlock() + block1 += pulse.Delay(10, self.d0) + + block2 = pulse.ScheduleBlock() + block2 += pulse.Delay(10, self.d1) + + self.assertNotEqual(block1, block2) + + def test_different_transform(self): + """Test equality is False if different transforms.""" + block1 = pulse.ScheduleBlock(alignment_context=self.left_context) + block1 += pulse.Delay(10, self.d0) + + block2 = pulse.ScheduleBlock(alignment_context=self.right_context) + block2 += pulse.Delay(10, self.d0) + + self.assertNotEqual(block1, block2) + + def test_different_transform_opts(self): + """Test equality is False if different transform options.""" + context1 = transforms.AlignEquispaced(duration=100) + context2 = transforms.AlignEquispaced(duration=500) + + block1 = pulse.ScheduleBlock(alignment_context=context1) + block1 += pulse.Delay(10, self.d0) + + block2 = pulse.ScheduleBlock(alignment_context=context2) + block2 += pulse.Delay(10, self.d0) + + self.assertNotEqual(block1, block2) + + def test_instruction_out_of_order_left(self): + """Test equality is True if two blocks have instructions in different order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.left_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.left_context) + block2 += pulse.Play(self.test_waveform0, self.d1) + block2 += pulse.Play(self.test_waveform0, self.d0) + + self.assertEqual(block1, block2) + + def test_instruction_in_order_left(self): + """Test equality is True if two blocks have instructions in same order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.left_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.left_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + + self.assertEqual(block1, block2) + + def test_instruction_out_of_order_right(self): + """Test equality is True if two blocks have instructions in different order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.right_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.right_context) + block2 += pulse.Play(self.test_waveform0, self.d1) + block2 += pulse.Play(self.test_waveform0, self.d0) + + self.assertEqual(block1, block2) + + def test_instruction_in_order_right(self): + """Test equality is True if two blocks have instructions in same order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.right_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.right_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + + self.assertEqual(block1, block2) + + def test_instruction_out_of_order_sequential(self): + """Test equality is False if two blocks have instructions in different order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block2 += pulse.Play(self.test_waveform0, self.d1) + block2 += pulse.Play(self.test_waveform0, self.d0) + + self.assertNotEqual(block1, block2) + + def test_instruction_out_of_order_sequential_more(self): + """Test equality is False if three blocks have instructions in different order. + + This could detect a particular bug as discussed in this thread: + https://github.com/Qiskit/qiskit-terra/pull/8005#discussion_r966191018 + """ + block1 = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + block2 += pulse.Play(self.test_waveform0, self.d0) + + self.assertNotEqual(block1, block2) + + def test_instruction_in_order_sequential(self): + """Test equality is True if two blocks have instructions in same order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.sequential_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + + self.assertEqual(block1, block2) + + def test_instruction_out_of_order_equispaced(self): + """Test equality is False if two blocks have instructions in different order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.equispaced_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.equispaced_context) + block2 += pulse.Play(self.test_waveform0, self.d1) + block2 += pulse.Play(self.test_waveform0, self.d0) + + self.assertNotEqual(block1, block2) + + def test_instruction_in_order_equispaced(self): + """Test equality is True if two blocks have instructions in same order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.equispaced_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.equispaced_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + + self.assertEqual(block1, block2) + + def test_instruction_out_of_order_func(self): + """Test equality is False if two blocks have instructions in different order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.func_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.func_context) + block2 += pulse.Play(self.test_waveform0, self.d1) + block2 += pulse.Play(self.test_waveform0, self.d0) + + self.assertNotEqual(block1, block2) + + def test_instruction_in_order_func(self): + """Test equality is True if two blocks have instructions in same order.""" + block1 = pulse.ScheduleBlock(alignment_context=self.func_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform0, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.func_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + + self.assertEqual(block1, block2) + + def test_instrution_in_oder_but_different_node(self): + """Test equality is False if two blocks have different instructions.""" + block1 = pulse.ScheduleBlock(alignment_context=self.left_context) + block1 += pulse.Play(self.test_waveform0, self.d0) + block1 += pulse.Play(self.test_waveform1, self.d1) + + block2 = pulse.ScheduleBlock(alignment_context=self.left_context) + block2 += pulse.Play(self.test_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, self.d1) + + self.assertNotEqual(block1, block2) + + def test_instruction_out_of_order_complex_equal(self): + """Test complex schedule equality can be correctly evaluated.""" + block1_a = pulse.ScheduleBlock(alignment_context=self.left_context) + block1_a += pulse.Delay(10, self.d0) + block1_a += pulse.Play(self.test_waveform1, self.d1) + block1_a += pulse.Play(self.test_waveform0, self.d0) + + block1_b = pulse.ScheduleBlock(alignment_context=self.left_context) + block1_b += pulse.Play(self.test_waveform1, self.d1) + block1_b += pulse.Delay(10, self.d0) + block1_b += pulse.Play(self.test_waveform0, self.d0) + + block2_a = pulse.ScheduleBlock(alignment_context=self.right_context) + block2_a += block1_a + block2_a += block1_b + block2_a += block1_a + + block2_b = pulse.ScheduleBlock(alignment_context=self.right_context) + block2_b += block1_a + block2_b += block1_a + block2_b += block1_b + + self.assertEqual(block2_a, block2_b) + + def test_instruction_out_of_order_complex_not_equal(self): + """Test complex schedule equality can be correctly evaluated.""" + block1_a = pulse.ScheduleBlock(alignment_context=self.left_context) + block1_a += pulse.Play(self.test_waveform0, self.d0) + block1_a += pulse.Play(self.test_waveform1, self.d1) + block1_a += pulse.Delay(10, self.d0) + + block1_b = pulse.ScheduleBlock(alignment_context=self.left_context) + block1_b += pulse.Play(self.test_waveform1, self.d1) + block1_b += pulse.Delay(10, self.d0) + block1_b += pulse.Play(self.test_waveform0, self.d0) + + block2_a = pulse.ScheduleBlock(alignment_context=self.right_context) + block2_a += block1_a + block2_a += block1_b + block2_a += block1_a + + block2_b = pulse.ScheduleBlock(alignment_context=self.right_context) + block2_b += block1_a + block2_b += block1_a + block2_b += block1_b + + self.assertNotEqual(block2_a, block2_b) + + +class TestParametrizedBlockOperation(BaseTestBlock): + """Test fundamental operation with parametrization.""" + + def setUp(self): + super().setUp() + + self.amp0 = circuit.Parameter("amp0") + self.amp1 = circuit.Parameter("amp1") + self.dur0 = circuit.Parameter("dur0") + self.dur1 = circuit.Parameter("dur1") + + self.test_par_waveform0 = pulse.Constant(self.dur0, self.amp0) + self.test_par_waveform1 = pulse.Constant(self.dur1, self.amp1) + + def test_report_parameter_assignment(self): + """Test duration assignment check.""" + block = pulse.ScheduleBlock() + block += pulse.Play(self.test_par_waveform0, self.d0) + + # check parameter evaluation mechanism + self.assertTrue(block.is_parameterized()) + self.assertFalse(block.is_schedulable()) + + # assign duration + block = block.assign_parameters({self.dur0: 200}) + self.assertTrue(block.is_parameterized()) + self.assertTrue(block.is_schedulable()) + + def test_cannot_get_duration_if_not_assigned(self): + """Test raise error when duration is not assigned.""" + block = pulse.ScheduleBlock() + block += pulse.Play(self.test_par_waveform0, self.d0) + + with self.assertRaises(PulseError): + # pylint: disable=pointless-statement + block.duration + + def test_get_assigend_duration(self): + """Test duration is correctly evaluated.""" + block = pulse.ScheduleBlock() + block += pulse.Play(self.test_par_waveform0, self.d0) + block += pulse.Play(self.test_waveform0, self.d0) + + block = block.assign_parameters({self.dur0: 300}) + + self.assertEqual(block.duration, 400) + + def test_equality_of_parametrized_channels(self): + """Test check equality of blocks involving parametrized channels.""" + par_ch = circuit.Parameter("ch") + + block1 = pulse.ScheduleBlock(alignment_context=self.left_context) + block1 += pulse.Play(self.test_waveform0, pulse.DriveChannel(par_ch)) + block1 += pulse.Play(self.test_par_waveform0, self.d0) + + block2 = pulse.ScheduleBlock(alignment_context=self.left_context) + block2 += pulse.Play(self.test_par_waveform0, self.d0) + block2 += pulse.Play(self.test_waveform0, pulse.DriveChannel(par_ch)) + + self.assertEqual(block1, block2) + + block1_assigned = block1.assign_parameters({par_ch: 1}) + block2_assigned = block2.assign_parameters({par_ch: 1}) + self.assertEqual(block1_assigned, block2_assigned) + + def test_replace_parametrized_instruction(self): + """Test parametrized instruction can updated with parameter table.""" + block = pulse.ScheduleBlock() + block += pulse.Play(self.test_par_waveform0, self.d0) + block += pulse.Delay(100, self.d0) + block += pulse.Play(self.test_waveform0, self.d0) + + replaced = block.replace( + pulse.Play(self.test_par_waveform0, self.d0), + pulse.Play(self.test_par_waveform1, self.d0), + ) + self.assertTrue(replaced.is_parameterized()) + + # check assign parameters + replaced_assigned = replaced.assign_parameters({self.dur1: 100, self.amp1: 0.1}) + self.assertFalse(replaced_assigned.is_parameterized()) + + def test_parametrized_context(self): + """Test parametrize context parameter.""" + duration = circuit.Parameter("dur") + param_context = transforms.AlignEquispaced(duration=duration) + + block = pulse.ScheduleBlock(alignment_context=param_context) + block += pulse.Delay(10, self.d0) + block += pulse.Delay(10, self.d0) + block += pulse.Delay(10, self.d0) + block += pulse.Delay(10, self.d0) + self.assertTrue(block.is_parameterized()) + self.assertFalse(block.is_schedulable()) + + block.assign_parameters({duration: 100}, inplace=True) + self.assertFalse(block.is_parameterized()) + self.assertTrue(block.is_schedulable()) + + ref_sched = pulse.Schedule() + ref_sched = ref_sched.insert(0, pulse.Delay(10, self.d0)) + ref_sched = ref_sched.insert(30, pulse.Delay(10, self.d0)) + ref_sched = ref_sched.insert(60, pulse.Delay(10, self.d0)) + ref_sched = ref_sched.insert(90, pulse.Delay(10, self.d0)) + + self.assertScheduleEqual(block, ref_sched) + + +class TestBlockFilter(BaseTestBlock): + """Test ScheduleBlock filtering methods.""" + + def test_filter_channels(self): + """Test filtering over channels.""" + with pulse.build() as blk: + pulse.play(self.test_waveform0, self.d0) + pulse.delay(10, self.d0) + pulse.play(self.test_waveform1, self.d1) + + filtered_blk = self._filter_and_test_consistency(blk, channels=[self.d0]) + self.assertEqual(len(filtered_blk.channels), 1) + self.assertTrue(self.d0 in filtered_blk.channels) + with pulse.build() as ref_blk: + pulse.play(self.test_waveform0, self.d0) + pulse.delay(10, self.d0) + self.assertEqual(filtered_blk, ref_blk) + + filtered_blk = self._filter_and_test_consistency(blk, channels=[self.d1]) + self.assertEqual(len(filtered_blk.channels), 1) + self.assertTrue(self.d1 in filtered_blk.channels) + with pulse.build() as ref_blk: + pulse.play(self.test_waveform1, self.d1) + self.assertEqual(filtered_blk, ref_blk) + + filtered_blk = self._filter_and_test_consistency(blk, channels=[self.d0, self.d1]) + self.assertEqual(len(filtered_blk.channels), 2) + for ch in [self.d0, self.d1]: + self.assertTrue(ch in filtered_blk.channels) + self.assertEqual(filtered_blk, blk) + + def test_filter_channels_nested_block(self): + """Test filtering over channels in a nested block.""" + with pulse.build() as blk: + with pulse.align_sequential(): + pulse.play(self.test_waveform0, self.d0) + pulse.delay(5, self.d0) + pulse.call( + self.backend.defaults() + .instruction_schedule_map._get_calibration_entry("cx", (0, 1)) + .get_schedule() + ) + + for ch in [self.d0, self.d1, pulse.ControlChannel(0)]: + filtered_blk = self._filter_and_test_consistency(blk, channels=[ch]) + self.assertEqual(len(filtered_blk.channels), 1) + self.assertTrue(ch in filtered_blk.channels) + + def test_filter_inst_types(self): + """Test filtering on instruction types.""" + with pulse.build() as blk: + pulse.acquire(5, pulse.AcquireChannel(0), pulse.MemorySlot(0)) + + with pulse.build() as blk_internal: + pulse.play(self.test_waveform1, self.d1) + + pulse.call(blk_internal) + pulse.reference(name="dummy_reference") + pulse.delay(10, self.d0) + pulse.play(self.test_waveform0, self.d0) + pulse.barrier(self.d0, self.d1, pulse.AcquireChannel(0), pulse.MemorySlot(0)) + pulse.set_frequency(10, self.d0) + pulse.shift_frequency(5, self.d1) + pulse.set_phase(3.14 / 4.0, self.d0) + pulse.shift_phase(-3.14 / 2.0, self.d1) + pulse.snapshot(label="dummy_snapshot") + + # test filtering Acquire + filtered_blk = self._filter_and_test_consistency(blk, instruction_types=[pulse.Acquire]) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.Acquire) + self.assertEqual(len(filtered_blk.channels), 2) + + # test filtering Reference + filtered_blk = self._filter_and_test_consistency( + blk, instruction_types=[pulse.instructions.Reference] + ) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.instructions.Reference) + + # test filtering Delay + filtered_blk = self._filter_and_test_consistency(blk, instruction_types=[pulse.Delay]) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.Delay) + self.assertEqual(len(filtered_blk.channels), 1) + + # test filtering Play + filtered_blk = self._filter_and_test_consistency(blk, instruction_types=[pulse.Play]) + self.assertEqual(len(filtered_blk.blocks), 2) + self.assertIsInstance(filtered_blk.blocks[0].blocks[0], pulse.Play) + self.assertIsInstance(filtered_blk.blocks[1], pulse.Play) + self.assertEqual(len(filtered_blk.channels), 2) + + # test filtering RelativeBarrier + filtered_blk = self._filter_and_test_consistency( + blk, instruction_types=[pulse.instructions.RelativeBarrier] + ) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.instructions.RelativeBarrier) + self.assertEqual(len(filtered_blk.channels), 4) + + # test filtering SetFrequency + filtered_blk = self._filter_and_test_consistency( + blk, instruction_types=[pulse.SetFrequency] + ) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.SetFrequency) + self.assertEqual(len(filtered_blk.channels), 1) + + # test filtering ShiftFrequency + filtered_blk = self._filter_and_test_consistency( + blk, instruction_types=[pulse.ShiftFrequency] + ) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.ShiftFrequency) + self.assertEqual(len(filtered_blk.channels), 1) + + # test filtering SetPhase + filtered_blk = self._filter_and_test_consistency(blk, instruction_types=[pulse.SetPhase]) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.SetPhase) + self.assertEqual(len(filtered_blk.channels), 1) + + # test filtering ShiftPhase + filtered_blk = self._filter_and_test_consistency(blk, instruction_types=[pulse.ShiftPhase]) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.ShiftPhase) + self.assertEqual(len(filtered_blk.channels), 1) + + # test filtering SnapShot + filtered_blk = self._filter_and_test_consistency(blk, instruction_types=[pulse.Snapshot]) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.Snapshot) + self.assertEqual(len(filtered_blk.channels), 1) + + def test_filter_functionals(self): + """Test functional filtering.""" + with pulse.build() as blk: + pulse.play(self.test_waveform0, self.d0, "play0") + pulse.delay(10, self.d0, "delay0") + + with pulse.build() as blk_internal: + pulse.play(self.test_waveform1, self.d1, "play1") + + pulse.call(blk_internal) + pulse.play(self.test_waveform1, self.d1) + + def filter_with_inst_name(inst: pulse.Instruction) -> bool: + try: + if isinstance(inst.name, str): + match_obj = re.search(pattern="play", string=inst.name) + if match_obj is not None: + return True + except AttributeError: + pass + return False + + filtered_blk = self._filter_and_test_consistency(blk, filter_with_inst_name) + self.assertEqual(len(filtered_blk.blocks), 2) + self.assertIsInstance(filtered_blk.blocks[0], pulse.Play) + self.assertIsInstance(filtered_blk.blocks[1].blocks[0], pulse.Play) + self.assertEqual(len(filtered_blk.channels), 2) + + def test_filter_multiple(self): + """Test filter composition.""" + with pulse.build() as blk: + pulse.play(pulse.Constant(100, 0.1, name="play0"), self.d0) + pulse.delay(10, self.d0, "delay0") + + with pulse.build(name="internal_blk") as blk_internal: + pulse.play(pulse.Constant(50, 0.1, name="play1"), self.d0) + + pulse.call(blk_internal) + pulse.barrier(self.d0, self.d1) + pulse.play(pulse.Constant(100, 0.1, name="play2"), self.d1) + + def filter_with_pulse_name(inst: pulse.Instruction) -> bool: + try: + if isinstance(inst.pulse.name, str): + match_obj = re.search(pattern="play", string=inst.pulse.name) + if match_obj is not None: + return True + except AttributeError: + pass + return False + + filtered_blk = self._filter_and_test_consistency( + blk, filter_with_pulse_name, channels=[self.d1], instruction_types=[pulse.Play] + ) + self.assertEqual(len(filtered_blk.blocks), 1) + self.assertIsInstance(filtered_blk.blocks[0], pulse.Play) + self.assertEqual(len(filtered_blk.channels), 1) + + def _filter_and_test_consistency( + self, sched_blk: pulse.ScheduleBlock, *args: Any, **kwargs: Any + ) -> pulse.ScheduleBlock: + """ + Returns sched_blk.filter(*args, **kwargs), + including a test that sched_blk.filter | sched_blk.exclude == sched_blk + in terms of instructions. + """ + filtered = sched_blk.filter(*args, **kwargs) + excluded = sched_blk.exclude(*args, **kwargs) + + def list_instructions(blk: pulse.ScheduleBlock) -> List[pulse.Instruction]: + insts = [] + for element in blk.blocks: + if isinstance(element, pulse.ScheduleBlock): + inner_insts = list_instructions(element) + if len(inner_insts) != 0: + insts.extend(inner_insts) + elif isinstance(element, pulse.Instruction): + insts.append(element) + return insts + + sum_insts = list_instructions(filtered) + list_instructions(excluded) + ref_insts = list_instructions(sched_blk) + self.assertEqual(len(sum_insts), len(ref_insts)) + self.assertTrue(all(inst in ref_insts for inst in sum_insts)) + return filtered diff --git a/test/dynamics/pulse/test_builder_v2.py b/test/dynamics/pulse/test_builder_v2.py new file mode 100644 index 000000000..60e39be4c --- /dev/null +++ b/test/dynamics/pulse/test_builder_v2.py @@ -0,0 +1,303 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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 pulse builder with backendV2 context utilities.""" + +import numpy as np + +from qiskit_dynamics import pulse +from qiskit_dynamics.pulse import macros + +from qiskit_dynamics.pulse.instructions import directives +from qiskit_dynamics.pulse.transforms import target_qobj_transform +from qiskit.providers.fake_provider import GenericBackendV2 +from qiskit_dynamics.pulse import instructions +from test import QiskitTestCase # pylint: disable=wrong-import-order + +from .legacy_cmaps import MUMBAI_CMAP + + +class TestBuilderV2(QiskitTestCase): + """Test the pulse builder context with backendV2.""" + + def setUp(self): + super().setUp() + self.backend = GenericBackendV2( + num_qubits=27, coupling_map=MUMBAI_CMAP, calibrate_instructions=True, seed=42 + ) + + def assertScheduleEqual(self, program, target): + """Assert an error when two pulse programs are not equal. + + .. note:: Two programs are converted into standard execution format then compared. + """ + self.assertEqual(target_qobj_transform(program), target_qobj_transform(target)) + + +class TestContextsV2(TestBuilderV2): + """Test builder contexts.""" + + def test_phase_compensated_frequency_offset(self): + """Test that the phase offset context properly compensates for phase + accumulation with backendV2.""" + d0 = pulse.DriveChannel(0) + with pulse.build(self.backend) as schedule: + with pulse.frequency_offset(1e9, d0, compensate_phase=True): + pulse.delay(10, d0) + + reference = pulse.Schedule() + reference += instructions.ShiftFrequency(1e9, d0) + reference += instructions.Delay(10, d0) + reference += instructions.ShiftPhase( + -2 * np.pi * ((1e9 * 10 * self.backend.target.dt) % 1), d0 + ) + reference += instructions.ShiftFrequency(-1e9, d0) + self.assertScheduleEqual(schedule, reference) + + +class TestChannelsV2(TestBuilderV2): + """Test builder channels.""" + + def test_drive_channel(self): + """Text context builder drive channel.""" + with pulse.build(self.backend): + self.assertEqual(pulse.drive_channel(0), pulse.DriveChannel(0)) + + def test_measure_channel(self): + """Text context builder measure channel.""" + with pulse.build(self.backend): + self.assertEqual(pulse.measure_channel(0), pulse.MeasureChannel(0)) + + def test_acquire_channel(self): + """Text context builder acquire channel.""" + with pulse.build(self.backend): + self.assertEqual(pulse.acquire_channel(0), pulse.AcquireChannel(0)) + + def test_control_channel(self): + """Text context builder control channel.""" + with pulse.build(self.backend): + self.assertEqual(pulse.control_channels(0, 1)[0], pulse.ControlChannel(0)) + + +class TestDirectivesV2(TestBuilderV2): + """Test builder directives.""" + + def test_barrier_on_qubits(self): + """Test barrier directive on qubits with backendV2. + A part of qubits map of Mumbai + 0 -- 1 -- 4 -- + | + | + 2 + """ + with pulse.build(self.backend) as schedule: + pulse.barrier(0, 1) + reference = pulse.ScheduleBlock() + reference += directives.RelativeBarrier( + pulse.DriveChannel(0), + pulse.DriveChannel(1), + pulse.MeasureChannel(0), + pulse.MeasureChannel(1), + pulse.ControlChannel(0), + pulse.ControlChannel(1), + pulse.ControlChannel(2), + pulse.ControlChannel(3), + pulse.ControlChannel(4), + pulse.ControlChannel(8), + pulse.AcquireChannel(0), + pulse.AcquireChannel(1), + ) + self.assertEqual(schedule, reference) + + +class TestUtilitiesV2(TestBuilderV2): + """Test builder utilities.""" + + def test_active_backend(self): + """Test getting active builder backend.""" + with pulse.build(self.backend): + self.assertEqual(pulse.active_backend(), self.backend) + + def test_qubit_channels(self): + """Test getting the qubit channels of the active builder's backend.""" + with pulse.build(self.backend): + qubit_channels = pulse.qubit_channels(0) + + self.assertEqual( + qubit_channels, + { + pulse.DriveChannel(0), + pulse.MeasureChannel(0), + pulse.AcquireChannel(0), + pulse.ControlChannel(0), + pulse.ControlChannel(1), + }, + ) + + def test_num_qubits(self): + """Test builder utility to get number of qubits with backendV2.""" + with pulse.build(self.backend): + self.assertEqual(pulse.num_qubits(), 27) + + def test_samples_to_seconds(self): + """Test samples to time with backendV2""" + target = self.backend.target + target.dt = 0.1 + with pulse.build(self.backend): + time = pulse.samples_to_seconds(100) + self.assertTrue(isinstance(time, float)) + self.assertEqual(pulse.samples_to_seconds(100), 10) + + def test_samples_to_seconds_array(self): + """Test samples to time (array format) with backendV2.""" + target = self.backend.target + target.dt = 0.1 + with pulse.build(self.backend): + samples = np.array([100, 200, 300]) + times = pulse.samples_to_seconds(samples) + self.assertTrue(np.issubdtype(times.dtype, np.floating)) + np.testing.assert_allclose(times, np.array([10, 20, 30])) + + def test_seconds_to_samples(self): + """Test time to samples with backendV2""" + target = self.backend.target + target.dt = 0.1 + with pulse.build(self.backend): + samples = pulse.seconds_to_samples(10) + self.assertTrue(isinstance(samples, int)) + self.assertEqual(pulse.seconds_to_samples(10), 100) + + def test_seconds_to_samples_array(self): + """Test time to samples (array format) with backendV2.""" + target = self.backend.target + target.dt = 0.1 + with pulse.build(self.backend): + times = np.array([10, 20, 30]) + samples = pulse.seconds_to_samples(times) + self.assertTrue(np.issubdtype(samples.dtype, np.integer)) + np.testing.assert_allclose(pulse.seconds_to_samples(times), np.array([100, 200, 300])) + + +class TestMacrosV2(TestBuilderV2): + """Test builder macros with backendV2.""" + + def test_macro(self): + """Test builder macro decorator.""" + + @pulse.macro + def nested(a): + pulse.play(pulse.Gaussian(100, a, 20), pulse.drive_channel(0)) + return a * 2 + + @pulse.macro + def test(): + pulse.play(pulse.Constant(100, 1.0), pulse.drive_channel(0)) + output = nested(0.5) + return output + + with pulse.build(self.backend) as schedule: + output = test() + self.assertEqual(output, 0.5 * 2) + + reference = pulse.Schedule() + reference += pulse.Play(pulse.Constant(100, 1.0), pulse.DriveChannel(0)) + reference += pulse.Play(pulse.Gaussian(100, 0.5, 20), pulse.DriveChannel(0)) + + self.assertScheduleEqual(schedule, reference) + + def test_measure(self): + """Test utility function - measure with backendV2.""" + with pulse.build(self.backend) as schedule: + reg = pulse.measure(0) + + self.assertEqual(reg, pulse.MemorySlot(0)) + + reference = macros.measure(qubits=[0], backend=self.backend, meas_map=self.backend.meas_map) + + self.assertScheduleEqual(schedule, reference) + + def test_measure_multi_qubits(self): + """Test utility function - measure with multi qubits with backendV2.""" + with pulse.build(self.backend) as schedule: + regs = pulse.measure([0, 1]) + + self.assertListEqual(regs, [pulse.MemorySlot(0), pulse.MemorySlot(1)]) + + reference = macros.measure( + qubits=[0, 1], backend=self.backend, meas_map=self.backend.meas_map + ) + + self.assertScheduleEqual(schedule, reference) + + def test_measure_all(self): + """Test utility function - measure with backendV2..""" + with pulse.build(self.backend) as schedule: + regs = pulse.measure_all() + + self.assertEqual(regs, [pulse.MemorySlot(i) for i in range(self.backend.num_qubits)]) + reference = macros.measure_all(self.backend) + + self.assertScheduleEqual(schedule, reference) + + def test_delay_qubit(self): + """Test delaying on a qubit macro.""" + with pulse.build(self.backend) as schedule: + pulse.delay_qubits(10, 0) + + d0 = pulse.DriveChannel(0) + m0 = pulse.MeasureChannel(0) + a0 = pulse.AcquireChannel(0) + u0 = pulse.ControlChannel(0) + u1 = pulse.ControlChannel(1) + + reference = pulse.Schedule() + reference += instructions.Delay(10, d0) + reference += instructions.Delay(10, m0) + reference += instructions.Delay(10, a0) + reference += instructions.Delay(10, u0) + reference += instructions.Delay(10, u1) + + self.assertScheduleEqual(schedule, reference) + + def test_delay_qubits(self): + """Test delaying on multiple qubits with backendV2 to make sure we don't insert delays twice.""" + with pulse.build(self.backend) as schedule: + pulse.delay_qubits(10, 0, 1) + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + m0 = pulse.MeasureChannel(0) + m1 = pulse.MeasureChannel(1) + a0 = pulse.AcquireChannel(0) + a1 = pulse.AcquireChannel(1) + u0 = pulse.ControlChannel(0) + u1 = pulse.ControlChannel(1) + u2 = pulse.ControlChannel(2) + u3 = pulse.ControlChannel(3) + u4 = pulse.ControlChannel(4) + u8 = pulse.ControlChannel(8) + + reference = pulse.Schedule() + reference += instructions.Delay(10, d0) + reference += instructions.Delay(10, d1) + reference += instructions.Delay(10, m0) + reference += instructions.Delay(10, m1) + reference += instructions.Delay(10, a0) + reference += instructions.Delay(10, a1) + reference += instructions.Delay(10, u0) + reference += instructions.Delay(10, u1) + reference += instructions.Delay(10, u2) + reference += instructions.Delay(10, u3) + reference += instructions.Delay(10, u4) + reference += instructions.Delay(10, u8) + + self.assertScheduleEqual(schedule, reference) diff --git a/test/dynamics/pulse/test_calibration_entries.py b/test/dynamics/pulse/test_calibration_entries.py new file mode 100644 index 000000000..bc0ab7fed --- /dev/null +++ b/test/dynamics/pulse/test_calibration_entries.py @@ -0,0 +1,274 @@ +# 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 for calibration entries.""" + +import numpy as np + +from qiskit.circuit.parameter import Parameter +from qiskit_dynamics.pulse import ( + Schedule, + ScheduleBlock, + Play, + ShiftPhase, + Constant, + Waveform, + DriveChannel, +) +from qiskit_dynamics.pulse.calibration_entries import ( + ScheduleDef, + CallableDef, + PulseQobjDef, +) +from qiskit_dynamics.pulse.exceptions import PulseError +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestSchedule(QiskitTestCase): + """Test case for the ScheduleDef.""" + + def test_add_schedule(self): + """Basic test pulse Schedule format.""" + program = Schedule() + program.insert( + 0, + Play(Constant(duration=10, amp=0.1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + entry = ScheduleDef() + entry.define(program) + + signature_to_test = list(entry.get_signature().parameters.keys()) + signature_ref = [] + self.assertListEqual(signature_to_test, signature_ref) + + schedule_to_test = entry.get_schedule() + schedule_ref = program + self.assertEqual(schedule_to_test, schedule_ref) + + def test_add_block(self): + """Basic test pulse Schedule format.""" + program = ScheduleBlock() + program.append( + Play(Constant(duration=10, amp=0.1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + entry = ScheduleDef() + entry.define(program) + + signature_to_test = list(entry.get_signature().parameters.keys()) + signature_ref = [] + self.assertListEqual(signature_to_test, signature_ref) + + schedule_to_test = entry.get_schedule() + schedule_ref = program + self.assertEqual(schedule_to_test, schedule_ref) + + def test_parameterized_schedule(self): + """Test adding and managing parameterized schedule.""" + param1 = Parameter("P1") + param2 = Parameter("P2") + + program = ScheduleBlock() + program.append( + Play(Constant(duration=param1, amp=param2, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + entry = ScheduleDef() + entry.define(program) + + signature_to_test = list(entry.get_signature().parameters.keys()) + signature_ref = ["P1", "P2"] + self.assertListEqual(signature_to_test, signature_ref) + + schedule_to_test = entry.get_schedule(P1=10, P2=0.1) + schedule_ref = program.assign_parameters({param1: 10, param2: 0.1}, inplace=False) + self.assertEqual(schedule_to_test, schedule_ref) + + def test_parameterized_schedule_with_user_args(self): + """Test adding schedule with user signature. + + Bind parameters to a pulse schedule but expecting non-lexicographical order. + """ + theta = Parameter("theta") + lam = Parameter("lam") + phi = Parameter("phi") + + program = ScheduleBlock() + program.append( + Play(Constant(duration=10, amp=phi, angle=0.0), DriveChannel(0)), + inplace=True, + ) + program.append( + Play(Constant(duration=10, amp=theta, angle=0.0), DriveChannel(0)), + inplace=True, + ) + program.append( + Play(Constant(duration=10, amp=lam, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + entry = ScheduleDef(arguments=["theta", "lam", "phi"]) + entry.define(program) + + signature_to_test = list(entry.get_signature().parameters.keys()) + signature_ref = ["theta", "lam", "phi"] + self.assertListEqual(signature_to_test, signature_ref) + + # Do not specify kwargs. This is order sensitive. + schedule_to_test = entry.get_schedule(0.1, 0.2, 0.3) + schedule_ref = program.assign_parameters( + {theta: 0.1, lam: 0.2, phi: 0.3}, + inplace=False, + ) + self.assertEqual(schedule_to_test, schedule_ref) + + def test_parameterized_schedule_with_wrong_signature(self): + """Test raising PulseError when signature doesn't match.""" + param1 = Parameter("P1") + + program = ScheduleBlock() + program.append( + Play(Constant(duration=10, amp=param1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + entry = ScheduleDef(arguments=["This_is_wrong_param_name"]) + + with self.assertRaises(PulseError): + entry.define(program) + + def test_equality(self): + """Test equality evaluation between the schedule entries.""" + program1 = Schedule() + program1.insert( + 0, + Play(Constant(duration=10, amp=0.1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + program2 = Schedule() + program2.insert( + 0, + Play(Constant(duration=10, amp=0.2, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + entry1 = ScheduleDef() + entry1.define(program1) + + entry2 = ScheduleDef() + entry2.define(program2) + + entry3 = ScheduleDef() + entry3.define(program1) + + self.assertEqual(entry1, entry3) + self.assertNotEqual(entry1, entry2) + + +class TestCallable(QiskitTestCase): + """Test case for the CallableDef.""" + + def test_add_callable(self): + """Basic test callable format.""" + program = Schedule() + program.insert( + 0, + Play(Constant(duration=10, amp=0.1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + + def factory(): + return program + + entry = CallableDef() + entry.define(factory) + + signature_to_test = list(entry.get_signature().parameters.keys()) + signature_ref = [] + self.assertListEqual(signature_to_test, signature_ref) + + schedule_to_test = entry.get_schedule() + schedule_ref = program + self.assertEqual(schedule_to_test, schedule_ref) + + def test_add_callable_with_argument(self): + """Basic test callable format.""" + + def factory(var1, var2): + program = Schedule() + if var1 > 0: + program.insert( + 0, + Play(Constant(duration=var2, amp=var1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + else: + program.insert( + 0, + Play(Constant(duration=var2, amp=np.abs(var1), angle=np.pi), DriveChannel(0)), + inplace=True, + ) + return program + + entry = CallableDef() + entry.define(factory) + + signature_to_test = list(entry.get_signature().parameters.keys()) + signature_ref = ["var1", "var2"] + self.assertListEqual(signature_to_test, signature_ref) + + schedule_to_test = entry.get_schedule(0.1, 10) + schedule_ref = Schedule() + schedule_ref.insert( + 0, + Play(Constant(duration=10, amp=0.1, angle=0.0), DriveChannel(0)), + inplace=True, + ) + self.assertEqual(schedule_to_test, schedule_ref) + + schedule_to_test = entry.get_schedule(-0.1, 10) + schedule_ref = Schedule() + schedule_ref.insert( + 0, + Play(Constant(duration=10, amp=0.1, angle=np.pi), DriveChannel(0)), + inplace=True, + ) + self.assertEqual(schedule_to_test, schedule_ref) + + def test_equality(self): + """Test equality evaluation between the callable entries. + + This does NOT compare the code. Just object equality. + """ + + def factory1(): + return Schedule() + + def factory2(): + return Schedule() + + entry1 = CallableDef() + entry1.define(factory1) + + entry2 = CallableDef() + entry2.define(factory2) + + entry3 = CallableDef() + entry3.define(factory1) + + self.assertEqual(entry1, entry3) + self.assertNotEqual(entry1, entry2) diff --git a/test/dynamics/pulse/test_channels.py b/test/dynamics/pulse/test_channels.py new file mode 100644 index 000000000..9d1d3e182 --- /dev/null +++ b/test/dynamics/pulse/test_channels.py @@ -0,0 +1,186 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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 cases for the pulse channel group.""" + +import unittest + +from qiskit_dynamics.pulse.channels import ( + AcquireChannel, + Channel, + ClassicalIOChannel, + ControlChannel, + DriveChannel, + MeasureChannel, + MemorySlot, + PulseChannel, + RegisterSlot, + SnapshotChannel, + PulseError, +) +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestChannel(QiskitTestCase): + """Test base channel.""" + + def test_cannot_be_instantiated(self): + """Test base channel cannot be instantiated.""" + with self.assertRaises(NotImplementedError): + Channel(0) + + +class TestPulseChannel(QiskitTestCase): + """Test base pulse channel.""" + + def test_cannot_be_instantiated(self): + """Test base pulse channel cannot be instantiated.""" + with self.assertRaises(NotImplementedError): + PulseChannel(0) + + +class TestAcquireChannel(QiskitTestCase): + """AcquireChannel tests.""" + + def test_default(self): + """Test default acquire channel.""" + acquire_channel = AcquireChannel(123) + + self.assertEqual(acquire_channel.index, 123) + self.assertEqual(acquire_channel.name, "a123") + + def test_channel_hash(self): + """Test hashing for acquire channel.""" + acq_channel_1 = AcquireChannel(123) + acq_channel_2 = AcquireChannel(123) + + hash_1 = hash(acq_channel_1) + hash_2 = hash(acq_channel_2) + + self.assertEqual(hash_1, hash_2) + + +class TestClassicalIOChannel(QiskitTestCase): + """Test base classical IO channel.""" + + def test_cannot_be_instantiated(self): + """Test base classical IO channel cannot be instantiated.""" + with self.assertRaises(NotImplementedError): + ClassicalIOChannel(0) + + +class TestMemorySlot(QiskitTestCase): + """MemorySlot tests.""" + + def test_default(self): + """Test default memory slot.""" + memory_slot = MemorySlot(123) + + self.assertEqual(memory_slot.index, 123) + self.assertEqual(memory_slot.name, "m123") + self.assertTrue(isinstance(memory_slot, ClassicalIOChannel)) + + def test_validation(self): + """Test channel validation""" + with self.assertRaises(PulseError): + MemorySlot(0.5) + with self.assertRaises(PulseError): + MemorySlot(-1) + + +class TestRegisterSlot(QiskitTestCase): + """RegisterSlot tests.""" + + def test_default(self): + """Test default register slot.""" + register_slot = RegisterSlot(123) + + self.assertEqual(register_slot.index, 123) + self.assertEqual(register_slot.name, "c123") + self.assertTrue(isinstance(register_slot, ClassicalIOChannel)) + + def test_validation(self): + """Test channel validation""" + with self.assertRaises(PulseError): + RegisterSlot(0.5) + with self.assertRaises(PulseError): + RegisterSlot(-1) + + +class TestSnapshotChannel(QiskitTestCase): + """SnapshotChannel tests.""" + + def test_default(self): + """Test default snapshot channel.""" + snapshot_channel = SnapshotChannel() + + self.assertEqual(snapshot_channel.index, 0) + self.assertEqual(snapshot_channel.name, "s0") + self.assertTrue(isinstance(snapshot_channel, ClassicalIOChannel)) + + +class TestDriveChannel(QiskitTestCase): + """DriveChannel tests.""" + + def test_default(self): + """Test default drive channel.""" + drive_channel = DriveChannel(123) + + self.assertEqual(drive_channel.index, 123) + self.assertEqual(drive_channel.name, "d123") + + def test_validation(self): + """Test channel validation""" + with self.assertRaises(PulseError): + DriveChannel(0.5) + with self.assertRaises(PulseError): + DriveChannel(-1) + + +class TestControlChannel(QiskitTestCase): + """ControlChannel tests.""" + + def test_default(self): + """Test default control channel.""" + control_channel = ControlChannel(123) + + self.assertEqual(control_channel.index, 123) + self.assertEqual(control_channel.name, "u123") + + def test_validation(self): + """Test channel validation""" + with self.assertRaises(PulseError): + ControlChannel(0.5) + with self.assertRaises(PulseError): + ControlChannel(-1) + + +class TestMeasureChannel(QiskitTestCase): + """MeasureChannel tests.""" + + def test_default(self): + """Test default measure channel.""" + measure_channel = MeasureChannel(123) + + self.assertEqual(measure_channel.index, 123) + self.assertEqual(measure_channel.name, "m123") + + def test_validation(self): + """Test channel validation""" + with self.assertRaises(PulseError): + MeasureChannel(0.5) + with self.assertRaises(PulseError): + MeasureChannel(-1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/dynamics/pulse/test_continuous_pulses.py b/test/dynamics/pulse/test_continuous_pulses.py new file mode 100644 index 000000000..518ae6376 --- /dev/null +++ b/test/dynamics/pulse/test_continuous_pulses.py @@ -0,0 +1,307 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + + +"""Tests continuous pulse functions.""" + +import numpy as np + +from qiskit_dynamics.pulse.library import continuous +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestContinuousPulses(QiskitTestCase): + """Test continuous pulses.""" + + def test_constant(self): + """Test constant pulse.""" + amp = 0.5j + samples = 50 + times = np.linspace(0, 10, samples) + + constant_arr = continuous.constant(times, amp=amp) + + self.assertEqual(constant_arr.dtype, np.complex128) + np.testing.assert_equal(constant_arr, amp) + self.assertEqual(len(constant_arr), samples) + + def test_zero(self): + """Test constant pulse.""" + times = np.linspace(0, 10, 50) + zero_arr = continuous.zero(times) + + self.assertEqual(zero_arr.dtype, np.complex128) + np.testing.assert_equal(zero_arr, 0.0) + self.assertEqual(len(zero_arr), 50) + + def test_square(self): + """Test square wave.""" + amp = 0.5 + freq = 0.2 + samples = 100 + times = np.linspace(0, 10, samples) + square_arr = continuous.square(times, amp=amp, freq=freq) + # with new phase + square_arr_phased = continuous.square(times, amp=amp, freq=freq, phase=np.pi / 2) + + self.assertEqual(square_arr.dtype, np.complex128) + + self.assertAlmostEqual(square_arr[0], amp) + # test constant + self.assertAlmostEqual(square_arr[1] - square_arr[0], 0.0) + self.assertAlmostEqual(square_arr[25], -amp) + self.assertAlmostEqual(square_arr_phased[0], -amp) + # Assert bounded between -amp and amp + self.assertTrue(np.all((-amp <= square_arr) & (square_arr <= amp))) + self.assertEqual(len(square_arr), samples) + + def test_sawtooth(self): + """Test sawtooth wave.""" + amp = 0.5 + freq = 0.2 + samples = 101 + times, dt = np.linspace(0, 10, samples, retstep=True) + sawtooth_arr = continuous.sawtooth(times, amp=amp, freq=freq) + # with new phase + sawtooth_arr_phased = continuous.sawtooth(times, amp=amp, freq=freq, phase=np.pi / 2) + + self.assertEqual(sawtooth_arr.dtype, np.complex128) + + self.assertAlmostEqual(sawtooth_arr[0], 0.0) + # test slope + self.assertAlmostEqual((sawtooth_arr[1] - sawtooth_arr[0]) / dt, 2 * amp * freq) + self.assertAlmostEqual(sawtooth_arr[24], 0.48) + self.assertAlmostEqual(sawtooth_arr[50], 0.0) + self.assertAlmostEqual(sawtooth_arr[75], -amp) + self.assertAlmostEqual(sawtooth_arr_phased[0], -amp) + # Assert bounded between -amp and amp + self.assertTrue(np.all((-amp <= sawtooth_arr) & (sawtooth_arr <= amp))) + self.assertEqual(len(sawtooth_arr), samples) + + def test_triangle(self): + """Test triangle wave.""" + amp = 0.5 + freq = 0.2 + samples = 101 + times, dt = np.linspace(0, 10, samples, retstep=True) + triangle_arr = continuous.triangle(times, amp=amp, freq=freq) + # with new phase + triangle_arr_phased = continuous.triangle(times, amp=amp, freq=freq, phase=np.pi / 2) + + self.assertEqual(triangle_arr.dtype, np.complex128) + + self.assertAlmostEqual(triangle_arr[0], 0.0) + # test slope + self.assertAlmostEqual((triangle_arr[1] - triangle_arr[0]) / dt, 4 * amp * freq) + self.assertAlmostEqual(triangle_arr[12], 0.48) + self.assertAlmostEqual(triangle_arr[13], 0.48) + self.assertAlmostEqual(triangle_arr[50], 0.0) + self.assertAlmostEqual(triangle_arr_phased[0], amp) + # Assert bounded between -amp and amp + self.assertTrue(np.all((-amp <= triangle_arr) & (triangle_arr <= amp))) + self.assertEqual(len(triangle_arr), samples) + + def test_cos(self): + """Test cosine wave.""" + amp = 0.5 + period = 5 + freq = 1 / period + samples = 101 + times = np.linspace(0, 10, samples) + cos_arr = continuous.cos(times, amp=amp, freq=freq) + # with new phase + cos_arr_phased = continuous.cos(times, amp=amp, freq=freq, phase=np.pi / 2) + + self.assertEqual(cos_arr.dtype, np.complex128) + + # Assert starts at 1 + self.assertAlmostEqual(cos_arr[0], amp) + self.assertAlmostEqual(cos_arr[6], 0.3644, places=2) + self.assertAlmostEqual(cos_arr[25], -amp) + self.assertAlmostEqual(cos_arr[50], amp) + self.assertAlmostEqual(cos_arr_phased[0], 0.0) + # Assert bounded between -amp and amp + self.assertTrue(np.all((-amp <= cos_arr) & (cos_arr <= amp))) + self.assertEqual(len(cos_arr), samples) + + def test_sin(self): + """Test sine wave.""" + amp = 0.5 + period = 5 + freq = 1 / period + samples = 101 + times = np.linspace(0, 10, samples) + sin_arr = continuous.sin(times, amp=amp, freq=freq) + # with new phase + sin_arr_phased = continuous.sin(times, amp=0.5, freq=1 / 5, phase=np.pi / 2) + + self.assertEqual(sin_arr.dtype, np.complex128) + + # Assert starts at 1 + self.assertAlmostEqual(sin_arr[0], 0.0) + self.assertAlmostEqual(sin_arr[6], 0.3427, places=2) + self.assertAlmostEqual(sin_arr[25], 0.0) + self.assertAlmostEqual(sin_arr[13], amp, places=2) + self.assertAlmostEqual(sin_arr_phased[0], amp) + # Assert bounded between -amp and amp + self.assertTrue(np.all((-amp <= sin_arr) & (sin_arr <= amp))) + self.assertEqual(len(sin_arr), samples) + + def test_gaussian(self): + """Test gaussian pulse.""" + amp = 0.5 + duration = 20 + center = duration / 2 + sigma = 2 + times, dt = np.linspace(0, duration, 1001, retstep=True) + gaussian_arr = continuous.gaussian(times, amp, center, sigma) + gaussian_arr_zeroed = continuous.gaussian( + np.array([-1, center, duration + 1]), + amp, + center, + sigma, + zeroed_width=2 * (center + 1), + rescale_amp=True, + ) + + self.assertEqual(gaussian_arr.dtype, np.complex128) + + center_time = np.argmax(gaussian_arr) + self.assertAlmostEqual(times[center_time], center) + self.assertAlmostEqual(gaussian_arr[center_time], amp) + self.assertAlmostEqual(gaussian_arr_zeroed[0], 0.0, places=6) + self.assertAlmostEqual(gaussian_arr_zeroed[1], amp) + self.assertAlmostEqual(gaussian_arr_zeroed[2], 0.0, places=6) + self.assertAlmostEqual( + np.sum(gaussian_arr * dt), amp * np.sqrt(2 * np.pi * sigma**2), places=3 + ) + + def test_gaussian_deriv(self): + """Test gaussian derivative pulse.""" + amp = 0.5 + center = 10 + sigma = 2 + times, dt = np.linspace(0, 20, 1000, retstep=True) + deriv_prefactor = -(sigma**2) / (times - center) + + gaussian_deriv_arr = continuous.gaussian_deriv(times, amp, center, sigma) + gaussian_arr = gaussian_deriv_arr * deriv_prefactor + + self.assertEqual(gaussian_deriv_arr.dtype, np.complex128) + + self.assertAlmostEqual( + continuous.gaussian_deriv(np.array([0]), amp, center, sigma)[0], 0, places=5 + ) + self.assertAlmostEqual( + np.sum(gaussian_arr * dt), amp * np.sqrt(2 * np.pi * sigma**2), places=3 + ) + + def test_sech(self): + """Test sech pulse.""" + amp = 0.5 + duration = 40 + center = duration / 2 + sigma = 2 + times, dt = np.linspace(0, duration, 1001, retstep=True) + sech_arr = continuous.sech(times, amp, center, sigma) + sech_arr_zeroed = continuous.sech(np.array([-1, center, duration + 1]), amp, center, sigma) + + self.assertEqual(sech_arr.dtype, np.complex128) + + center_time = np.argmax(sech_arr) + self.assertAlmostEqual(times[center_time], center) + self.assertAlmostEqual(sech_arr[center_time], amp) + self.assertAlmostEqual(sech_arr_zeroed[0], 0.0, places=2) + self.assertAlmostEqual(sech_arr_zeroed[1], amp) + self.assertAlmostEqual(sech_arr_zeroed[2], 0.0, places=2) + self.assertAlmostEqual(np.sum(sech_arr * dt), amp * np.pi * sigma, places=3) + + def test_sech_deriv(self): + """Test sech derivative pulse.""" + amp = 0.5 + center = 20 + sigma = 2 + times = np.linspace(0, 40, 1000) + + sech_deriv_arr = continuous.sech_deriv(times, amp, center, sigma) + + self.assertEqual(sech_deriv_arr.dtype, np.complex128) + + self.assertAlmostEqual( + continuous.sech_deriv(np.array([0]), amp, center, sigma)[0], 0, places=3 + ) + + def test_gaussian_square(self): + """Test gaussian square pulse.""" + amp = 0.5 + center = 10 + width = 2 + sigma = 0.1 + times, dt = np.linspace(0, 20, 2001, retstep=True) + gaussian_square_arr = continuous.gaussian_square(times, amp, center, width, sigma) + + self.assertEqual(gaussian_square_arr.dtype, np.complex128) + + self.assertEqual(gaussian_square_arr[1000], amp) + # test half gaussian rise/fall + self.assertAlmostEqual( + np.sum(gaussian_square_arr[:900] * dt) * 2, + amp * np.sqrt(2 * np.pi * sigma**2), + places=2, + ) + self.assertAlmostEqual( + np.sum(gaussian_square_arr[1100:] * dt) * 2, + amp * np.sqrt(2 * np.pi * sigma**2), + places=2, + ) + # test for continuity at gaussian/square boundaries + gauss_rise_end_time = center - width / 2 + gauss_fall_start_time = center + width / 2 + epsilon = 0.01 + rise_times, dt_rise = np.linspace( + gauss_rise_end_time - epsilon, gauss_rise_end_time + epsilon, 1001, retstep=True + ) + fall_times, dt_fall = np.linspace( + gauss_fall_start_time - epsilon, gauss_fall_start_time + epsilon, 1001, retstep=True + ) + gaussian_square_rise_arr = continuous.gaussian_square(rise_times, amp, center, width, sigma) + gaussian_square_fall_arr = continuous.gaussian_square(fall_times, amp, center, width, sigma) + + # should be locally approximated by amp*dt^2/(2*sigma^2) + self.assertAlmostEqual( + amp * dt_rise**2 / (2 * sigma**2), + gaussian_square_rise_arr[500] - gaussian_square_rise_arr[499], + ) + self.assertAlmostEqual( + amp * dt_fall**2 / (2 * sigma**2), + gaussian_square_fall_arr[501] - gaussian_square_fall_arr[500], + ) + + def test_drag(self): + """Test drag pulse.""" + amp = 0.5 + center = 10 + sigma = 0.1 + beta = 0 + times = np.linspace(0, 20, 2001) + # test that we recover gaussian for beta=0 + gaussian_arr = continuous.gaussian( + times, amp, center, sigma, zeroed_width=2 * (center + 1), rescale_amp=True + ) + + drag_arr = continuous.drag( + times, amp, center, sigma, beta=beta, zeroed_width=2 * (center + 1), rescale_amp=True + ) + + self.assertEqual(drag_arr.dtype, np.complex128) + + np.testing.assert_equal(drag_arr, gaussian_arr) diff --git a/test/dynamics/pulse/test_experiment_configurations.py b/test/dynamics/pulse/test_experiment_configurations.py new file mode 100644 index 000000000..0504c8f41 --- /dev/null +++ b/test/dynamics/pulse/test_experiment_configurations.py @@ -0,0 +1,204 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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 cases for the experimental conditions for pulse.""" +import unittest +import numpy as np + +from qiskit_dynamics.pulse.channels import DriveChannel, MeasureChannel, AcquireChannel +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse import LoConfig, LoRange, Kernel, Discriminator +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestLoRange(QiskitTestCase): + """Test LO LoRange.""" + + def test_properties_includes_and_eq(self): + """Test creation of LoRange. Test upper/lower bounds and includes. + Test __eq__ for two same and different LoRange's. + """ + lo_range_1 = LoRange(lower_bound=-0.1, upper_bound=+0.1) + + self.assertEqual(lo_range_1.lower_bound, -0.1) + self.assertEqual(lo_range_1.upper_bound, +0.1) + self.assertTrue(lo_range_1.includes(0.0)) + + lo_range_2 = LoRange(lower_bound=-0.1, upper_bound=+0.1) + lo_range_3 = LoRange(lower_bound=-0.2, upper_bound=+0.2) + + self.assertTrue(lo_range_1 == lo_range_2) + self.assertFalse(lo_range_1 == lo_range_3) + + +class TestLoConfig(QiskitTestCase): + """LoConfig tests.""" + + def test_can_create_empty_user_lo_config(self): + """Test if a LoConfig can be created without no arguments.""" + user_lo_config = LoConfig() + self.assertEqual({}, user_lo_config.qubit_los) + self.assertEqual({}, user_lo_config.meas_los) + + def test_can_create_valid_user_lo_config(self): + """Test if a LoConfig can be created with valid user_los.""" + channel1 = DriveChannel(0) + channel2 = MeasureChannel(0) + user_lo_config = LoConfig({channel1: 1.4, channel2: 3.6}) + self.assertEqual(1.4, user_lo_config.qubit_los[channel1]) + self.assertEqual(3.6, user_lo_config.meas_los[channel2]) + + def test_fail_to_create_with_out_of_range_user_lo(self): + """Test if a LoConfig cannot be created with invalid user_los.""" + channel = DriveChannel(0) + with self.assertRaises(PulseError): + LoConfig({channel: 3.3}, {channel: (1.0, 2.0)}) + + def test_fail_to_create_with_invalid_channel(self): + """Test if a LoConfig cannot be created with invalid channel.""" + channel = AcquireChannel(0) + with self.assertRaises(PulseError): + LoConfig({channel: 1.0}) + + def test_keep_dict_unchanged_after_updating_the_dict_used_in_construction(self): + """Test if a LoConfig keeps its dictionary unchanged even after + the dictionary used in construction is updated. + """ + channel = DriveChannel(0) + original = {channel: 3.4} + user_lo_config = LoConfig(original) + self.assertEqual(3.4, user_lo_config.qubit_los[channel]) + original[channel] = 5.6 + self.assertEqual(3.4, user_lo_config.qubit_los[channel]) + + def test_get_channel_lo(self): + """Test retrieving channel lo from LO config.""" + channel = DriveChannel(0) + lo_config = LoConfig({channel: 1.0}) + self.assertEqual(lo_config.channel_lo(channel), 1.0) + + channel = MeasureChannel(0) + lo_config = LoConfig({channel: 2.0}) + self.assertEqual(lo_config.channel_lo(channel), 2.0) + + with self.assertRaises(PulseError): + lo_config.channel_lo(MeasureChannel(1)) + + +class TestKernel(QiskitTestCase): + """Test Kernel.""" + + def test_eq(self): + """Test if two kernels are equal.""" + kernel_a = Kernel( + "kernel_test", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + kernel_b = Kernel( + "kernel_test", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + self.assertTrue(kernel_a == kernel_b) + + def test_neq_name(self): + """Test if two kernels with different names are not equal.""" + kernel_a = Kernel( + "kernel_test", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + kernel_b = Kernel( + "kernel_test_2", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + self.assertFalse(kernel_a == kernel_b) + + def test_neq_params(self): + """Test if two kernels with different parameters are not equal.""" + kernel_a = Kernel( + "kernel_test", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + kernel_b = Kernel( + "kernel_test", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[1, 0], + ) + self.assertFalse(kernel_a == kernel_b) + + def test_neq_nested_params(self): + """Test if two kernels with different nested parameters are not equal.""" + kernel_a = Kernel( + "kernel_test", + kernel={"real": np.zeros(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + kernel_b = Kernel( + "kernel_test", + kernel={"real": np.ones(10), "imag": np.zeros(10)}, + bias=[0, 0], + ) + self.assertFalse(kernel_a == kernel_b) + + +class TestDiscriminator(QiskitTestCase): + """Test Discriminator.""" + + def test_eq(self): + """Test if two discriminators are equal.""" + discriminator_a = Discriminator( + "discriminator_test", + discriminator_type="linear", + params=[1, 0], + ) + discriminator_b = Discriminator( + "discriminator_test", + discriminator_type="linear", + params=[1, 0], + ) + self.assertTrue(discriminator_a == discriminator_b) + + def test_neq_name(self): + """Test if two discriminators with different names are not equal.""" + discriminator_a = Discriminator( + "discriminator_test", + discriminator_type="linear", + params=[1, 0], + ) + discriminator_b = Discriminator( + "discriminator_test_2", + discriminator_type="linear", + params=[1, 0], + ) + self.assertFalse(discriminator_a == discriminator_b) + + def test_neq_params(self): + """Test if two discriminators with different parameters are not equal.""" + discriminator_a = Discriminator( + "discriminator_test", + discriminator_type="linear", + params=[1, 0], + ) + discriminator_b = Discriminator( + "discriminator_test", + discriminator_type="non-linear", + params=[0, 0], + ) + self.assertFalse(discriminator_a == discriminator_b) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/dynamics/pulse/test_instruction_schedule_map.py b/test/dynamics/pulse/test_instruction_schedule_map.py new file mode 100644 index 000000000..b7f170e85 --- /dev/null +++ b/test/dynamics/pulse/test_instruction_schedule_map.py @@ -0,0 +1,634 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019, 2024. +# +# 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 InstructionScheduleMap.""" +import copy +import pickle + +import numpy as np + +from qiskit.circuit.library.standard_gates import U1Gate, U3Gate, CXGate, XGate +from qiskit.circuit.parameter import Parameter +from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit_dynamics.pulse import ( + InstructionScheduleMap, + Play, + PulseError, + Schedule, + ScheduleBlock, + Waveform, + ShiftPhase, + Constant, +) +from qiskit_dynamics.pulse.calibration_entries import CalibrationPublisher +from qiskit_dynamics.pulse.channels import DriveChannel +from qiskit.qobj import PulseQobjInstruction +from qiskit.qobj.converters import QobjToInstructionConverter +from qiskit.providers.fake_provider import FakeOpenPulse2Q, Fake7QPulseV1 +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestInstructionScheduleMap(QiskitTestCase): + """Test the InstructionScheduleMap.""" + + def test_add(self): + """Test add, and that errors are raised when expected.""" + sched = Schedule() + sched.append(Play(Waveform(np.ones(5)), DriveChannel(0)), inplace=True) + inst_map = InstructionScheduleMap() + + inst_map.add("u1", 1, sched) + inst_map.add("u1", 0, sched) + + self.assertIn("u1", inst_map.instructions) + self.assertEqual(inst_map.qubits_with_instruction("u1"), [0, 1]) + self.assertTrue("u1" in inst_map.qubit_instructions(0)) + + with self.assertRaises(PulseError): + inst_map.add("u1", (), sched) + with self.assertRaises(PulseError): + inst_map.add("u1", 1, "not a schedule") + + def test_add_block(self): + """Test add block, and that errors are raised when expected.""" + sched = ScheduleBlock() + sched.append(Play(Waveform(np.ones(5)), DriveChannel(0)), inplace=True) + inst_map = InstructionScheduleMap() + + inst_map.add("u1", 1, sched) + inst_map.add("u1", 0, sched) + + self.assertIn("u1", inst_map.instructions) + self.assertEqual(inst_map.qubits_with_instruction("u1"), [0, 1]) + self.assertTrue("u1" in inst_map.qubit_instructions(0)) + + def test_instructions(self): + """Test `instructions`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("u1", 1, sched) + inst_map.add("u3", 0, sched) + + instructions = inst_map.instructions + for inst in ["u1", "u3"]: + self.assertTrue(inst in instructions) + + def test_has(self): + """Test `has` and `assert_has`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("u1", (0,), sched) + inst_map.add("cx", [0, 1], sched) + + self.assertTrue(inst_map.has("u1", [0])) + self.assertTrue(inst_map.has("cx", (0, 1))) + with self.assertRaises(PulseError): + inst_map.assert_has("dne", [0]) + with self.assertRaises(PulseError): + inst_map.assert_has("cx", 100) + + def test_has_from_mock(self): + """Test `has` and `assert_has` from mock data.""" + with self.assertWarns(DeprecationWarning): + backend = FakeOpenPulse2Q() + inst_map = backend.defaults().instruction_schedule_map + self.assertTrue(inst_map.has("u1", [0])) + self.assertTrue(inst_map.has("cx", (0, 1))) + self.assertTrue(inst_map.has("u3", 0)) + self.assertTrue(inst_map.has("measure", [0, 1])) + self.assertFalse(inst_map.has("u1", [0, 1])) + with self.assertRaises(PulseError): + inst_map.assert_has("dne", [0]) + with self.assertRaises(PulseError): + inst_map.assert_has("cx", 100) + + def test_qubits_with_instruction(self): + """Test `qubits_with_instruction`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("u1", (0,), sched) + inst_map.add("u1", (1,), sched) + inst_map.add("cx", [0, 1], sched) + + self.assertEqual(inst_map.qubits_with_instruction("u1"), [0, 1]) + self.assertEqual(inst_map.qubits_with_instruction("cx"), [(0, 1)]) + self.assertEqual(inst_map.qubits_with_instruction("none"), []) + + def test_qubit_instructions(self): + """Test `qubit_instructions`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("u1", (0,), sched) + inst_map.add("u1", (1,), sched) + inst_map.add("cx", [0, 1], sched) + + self.assertEqual(inst_map.qubit_instructions(0), ["u1"]) + self.assertEqual(inst_map.qubit_instructions(1), ["u1"]) + self.assertEqual(inst_map.qubit_instructions((0, 1)), ["cx"]) + self.assertEqual(inst_map.qubit_instructions(10), []) + + def test_get(self): + """Test `get`.""" + sched = Schedule() + sched.append(Play(Waveform(np.ones(5)), DriveChannel(0)), inplace=True) + inst_map = InstructionScheduleMap() + inst_map.add("x", 0, sched) + + self.assertEqual(sched, inst_map.get("x", (0,))) + + def test_get_block(self): + """Test `get` block.""" + sched = ScheduleBlock() + sched.append(Play(Waveform(np.ones(5)), DriveChannel(0)), inplace=True) + inst_map = InstructionScheduleMap() + inst_map.add("x", 0, sched) + + self.assertEqual(sched, inst_map.get("x", (0,))) + + def test_remove(self): + """Test removing a defined operation and removing an undefined operation.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("tmp", 0, sched) + inst_map.remove("tmp", 0) + self.assertFalse(inst_map.has("tmp", 0)) + with self.assertRaises(PulseError): + inst_map.remove("not_there", (0,)) + self.assertFalse("tmp" in inst_map.qubit_instructions(0)) + + def test_pop(self): + """Test pop with default.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("tmp", 100, sched) + self.assertEqual(inst_map.pop("tmp", 100), sched) + self.assertFalse(inst_map.has("tmp", 100)) + + self.assertEqual(inst_map.qubit_instructions(100), []) + self.assertEqual(inst_map.qubits_with_instruction("tmp"), []) + with self.assertRaises(PulseError): + inst_map.pop("not_there", (0,)) + + def test_add_gate(self): + """Test add, and that errors are raised when expected.""" + sched = Schedule() + sched.append(Play(Waveform(np.ones(5)), DriveChannel(0))) + inst_map = InstructionScheduleMap() + + inst_map.add(U1Gate(0), 1, sched) + inst_map.add(U1Gate(0), 0, sched) + + self.assertIn("u1", inst_map.instructions) + self.assertEqual(inst_map.qubits_with_instruction(U1Gate(0)), [0, 1]) + self.assertTrue("u1" in inst_map.qubit_instructions(0)) + + with self.assertRaises(PulseError): + inst_map.add(U1Gate(0), (), sched) + with self.assertRaises(PulseError): + inst_map.add(U1Gate(0), 1, "not a schedule") + + def test_instructions_gate(self): + """Test `instructions`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add(U1Gate(0), 1, sched) + inst_map.add(U3Gate(0, 0, 0), 0, sched) + + instructions = inst_map.instructions + for inst in ["u1", "u3"]: + self.assertTrue(inst in instructions) + + def test_has_gate(self): + """Test `has` and `assert_has`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add(U1Gate(0), (0,), sched) + inst_map.add(CXGate(), [0, 1], sched) + + self.assertTrue(inst_map.has(U1Gate(0), [0])) + self.assertTrue(inst_map.has(CXGate(), (0, 1))) + with self.assertRaises(PulseError): + inst_map.assert_has("dne", [0]) + with self.assertRaises(PulseError): + inst_map.assert_has(CXGate(), 100) + + def test_has_from_mock_gate(self): + """Test `has` and `assert_has` from mock data.""" + with self.assertWarns(DeprecationWarning): + backend = FakeOpenPulse2Q() + inst_map = backend.defaults().instruction_schedule_map + self.assertTrue(inst_map.has(U1Gate(0), [0])) + self.assertTrue(inst_map.has(CXGate(), (0, 1))) + self.assertTrue(inst_map.has(U3Gate(0, 0, 0), 0)) + self.assertTrue(inst_map.has("measure", [0, 1])) + self.assertFalse(inst_map.has(U1Gate(0), [0, 1])) + with self.assertRaises(PulseError): + inst_map.assert_has("dne", [0]) + with self.assertRaises(PulseError): + inst_map.assert_has(CXGate(), 100) + + def test_qubits_with_instruction_gate(self): + """Test `qubits_with_instruction`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add(U1Gate(0), (0,), sched) + inst_map.add(U1Gate(0), (1,), sched) + inst_map.add(CXGate(), [0, 1], sched) + + self.assertEqual(inst_map.qubits_with_instruction(U1Gate(0)), [0, 1]) + self.assertEqual(inst_map.qubits_with_instruction(CXGate()), [(0, 1)]) + self.assertEqual(inst_map.qubits_with_instruction("none"), []) + + def test_qubit_instructions_gate(self): + """Test `qubit_instructions`.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add(U1Gate(0), (0,), sched) + inst_map.add(U1Gate(0), (1,), sched) + inst_map.add(CXGate(), [0, 1], sched) + + self.assertEqual(inst_map.qubit_instructions(0), ["u1"]) + self.assertEqual(inst_map.qubit_instructions(1), ["u1"]) + self.assertEqual(inst_map.qubit_instructions((0, 1)), ["cx"]) + self.assertEqual(inst_map.qubit_instructions(10), []) + + def test_get_gate(self): + """Test `get`.""" + sched = Schedule() + sched.append(Play(Waveform(np.ones(5)), DriveChannel(0))) + inst_map = InstructionScheduleMap() + inst_map.add(XGate(), 0, sched) + + self.assertEqual(sched, inst_map.get(XGate(), (0,))) + + def test_remove_gate(self): + """Test removing a defined operation and removing an undefined operation.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add("tmp", 0, sched) + inst_map.remove("tmp", 0) + self.assertFalse(inst_map.has("tmp", 0)) + with self.assertRaises(PulseError): + inst_map.remove("not_there", (0,)) + self.assertFalse("tmp" in inst_map.qubit_instructions(0)) + + def test_pop_gate(self): + """Test pop with default.""" + sched = Schedule() + inst_map = InstructionScheduleMap() + + inst_map.add(XGate(), 100, sched) + self.assertEqual(inst_map.pop(XGate(), 100), sched) + self.assertFalse(inst_map.has(XGate(), 100)) + + self.assertEqual(inst_map.qubit_instructions(100), []) + self.assertEqual(inst_map.qubits_with_instruction(XGate()), []) + with self.assertRaises(PulseError): + inst_map.pop("not_there", (0,)) + + def test_sequenced_parameterized_schedule(self): + """Test parameterized schedule consists of multiple instruction.""" + + with self.assertWarns(DeprecationWarning): + converter = QobjToInstructionConverter([], buffer=0) + qobjs = [ + PulseQobjInstruction(name="fc", ch="d0", t0=10, phase="P1"), + PulseQobjInstruction(name="fc", ch="d0", t0=20, phase="P2"), + PulseQobjInstruction(name="fc", ch="d0", t0=30, phase="P3"), + ] + converted_instruction = [converter(qobj) for qobj in qobjs] + + inst_map = InstructionScheduleMap() + + inst_map.add("inst_seq", 0, Schedule(*converted_instruction, name="inst_seq")) + + with self.assertRaises(PulseError): + inst_map.get("inst_seq", 0, P1=1, P2=2, P3=3, P4=4, P5=5) + + with self.assertRaises(PulseError): + inst_map.get("inst_seq", 0, 1, 2, 3, 4, 5, 6, 7, 8) + + p3_expr = Parameter("p3") + p3_expr = p3_expr.bind({p3_expr: 3}) + + sched = inst_map.get("inst_seq", 0, 1, 2, p3_expr) + self.assertEqual(sched.instructions[0][-1].phase, 1) + self.assertEqual(sched.instructions[1][-1].phase, 2) + self.assertEqual(sched.instructions[2][-1].phase, 3) + + sched = inst_map.get("inst_seq", 0, P1=1, P2=2, P3=p3_expr) + self.assertEqual(sched.instructions[0][-1].phase, 1) + self.assertEqual(sched.instructions[1][-1].phase, 2) + self.assertEqual(sched.instructions[2][-1].phase, 3) + + sched = inst_map.get("inst_seq", 0, 1, 2, P3=p3_expr) + self.assertEqual(sched.instructions[0][-1].phase, 1) + self.assertEqual(sched.instructions[1][-1].phase, 2) + self.assertEqual(sched.instructions[2][-1].phase, 3) + + def test_schedule_generator(self): + """Test schedule generator functionality.""" + + dur_val = 10 + amp = 1.0 + + def test_func(dur: int): + sched = Schedule() + waveform = Constant(int(dur), amp).get_waveform() + sched += Play(waveform, DriveChannel(0)) + return sched + + expected_sched = Schedule() + cons_waveform = Constant(dur_val, amp).get_waveform() + expected_sched += Play(cons_waveform, DriveChannel(0)) + + inst_map = InstructionScheduleMap() + inst_map.add("f", (0,), test_func) + self.assertEqual(inst_map.get("f", (0,), dur_val), expected_sched) + + self.assertEqual(inst_map.get_parameters("f", (0,)), ("dur",)) + + def test_schedule_generator_supports_parameter_expressions(self): + """Test expression-based schedule generator functionality.""" + + t_param = Parameter("t") + amp = 1.0 + + def test_func(dur: ParameterExpression, t_val: int): + dur_bound = dur.bind({t_param: t_val}) + sched = Schedule() + waveform = Constant(int(float(dur_bound)), amp).get_waveform() + sched += Play(waveform, DriveChannel(0)) + return sched + + expected_sched = Schedule() + cons_waveform = Constant(10, amp).get_waveform() + expected_sched += Play(cons_waveform, DriveChannel(0)) + + inst_map = InstructionScheduleMap() + inst_map.add("f", (0,), test_func) + self.assertEqual(inst_map.get("f", (0,), dur=2 * t_param, t_val=5), expected_sched) + + self.assertEqual( + inst_map.get_parameters("f", (0,)), + ( + "dur", + "t_val", + ), + ) + + def test_schedule_with_non_alphanumeric_ordering(self): + """Test adding and getting schedule with non obvious parameter ordering.""" + theta = Parameter("theta") + phi = Parameter("phi") + lamb = Parameter("lam") + + target_sched = Schedule() + target_sched.insert(0, ShiftPhase(theta, DriveChannel(0)), inplace=True) + target_sched.insert(10, ShiftPhase(phi, DriveChannel(0)), inplace=True) + target_sched.insert(20, ShiftPhase(lamb, DriveChannel(0)), inplace=True) + + inst_map = InstructionScheduleMap() + inst_map.add("target_sched", (0,), target_sched, arguments=["theta", "phi", "lam"]) + + ref_sched = Schedule() + ref_sched.insert(0, ShiftPhase(0, DriveChannel(0)), inplace=True) + ref_sched.insert(10, ShiftPhase(1, DriveChannel(0)), inplace=True) + ref_sched.insert(20, ShiftPhase(2, DriveChannel(0)), inplace=True) + + # if parameter is alphanumerical ordering this maps to + # theta -> 2 + # phi -> 1 + # lamb -> 0 + # however non alphanumerical ordering is specified in add method thus mapping should be + # theta -> 0 + # phi -> 1 + # lamb -> 2 + test_sched = inst_map.get("target_sched", (0,), 0, 1, 2) + + for test_inst, ref_inst in zip(test_sched.instructions, ref_sched.instructions): + self.assertEqual(test_inst[0], ref_inst[0]) + self.assertEqual(test_inst[1], ref_inst[1]) + + def test_binding_too_many_parameters(self): + """Test getting schedule with too many parameter binding.""" + param = Parameter("param") + + target_sched = Schedule() + target_sched.insert(0, ShiftPhase(param, DriveChannel(0)), inplace=True) + + inst_map = InstructionScheduleMap() + inst_map.add("target_sched", (0,), target_sched) + + with self.assertRaises(PulseError): + inst_map.get("target_sched", (0,), 0, 1, 2, 3) + + def test_binding_unassigned_parameters(self): + """Test getting schedule with unassigned parameter binding.""" + param = Parameter("param") + + target_sched = Schedule() + target_sched.insert(0, ShiftPhase(param, DriveChannel(0)), inplace=True) + + inst_map = InstructionScheduleMap() + inst_map.add("target_sched", (0,), target_sched) + + with self.assertRaises(PulseError): + inst_map.get("target_sched", (0,), P0=0) + + def test_schedule_with_multiple_parameters_under_same_name(self): + """Test getting schedule with parameters that have the same name.""" + param1 = Parameter("param") + param2 = Parameter("param") + param3 = Parameter("param") + + target_sched = Schedule() + target_sched.insert(0, ShiftPhase(param1, DriveChannel(0)), inplace=True) + target_sched.insert(10, ShiftPhase(param2, DriveChannel(0)), inplace=True) + target_sched.insert(20, ShiftPhase(param3, DriveChannel(0)), inplace=True) + + inst_map = InstructionScheduleMap() + inst_map.add("target_sched", (0,), target_sched) + + ref_sched = Schedule() + ref_sched.insert(0, ShiftPhase(1.23, DriveChannel(0)), inplace=True) + ref_sched.insert(10, ShiftPhase(1.23, DriveChannel(0)), inplace=True) + ref_sched.insert(20, ShiftPhase(1.23, DriveChannel(0)), inplace=True) + + test_sched = inst_map.get("target_sched", (0,), param=1.23) + + for test_inst, ref_inst in zip(test_sched.instructions, ref_sched.instructions): + self.assertEqual(test_inst[0], ref_inst[0]) + self.assertAlmostEqual(test_inst[1], ref_inst[1]) + + def test_get_schedule_with_unbound_parameter(self): + """Test get schedule with partial binding.""" + param1 = Parameter("param1") + param2 = Parameter("param2") + + target_sched = Schedule() + target_sched.insert(0, ShiftPhase(param1, DriveChannel(0)), inplace=True) + target_sched.insert(10, ShiftPhase(param2, DriveChannel(0)), inplace=True) + + inst_map = InstructionScheduleMap() + inst_map.add("target_sched", (0,), target_sched) + + ref_sched = Schedule() + ref_sched.insert(0, ShiftPhase(param1, DriveChannel(0)), inplace=True) + ref_sched.insert(10, ShiftPhase(1.23, DriveChannel(0)), inplace=True) + + test_sched = inst_map.get("target_sched", (0,), param2=1.23) + + for test_inst, ref_inst in zip(test_sched.instructions, ref_sched.instructions): + self.assertEqual(test_inst[0], ref_inst[0]) + self.assertAlmostEqual(test_inst[1], ref_inst[1]) + + def test_partially_bound_callable(self): + """Test register partial function.""" + import functools + + def callable_schedule(par_b, par_a): + sched = Schedule() + sched.insert(10, Play(Constant(10, par_b), DriveChannel(0)), inplace=True) + sched.insert(20, Play(Constant(10, par_a), DriveChannel(0)), inplace=True) + return sched + + ref_sched = Schedule() + ref_sched.insert(10, Play(Constant(10, 0.1), DriveChannel(0)), inplace=True) + ref_sched.insert(20, Play(Constant(10, 0.2), DriveChannel(0)), inplace=True) + + inst_map = InstructionScheduleMap() + + def test_callable_sched1(par_b): + return callable_schedule(par_b, 0.2) + + inst_map.add("my_gate1", (0,), test_callable_sched1, ["par_b"]) + ret_sched = inst_map.get("my_gate1", (0,), par_b=0.1) + self.assertEqual(ret_sched, ref_sched) + + # bind partially + test_callable_sched2 = functools.partial(callable_schedule, par_a=0.2) + + inst_map.add("my_gate2", (0,), test_callable_sched2, ["par_b"]) + ret_sched = inst_map.get("my_gate2", (0,), par_b=0.1) + self.assertEqual(ret_sched, ref_sched) + + def test_two_instmaps_equal(self): + """Test eq method when two instmaps are identical.""" + with self.assertWarns(DeprecationWarning): + backend = Fake7QPulseV1() + instmap1 = backend.defaults().instruction_schedule_map + instmap2 = copy.deepcopy(instmap1) + + self.assertEqual(instmap1, instmap2) + + def test_two_instmaps_different(self): + """Test eq method when two instmaps are not identical.""" + with self.assertWarns(DeprecationWarning): + backend = Fake7QPulseV1() + instmap1 = backend.defaults().instruction_schedule_map + instmap2 = copy.deepcopy(instmap1) + + # override one of instruction + instmap2.add("sx", (0,), Schedule()) + + self.assertNotEqual(instmap1, instmap2) + + def test_instmap_picklable(self): + """Test if instmap can be pickled.""" + with self.assertWarns(DeprecationWarning): + backend = Fake7QPulseV1() + instmap = backend.defaults().instruction_schedule_map + + ser_obj = pickle.dumps(instmap) + deser_instmap = pickle.loads(ser_obj) + + self.assertEqual(instmap, deser_instmap) + + def test_instmap_picklable_with_arguments(self): + """Test instmap pickling with an edge case. + + This test attempts to pickle instmap with custom entry, + in which arguments are provided by users in the form of + python dict key object that is not picklable. + """ + with self.assertWarns(DeprecationWarning): + backend = Fake7QPulseV1() + instmap = backend.defaults().instruction_schedule_map + + param1 = Parameter("P1") + param2 = Parameter("P2") + sched = Schedule() + sched.insert(0, Play(Constant(100, param1), DriveChannel(0)), inplace=True) + sched.insert(0, Play(Constant(100, param2), DriveChannel(1)), inplace=True) + to_assign = {"P1": 0.1, "P2": 0.2} + + # Note that dict keys is not picklable + # Instmap should typecast it into list to pickle itself. + instmap.add("custom", (0, 1), sched, arguments=to_assign.keys()) + + ser_obj = pickle.dumps(instmap) + deser_instmap = pickle.loads(ser_obj) + + self.assertEqual(instmap, deser_instmap) + + def test_check_backend_provider_cals(self): + """Test if schedules provided by backend provider is distinguishable.""" + with self.assertWarns(DeprecationWarning): + backend = FakeOpenPulse2Q() + instmap = backend.defaults().instruction_schedule_map + publisher = instmap.get("u1", (0,), P0=0).metadata["publisher"] + + self.assertEqual(publisher, CalibrationPublisher.BACKEND_PROVIDER) + + def test_check_user_cals(self): + """Test if schedules provided by user is distinguishable.""" + with self.assertWarns(DeprecationWarning): + backend = FakeOpenPulse2Q() + instmap = backend.defaults().instruction_schedule_map + + test_u1 = Schedule() + test_u1 += ShiftPhase(Parameter("P0"), DriveChannel(0)) + + instmap.add("u1", (0,), test_u1, arguments=["P0"]) + publisher = instmap.get("u1", (0,), P0=0).metadata["publisher"] + + self.assertEqual(publisher, CalibrationPublisher.QISKIT) + + def test_has_custom_gate(self): + """Test method to check custom gate.""" + with self.assertWarns(DeprecationWarning): + backend = FakeOpenPulse2Q() + instmap = backend.defaults().instruction_schedule_map + + self.assertFalse(instmap.has_custom_gate()) + + # add custom schedule + some_sched = Schedule() + instmap.add("u3", (0,), some_sched) + + self.assertTrue(instmap.has_custom_gate()) + + # delete custom schedule + instmap.remove("u3", (0,)) + self.assertFalse(instmap.has_custom_gate()) diff --git a/test/dynamics/pulse/test_instructions.py b/test/dynamics/pulse/test_instructions.py new file mode 100644 index 000000000..414993f2f --- /dev/null +++ b/test/dynamics/pulse/test_instructions.py @@ -0,0 +1,325 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Unit tests for pulse instructions.""" + +import numpy as np + +from qiskit import circuit +from qiskit_dynamics.pulse import channels, configuration, instructions, library, exceptions +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestAcquire(QiskitTestCase): + """Acquisition tests.""" + + def test_can_construct_valid_acquire_command(self): + """Test if valid acquire command can be constructed.""" + kernel_opts = {"start_window": 0, "stop_window": 10} + kernel = configuration.Kernel(name="boxcar", **kernel_opts) + + discriminator_opts = { + "neighborhoods": [{"qubits": 1, "channels": 1}], + "cal": "coloring", + "resample": False, + } + discriminator = configuration.Discriminator( + name="linear_discriminator", **discriminator_opts + ) + + acq = instructions.Acquire( + 10, + channels.AcquireChannel(0), + channels.MemorySlot(0), + kernel=kernel, + discriminator=discriminator, + name="acquire", + ) + + self.assertEqual(acq.duration, 10) + self.assertEqual(acq.discriminator.name, "linear_discriminator") + self.assertEqual(acq.discriminator.params, discriminator_opts) + self.assertEqual(acq.kernel.name, "boxcar") + self.assertEqual(acq.kernel.params, kernel_opts) + self.assertIsInstance(acq.id, int) + self.assertEqual(acq.name, "acquire") + self.assertEqual( + acq.operands, + ( + 10, + channels.AcquireChannel(0), + channels.MemorySlot(0), + None, + kernel, + discriminator, + ), + ) + + def test_instructions_hash(self): + """Test hashing for acquire instruction.""" + acq_1 = instructions.Acquire( + 10, + channels.AcquireChannel(0), + channels.MemorySlot(0), + name="acquire", + ) + acq_2 = instructions.Acquire( + 10, + channels.AcquireChannel(0), + channels.MemorySlot(0), + name="acquire", + ) + + hash_1 = hash(acq_1) + hash_2 = hash(acq_2) + + self.assertEqual(hash_1, hash_2) + + +class TestDelay(QiskitTestCase): + """Delay tests.""" + + def test_delay(self): + """Test delay.""" + delay = instructions.Delay(10, channels.DriveChannel(0), name="test_name") + + self.assertIsInstance(delay.id, int) + self.assertEqual(delay.name, "test_name") + self.assertEqual(delay.duration, 10) + self.assertIsInstance(delay.duration, int) + self.assertEqual(delay.operands, (10, channels.DriveChannel(0))) + self.assertEqual(delay, instructions.Delay(10, channels.DriveChannel(0))) + self.assertNotEqual(delay, instructions.Delay(11, channels.DriveChannel(1))) + self.assertEqual(repr(delay), "Delay(10, DriveChannel(0), name='test_name')") + + # Test numpy int for duration + delay = instructions.Delay(np.int32(10), channels.DriveChannel(0), name="test_name2") + self.assertEqual(delay.duration, 10) + self.assertIsInstance(delay.duration, np.integer) + + def test_operator_delay(self): + """Test Operator(delay).""" + from qiskit.circuit import QuantumCircuit + from qiskit.quantum_info import Operator + + circ = QuantumCircuit(1) + circ.delay(10) + op_delay = Operator(circ) + + expected = QuantumCircuit(1) + expected.id(0) + op_identity = Operator(expected) + self.assertEqual(op_delay, op_identity) + + +class TestSetFrequency(QiskitTestCase): + """Set frequency tests.""" + + def test_freq(self): + """Test set frequency basic functionality.""" + set_freq = instructions.SetFrequency(4.5e9, channels.DriveChannel(1), name="test") + + self.assertIsInstance(set_freq.id, int) + self.assertEqual(set_freq.duration, 0) + self.assertEqual(set_freq.frequency, 4.5e9) + self.assertEqual(set_freq.operands, (4.5e9, channels.DriveChannel(1))) + self.assertEqual( + set_freq, instructions.SetFrequency(4.5e9, channels.DriveChannel(1), name="test") + ) + self.assertNotEqual( + set_freq, instructions.SetFrequency(4.5e8, channels.DriveChannel(1), name="test") + ) + self.assertEqual(repr(set_freq), "SetFrequency(4500000000.0, DriveChannel(1), name='test')") + + def test_freq_non_pulse_channel(self): + """Test set frequency constructor with illegal channel""" + with self.assertRaises(exceptions.PulseError): + instructions.SetFrequency(4.5e9, channels.RegisterSlot(1), name="test") + + def test_parameter_expression(self): + """Test getting all parameters assigned by expression.""" + p1 = circuit.Parameter("P1") + p2 = circuit.Parameter("P2") + expr = p1 + p2 + + instr = instructions.SetFrequency(expr, channel=channels.DriveChannel(0)) + self.assertSetEqual(instr.parameters, {p1, p2}) + + +class TestShiftFrequency(QiskitTestCase): + """Shift frequency tests.""" + + def test_shift_freq(self): + """Test shift frequency basic functionality.""" + shift_freq = instructions.ShiftFrequency(4.5e9, channels.DriveChannel(1), name="test") + + self.assertIsInstance(shift_freq.id, int) + self.assertEqual(shift_freq.duration, 0) + self.assertEqual(shift_freq.frequency, 4.5e9) + self.assertEqual(shift_freq.operands, (4.5e9, channels.DriveChannel(1))) + self.assertEqual( + shift_freq, instructions.ShiftFrequency(4.5e9, channels.DriveChannel(1), name="test") + ) + self.assertNotEqual( + shift_freq, instructions.ShiftFrequency(4.5e8, channels.DriveChannel(1), name="test") + ) + self.assertEqual( + repr(shift_freq), "ShiftFrequency(4500000000.0, DriveChannel(1), name='test')" + ) + + def test_freq_non_pulse_channel(self): + """Test shift frequency constructor with illegal channel""" + with self.assertRaises(exceptions.PulseError): + instructions.ShiftFrequency(4.5e9, channels.RegisterSlot(1), name="test") + + def test_parameter_expression(self): + """Test getting all parameters assigned by expression.""" + p1 = circuit.Parameter("P1") + p2 = circuit.Parameter("P2") + expr = p1 + p2 + + instr = instructions.ShiftFrequency(expr, channel=channels.DriveChannel(0)) + self.assertSetEqual(instr.parameters, {p1, p2}) + + +class TestSetPhase(QiskitTestCase): + """Test the instruction construction.""" + + def test_default(self): + """Test basic SetPhase.""" + set_phase = instructions.SetPhase(1.57, channels.DriveChannel(0)) + + self.assertIsInstance(set_phase.id, int) + self.assertEqual(set_phase.name, None) + self.assertEqual(set_phase.duration, 0) + self.assertEqual(set_phase.phase, 1.57) + self.assertEqual(set_phase.operands, (1.57, channels.DriveChannel(0))) + self.assertEqual( + set_phase, instructions.SetPhase(1.57, channels.DriveChannel(0), name="test") + ) + self.assertNotEqual( + set_phase, instructions.SetPhase(1.57j, channels.DriveChannel(0), name="test") + ) + self.assertEqual(repr(set_phase), "SetPhase(1.57, DriveChannel(0))") + + def test_set_phase_non_pulse_channel(self): + """Test shift phase constructor with illegal channel""" + with self.assertRaises(exceptions.PulseError): + instructions.SetPhase(1.57, channels.RegisterSlot(1), name="test") + + def test_parameter_expression(self): + """Test getting all parameters assigned by expression.""" + p1 = circuit.Parameter("P1") + p2 = circuit.Parameter("P2") + expr = p1 + p2 + + instr = instructions.SetPhase(expr, channel=channels.DriveChannel(0)) + self.assertSetEqual(instr.parameters, {p1, p2}) + + +class TestShiftPhase(QiskitTestCase): + """Test the instruction construction.""" + + def test_default(self): + """Test basic ShiftPhase.""" + shift_phase = instructions.ShiftPhase(1.57, channels.DriveChannel(0)) + + self.assertIsInstance(shift_phase.id, int) + self.assertEqual(shift_phase.name, None) + self.assertEqual(shift_phase.duration, 0) + self.assertEqual(shift_phase.phase, 1.57) + self.assertEqual(shift_phase.operands, (1.57, channels.DriveChannel(0))) + self.assertEqual( + shift_phase, instructions.ShiftPhase(1.57, channels.DriveChannel(0), name="test") + ) + self.assertNotEqual( + shift_phase, instructions.ShiftPhase(1.57j, channels.DriveChannel(0), name="test") + ) + self.assertEqual(repr(shift_phase), "ShiftPhase(1.57, DriveChannel(0))") + + def test_shift_phase_non_pulse_channel(self): + """Test shift phase constructor with illegal channel""" + with self.assertRaises(exceptions.PulseError): + instructions.ShiftPhase(1.57, channels.RegisterSlot(1), name="test") + + def test_parameter_expression(self): + """Test getting all parameters assigned by expression.""" + p1 = circuit.Parameter("P1") + p2 = circuit.Parameter("P2") + expr = p1 + p2 + + instr = instructions.ShiftPhase(expr, channel=channels.DriveChannel(0)) + self.assertSetEqual(instr.parameters, {p1, p2}) + + +class TestSnapshot(QiskitTestCase): + """Snapshot tests.""" + + def test_default(self): + """Test default snapshot.""" + snapshot = instructions.Snapshot(label="test_name", snapshot_type="state") + + self.assertIsInstance(snapshot.id, int) + self.assertEqual(snapshot.name, "test_name") + self.assertEqual(snapshot.type, "state") + self.assertEqual(snapshot.duration, 0) + self.assertNotEqual(snapshot, instructions.Delay(10, channels.DriveChannel(0))) + self.assertEqual(repr(snapshot), "Snapshot(test_name, state, name='test_name')") + + +class TestPlay(QiskitTestCase): + """Play tests.""" + + def setUp(self): + """Setup play tests.""" + super().setUp() + self.duration = 4 + self.pulse_op = library.Waveform([1.0] * self.duration, name="test") + + def test_play(self): + """Test basic play instruction.""" + play = instructions.Play(self.pulse_op, channels.DriveChannel(1)) + + self.assertIsInstance(play.id, int) + self.assertEqual(play.name, self.pulse_op.name) + self.assertEqual(play.duration, self.duration) + self.assertEqual( + repr(play), + "Play(Waveform(array([1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j]), name='test')," + " DriveChannel(1), name='test')", + ) + + def test_play_non_pulse_ch_raises(self): + """Test that play instruction on non-pulse channel raises a pulse error.""" + with self.assertRaises(exceptions.PulseError): + instructions.Play(self.pulse_op, channels.AcquireChannel(0)) + + +class TestDirectives(QiskitTestCase): + """Test pulse directives.""" + + def test_relative_barrier(self): + """Test the relative barrier directive.""" + a0 = channels.AcquireChannel(0) + d0 = channels.DriveChannel(0) + m0 = channels.MeasureChannel(0) + u0 = channels.ControlChannel(0) + mem0 = channels.MemorySlot(0) + reg0 = channels.RegisterSlot(0) + chans = (a0, d0, m0, u0, mem0, reg0) + name = "barrier" + barrier = instructions.RelativeBarrier(*chans, name=name) + + self.assertEqual(barrier.name, name) + self.assertEqual(barrier.duration, 0) + self.assertEqual(barrier.channels, chans) + self.assertEqual(barrier.operands, chans) diff --git a/test/dynamics/pulse/test_macros.py b/test/dynamics/pulse/test_macros.py new file mode 100644 index 000000000..96fd50790 --- /dev/null +++ b/test/dynamics/pulse/test_macros.py @@ -0,0 +1,245 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019, 2024. +# +# 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 cases for Pulse Macro functions.""" + +from qiskit_dynamics.pulse import ( + Schedule, + AcquireChannel, + Acquire, + InstructionScheduleMap, + MeasureChannel, + MemorySlot, + GaussianSquare, + Play, +) +from qiskit_dynamics.pulse import macros +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit.providers.fake_provider import FakeOpenPulse2Q, Fake27QPulseV1, GenericBackendV2 +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestMeasure(QiskitTestCase): + """Pulse measure macro.""" + + def setUp(self): + super().setUp() + with self.assertWarns(DeprecationWarning): + self.backend = FakeOpenPulse2Q() + self.backend_v1 = Fake27QPulseV1() + + self.inst_map = self.backend.defaults().instruction_schedule_map + self.backend_v2 = GenericBackendV2( + num_qubits=27, + calibrate_instructions=self.backend_v1.defaults().instruction_schedule_map, + seed=42, + ) + + def test_measure(self): + """Test macro - measure.""" + sched = macros.measure(qubits=[0], backend=self.backend) + expected = Schedule( + self.inst_map.get("measure", [0, 1]).filter(channels=[MeasureChannel(0)]), + Acquire(10, AcquireChannel(0), MemorySlot(0)), + ) + self.assertEqual(sched.instructions, expected.instructions) + + def test_measure_sched_with_qubit_mem_slots(self): + """Test measure with custom qubit_mem_slots.""" + sched = macros.measure(qubits=[0], backend=self.backend, qubit_mem_slots={0: 1}) + expected = Schedule( + self.inst_map.get("measure", [0, 1]).filter(channels=[MeasureChannel(0)]), + Acquire(10, AcquireChannel(0), MemorySlot(1)), + ) + self.assertEqual(sched.instructions, expected.instructions) + + def test_measure_sched_with_meas_map(self): + """Test measure with custom meas_map as list and dict.""" + sched_with_meas_map_list = macros.measure( + qubits=[0], backend=self.backend, meas_map=[[0, 1]] + ) + sched_with_meas_map_dict = macros.measure( + qubits=[0], backend=self.backend, meas_map={0: [0, 1], 1: [0, 1]} + ) + expected = Schedule( + self.inst_map.get("measure", [0, 1]).filter(channels=[MeasureChannel(0)]), + Acquire(10, AcquireChannel(0), MemorySlot(0)), + ) + self.assertEqual(sched_with_meas_map_list.instructions, expected.instructions) + self.assertEqual(sched_with_meas_map_dict.instructions, expected.instructions) + + def test_measure_with_custom_inst_map(self): + """Test measure with custom inst_map, meas_map with measure_name.""" + q0_sched = Play(GaussianSquare(1200, 1, 0.4, 1150), MeasureChannel(0)) + q0_sched += Acquire(1200, AcquireChannel(0), MemorySlot(0)) + inst_map = InstructionScheduleMap() + inst_map.add("my_sched", 0, q0_sched) + sched = macros.measure( + qubits=[0], measure_name="my_sched", inst_map=inst_map, meas_map=[[0]] + ) + self.assertEqual(sched.instructions, q0_sched.instructions) + + with self.assertRaises(PulseError): + macros.measure(qubits=[0], measure_name="name", inst_map=inst_map, meas_map=[[0]]) + + def test_fail_measure(self): + """Test failing measure.""" + with self.assertRaises(PulseError): + macros.measure(qubits=[0], meas_map=self.backend.configuration().meas_map) + with self.assertRaises(PulseError): + macros.measure(qubits=[0], inst_map=self.inst_map) + + def test_measure_v2(self): + """Test macro - measure with backendV2.""" + sched = macros.measure(qubits=[0], backend=self.backend_v2) + expected = self.backend_v2.target.get_calibration("measure", (0,)).filter( + channels=[MeasureChannel(0), AcquireChannel(0)] + ) + self.assertEqual(sched.instructions, expected.instructions) + + def test_measure_v2_sched_with_qubit_mem_slots(self): + """Test measure with backendV2 and custom qubit_mem_slots.""" + sched = macros.measure(qubits=[0], backend=self.backend_v2, qubit_mem_slots={0: 2}) + expected = self.backend_v2.target.get_calibration("measure", (0,)).filter( + channels=[ + MeasureChannel(0), + ] + ) + measure_duration = expected.filter(instruction_types=[Play]).duration + expected += Acquire(measure_duration, AcquireChannel(0), MemorySlot(2)) + self.assertEqual(sched.instructions, expected.instructions) + + def test_measure_v2_sched_with_meas_map(self): + """Test measure with backendV2 custom meas_map as list and dict.""" + sched_with_meas_map_list = macros.measure( + qubits=[0], backend=self.backend_v2, meas_map=[[0, 1]] + ) + sched_with_meas_map_dict = macros.measure( + qubits=[0], backend=self.backend_v2, meas_map={0: [0, 1], 1: [0, 1]} + ) + expected = self.backend_v2.target.get_calibration("measure", (0,)).filter( + channels=[ + MeasureChannel(0), + ] + ) + measure_duration = expected.filter(instruction_types=[Play]).duration + expected += Acquire(measure_duration, AcquireChannel(0), MemorySlot(0)) + self.assertEqual(sched_with_meas_map_list.instructions, expected.instructions) + self.assertEqual(sched_with_meas_map_dict.instructions, expected.instructions) + + def test_multiple_measure_v2(self): + """Test macro - multiple qubit measure with backendV2.""" + sched = macros.measure(qubits=[0, 1], backend=self.backend_v2) + expected = self.backend_v2.target.get_calibration("measure", (0,)).filter( + channels=[ + MeasureChannel(0), + ] + ) + expected += self.backend_v2.target.get_calibration("measure", (1,)).filter( + channels=[ + MeasureChannel(1), + ] + ) + measure_duration = expected.filter(instruction_types=[Play]).duration + expected += Acquire(measure_duration, AcquireChannel(0), MemorySlot(0)) + expected += Acquire(measure_duration, AcquireChannel(1), MemorySlot(1)) + self.assertEqual(sched.instructions, expected.instructions) + + def test_output_with_measure_v1_and_measure_v2(self): + """Test make outputs of measure_v1 and measure_v2 consistent.""" + sched_measure_v1 = macros.measure(qubits=[0, 1], backend=self.backend_v1) + sched_measure_v2 = macros.measure(qubits=[0, 1], backend=self.backend_v2) + + self.assertEqual(sched_measure_v1.instructions, sched_measure_v2.instructions) + + def test_output_with_measure_v1_and_measure_v2_sched_with_qubit_mem_slots(self): + """Test make outputs of measure_v1 and measure_v2 with custom qubit_mem_slots consistent.""" + sched_measure_v1 = macros.measure( + qubits=[0], backend=self.backend_v1, qubit_mem_slots={0: 2} + ) + sched_measure_v2 = macros.measure( + qubits=[0], backend=self.backend_v2, qubit_mem_slots={0: 2} + ) + self.assertEqual(sched_measure_v1.instructions, sched_measure_v2.instructions) + + def test_output_with_measure_v1_and_measure_v2_sched_with_meas_map(self): + """Test make outputs of measure_v1 and measure_v2 + with custom meas_map as list and dict consistent.""" + with self.assertWarns(DeprecationWarning): + backend = Fake27QPulseV1() + num_qubits_list_measure_v1 = list(range(backend.configuration().num_qubits)) + num_qubits_list_measure_v2 = list(range(self.backend_v2.num_qubits)) + sched_with_meas_map_list_v1 = macros.measure( + qubits=[0], backend=self.backend_v1, meas_map=[num_qubits_list_measure_v1] + ) + sched_with_meas_map_dict_v1 = macros.measure( + qubits=[0], + backend=self.backend_v1, + meas_map={0: num_qubits_list_measure_v1, 1: num_qubits_list_measure_v1}, + ) + sched_with_meas_map_list_v2 = macros.measure( + qubits=[0], backend=self.backend_v2, meas_map=[num_qubits_list_measure_v2] + ) + sched_with_meas_map_dict_v2 = macros.measure( + qubits=[0], + backend=self.backend_v2, + meas_map={0: num_qubits_list_measure_v2, 1: num_qubits_list_measure_v2}, + ) + self.assertEqual( + sched_with_meas_map_list_v1.instructions, + sched_with_meas_map_list_v2.instructions, + ) + self.assertEqual( + sched_with_meas_map_dict_v1.instructions, + sched_with_meas_map_dict_v2.instructions, + ) + + def test_output_with_multiple_measure_v1_and_measure_v2(self): + """Test macro - consistent output of multiple qubit measure with backendV1 and backendV2.""" + sched_measure_v1 = macros.measure(qubits=[0, 1], backend=self.backend_v1) + sched_measure_v2 = macros.measure(qubits=[0, 1], backend=self.backend_v2) + self.assertEqual(sched_measure_v1.instructions, sched_measure_v2.instructions) + + +class TestMeasureAll(QiskitTestCase): + """Pulse measure all macro.""" + + def setUp(self): + super().setUp() + with self.assertWarns(DeprecationWarning): + self.backend_v1 = FakeOpenPulse2Q() + self.inst_map = self.backend_v1.defaults().instruction_schedule_map + self.backend_v2 = GenericBackendV2( + num_qubits=2, + calibrate_instructions=self.backend_v1.defaults().instruction_schedule_map, + seed=42, + ) + + def test_measure_all(self): + """Test measure_all function.""" + sched = macros.measure_all(self.backend_v1) + expected = Schedule(self.inst_map.get("measure", [0, 1])) + self.assertEqual(sched.instructions, expected.instructions) + + def test_measure_all_v2(self): + """Test measure_all function with backendV2.""" + sched = macros.measure_all(self.backend_v1) + expected = Schedule( + self.inst_map.get("measure", list(range(self.backend_v1.configuration().num_qubits))) + ) + self.assertEqual(sched.instructions, expected.instructions) + + def test_output_of_measure_all_with_backend_v1_and_v2(self): + """Test make outputs of measure_all with backendV1 and backendV2 consistent.""" + sched_measure_v1 = macros.measure_all(backend=self.backend_v1) + sched_measure_v2 = macros.measure_all(backend=self.backend_v2) + self.assertEqual(sched_measure_v1.instructions, sched_measure_v2.instructions) diff --git a/test/dynamics/pulse/test_parameter_manager.py b/test/dynamics/pulse/test_parameter_manager.py new file mode 100644 index 000000000..7ddd365db --- /dev/null +++ b/test/dynamics/pulse/test_parameter_manager.py @@ -0,0 +1,761 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. + +# pylint: disable=invalid-name + +"""Test cases for parameter manager.""" + +from copy import deepcopy +from unittest.mock import patch + +import ddt +import numpy as np + +from qiskit import pulse +from qiskit.circuit import Parameter, ParameterVector +from qiskit_dynamics.pulse.exceptions import PulseError, UnassignedDurationError +from qiskit_dynamics.pulse.parameter_manager import ParameterGetter, ParameterSetter +from qiskit_dynamics.pulse.transforms import AlignEquispaced, AlignLeft, inline_subroutines +from qiskit_dynamics.pulse.utils import format_parameter_value +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class ParameterTestBase(QiskitTestCase): + """A base class for parameter manager unittest, providing test schedule.""" + + def setUp(self): + """Just some useful, reusable Parameters, constants, schedules.""" + super().setUp() + + self.amp1_1 = Parameter("amp1_1") + self.amp1_2 = Parameter("amp1_2") + self.amp2 = Parameter("amp2") + self.amp3 = Parameter("amp3") + + self.dur1 = Parameter("dur1") + self.dur2 = Parameter("dur2") + self.dur3 = Parameter("dur3") + + self.parametric_waveform1 = pulse.Gaussian( + duration=self.dur1, amp=self.amp1_1 + self.amp1_2, sigma=self.dur1 / 4 + ) + + self.parametric_waveform2 = pulse.Gaussian( + duration=self.dur2, amp=self.amp2, sigma=self.dur2 / 5 + ) + + self.parametric_waveform3 = pulse.Gaussian( + duration=self.dur3, amp=self.amp3, sigma=self.dur3 / 6 + ) + + self.ch1 = Parameter("ch1") + self.ch2 = Parameter("ch2") + self.ch3 = Parameter("ch3") + + self.d1 = pulse.DriveChannel(self.ch1) + self.d2 = pulse.DriveChannel(self.ch2) + self.d3 = pulse.DriveChannel(self.ch3) + + self.phi1 = Parameter("phi1") + self.phi2 = Parameter("phi2") + self.phi3 = Parameter("phi3") + + self.meas_dur = Parameter("meas_dur") + self.mem1 = Parameter("s1") + self.reg1 = Parameter("m1") + + self.context_dur = Parameter("context_dur") + + # schedule under test + subroutine = pulse.ScheduleBlock(alignment_context=AlignLeft()) + subroutine += pulse.ShiftPhase(self.phi1, self.d1) + subroutine += pulse.Play(self.parametric_waveform1, self.d1) + + long_schedule = pulse.ScheduleBlock( + alignment_context=AlignEquispaced(self.context_dur), name="long_schedule" + ) + + long_schedule += subroutine + long_schedule += pulse.ShiftPhase(self.phi2, self.d2) + long_schedule += pulse.Play(self.parametric_waveform2, self.d2) + long_schedule += pulse.ShiftPhase(self.phi3, self.d3) + long_schedule += pulse.Play(self.parametric_waveform3, self.d3) + + long_schedule += pulse.Acquire( + self.meas_dur, + pulse.AcquireChannel(self.ch1), + mem_slot=pulse.MemorySlot(self.mem1), + reg_slot=pulse.RegisterSlot(self.reg1), + ) + + self.test_sched = long_schedule + + +class TestParameterGetter(ParameterTestBase): + """Test getting parameters.""" + + def test_get_parameter_from_channel(self): + """Test get parameters from channel.""" + test_obj = pulse.DriveChannel(self.ch1 + self.ch2) + + visitor = ParameterGetter() + visitor.visit(test_obj) + + ref_params = {self.ch1, self.ch2} + + self.assertSetEqual(visitor.parameters, ref_params) + + def test_get_parameter_from_pulse(self): + """Test get parameters from pulse instruction.""" + test_obj = self.parametric_waveform1 + + visitor = ParameterGetter() + visitor.visit(test_obj) + + ref_params = {self.amp1_1, self.amp1_2, self.dur1} + + self.assertSetEqual(visitor.parameters, ref_params) + + def test_get_parameter_from_acquire(self): + """Test get parameters from acquire instruction.""" + test_obj = pulse.Acquire(16000, pulse.AcquireChannel(self.ch1), pulse.MemorySlot(self.ch1)) + + visitor = ParameterGetter() + visitor.visit(test_obj) + + ref_params = {self.ch1} + + self.assertSetEqual(visitor.parameters, ref_params) + + def test_get_parameter_from_inst(self): + """Test get parameters from instruction.""" + test_obj = pulse.ShiftPhase(self.phi1 + self.phi2, pulse.DriveChannel(0)) + + visitor = ParameterGetter() + visitor.visit(test_obj) + + ref_params = {self.phi1, self.phi2} + + self.assertSetEqual(visitor.parameters, ref_params) + + def test_with_function(self): + """Test ParameterExpressions formed trivially in a function.""" + + def get_shift(variable): + return variable - 1 + + test_obj = pulse.ShiftPhase(get_shift(self.phi1), self.d1) + + visitor = ParameterGetter() + visitor.visit(test_obj) + + ref_params = {self.phi1, self.ch1} + + self.assertSetEqual(visitor.parameters, ref_params) + + def test_get_parameter_from_alignment_context(self): + """Test get parameters from alignment context.""" + test_obj = AlignEquispaced(duration=self.context_dur + self.dur1) + + visitor = ParameterGetter() + visitor.visit(test_obj) + + ref_params = {self.context_dur, self.dur1} + + self.assertSetEqual(visitor.parameters, ref_params) + + def test_get_parameter_from_complex_schedule(self): + """Test get parameters from complicated schedule.""" + test_block = deepcopy(self.test_sched) + + visitor = ParameterGetter() + visitor.visit(test_block) + + self.assertEqual(len(visitor.parameters), 17) + + +class TestParameterSetter(ParameterTestBase): + """Test setting parameters.""" + + def test_set_parameter_to_channel(self): + """Test set parameters from channel.""" + test_obj = pulse.DriveChannel(self.ch1 + self.ch2) + + value_dict = {self.ch1: 1, self.ch2: 2} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_obj = pulse.DriveChannel(3) + + self.assertEqual(assigned, ref_obj) + + def test_set_parameter_to_pulse(self): + """Test set parameters from pulse instruction.""" + test_obj = self.parametric_waveform1 + + value_dict = {self.amp1_1: 0.1, self.amp1_2: 0.2, self.dur1: 160} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_obj = pulse.Gaussian(duration=160, amp=0.3, sigma=40) + + self.assertEqual(assigned, ref_obj) + + def test_set_parameter_to_acquire(self): + """Test set parameters to acquire instruction.""" + test_obj = pulse.Acquire(16000, pulse.AcquireChannel(self.ch1), pulse.MemorySlot(self.ch1)) + + value_dict = {self.ch1: 2} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_obj = pulse.Acquire(16000, pulse.AcquireChannel(2), pulse.MemorySlot(2)) + + self.assertEqual(assigned, ref_obj) + + def test_set_parameter_to_inst(self): + """Test get parameters from instruction.""" + test_obj = pulse.ShiftPhase(self.phi1 + self.phi2, pulse.DriveChannel(0)) + + value_dict = {self.phi1: 0.123, self.phi2: 0.456} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_obj = pulse.ShiftPhase(0.579, pulse.DriveChannel(0)) + + self.assertEqual(assigned, ref_obj) + + def test_with_function(self): + """Test ParameterExpressions formed trivially in a function.""" + + def get_shift(variable): + return variable - 1 + + test_obj = pulse.ShiftPhase(get_shift(self.phi1), self.d1) + + value_dict = {self.phi1: 2.0, self.ch1: 2} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_obj = pulse.ShiftPhase(1.0, pulse.DriveChannel(2)) + + self.assertEqual(assigned, ref_obj) + + def test_set_parameter_to_alignment_context(self): + """Test get parameters from alignment context.""" + test_obj = AlignEquispaced(duration=self.context_dur + self.dur1) + + value_dict = {self.context_dur: 1000, self.dur1: 100} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_obj = AlignEquispaced(duration=1100) + + self.assertEqual(assigned, ref_obj) + + def test_nested_assignment_partial_bind(self): + """Test nested schedule with call instruction. + Inline the schedule and partially bind parameters.""" + context = AlignEquispaced(duration=self.context_dur) + subroutine = pulse.ScheduleBlock(alignment_context=context) + subroutine += pulse.Play(self.parametric_waveform1, self.d1) + + nested_block = pulse.ScheduleBlock() + + nested_block += subroutine + + test_obj = pulse.ScheduleBlock() + test_obj += nested_block + + test_obj = inline_subroutines(test_obj) + + value_dict = {self.context_dur: 1000, self.dur1: 200, self.ch1: 1} + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + ref_context = AlignEquispaced(duration=1000) + ref_subroutine = pulse.ScheduleBlock(alignment_context=ref_context) + ref_subroutine += pulse.Play( + pulse.Gaussian(200, self.amp1_1 + self.amp1_2, 50), pulse.DriveChannel(1) + ) + + ref_nested_block = pulse.ScheduleBlock() + ref_nested_block += ref_subroutine + + ref_obj = pulse.ScheduleBlock() + ref_obj += ref_nested_block + + self.assertEqual(assigned, ref_obj) + + def test_complex_valued_parameter(self): + """Test complex valued parameter can be casted to a complex value, + but raises PendingDeprecationWarning..""" + amp = Parameter("amp") + test_obj = pulse.Constant(duration=160, amp=1j * amp) + + value_dict = {amp: 0.1} + + visitor = ParameterSetter(param_map=value_dict) + with self.assertWarns(PendingDeprecationWarning): + assigned = visitor.visit(test_obj) + + self.assertEqual(assigned.amp, 0.1j) + + def test_complex_value_to_parameter(self): + """Test complex value can be assigned to parameter object, + but raises PendingDeprecationWarning.""" + amp = Parameter("amp") + test_obj = pulse.Constant(duration=160, amp=amp) + + value_dict = {amp: 0.1j} + + visitor = ParameterSetter(param_map=value_dict) + with self.assertWarns(PendingDeprecationWarning): + assigned = visitor.visit(test_obj) + + self.assertEqual(assigned.amp, 0.1j) + + def test_complex_parameter_expression(self): + """Test assignment of complex-valued parameter expression to parameter, + but raises PendingDeprecationWarning.""" + amp = Parameter("amp") + + mag = Parameter("A") + phi = Parameter("phi") + + test_obj = pulse.Constant(duration=160, amp=amp) + test_obj_copy = deepcopy(test_obj) + # generate parameter expression + value_dict = {amp: mag * np.exp(1j * phi)} + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_obj) + + # generate complex value + value_dict = {mag: 0.1, phi: 0.5} + visitor = ParameterSetter(param_map=value_dict) + with self.assertWarns(PendingDeprecationWarning): + assigned = visitor.visit(assigned) + + # evaluated parameter expression: 0.0877582561890373 + 0.0479425538604203*I + value_dict = {amp: 0.1 * np.exp(0.5j)} + + visitor = ParameterSetter(param_map=value_dict) + with self.assertWarns(PendingDeprecationWarning): + ref_obj = visitor.visit(test_obj_copy) + self.assertEqual(assigned, ref_obj) + + def test_invalid_pulse_amplitude(self): + """Test that invalid parameters are still checked upon assignment.""" + amp = Parameter("amp") + + test_sched = pulse.ScheduleBlock() + test_sched.append( + pulse.Play( + pulse.Constant(160, amp=2 * amp), + pulse.DriveChannel(0), + ), + inplace=True, + ) + with self.assertRaises(PulseError): + test_sched.assign_parameters({amp: 0.6}, inplace=False) + + def test_disable_validation_parameter_assignment(self): + """Test that pulse validation can be disabled on the class level. + + Tests for representative examples. + """ + sig = Parameter("sigma") + test_sched = pulse.ScheduleBlock() + test_sched.append( + pulse.Play( + pulse.Gaussian(duration=100, amp=0.5, sigma=sig, angle=0.0), pulse.DriveChannel(0) + ), + inplace=True, + ) + with self.assertRaises(PulseError): + test_sched.assign_parameters({sig: -1.0}, inplace=False) + with patch( + "qiskit_dynamics.pulse.library.symbolic_pulses.SymbolicPulse.disable_validation", + new=True, + ): + test_sched = pulse.ScheduleBlock() + test_sched.append( + pulse.Play( + pulse.Gaussian(duration=100, amp=0.5, sigma=sig, angle=0.0), + pulse.DriveChannel(0), + ), + inplace=True, + ) + binded_sched = test_sched.assign_parameters({sig: -1.0}, inplace=False) + self.assertLess(binded_sched.instructions[0][1].pulse.sigma, 0) + + def test_set_parameter_to_complex_schedule(self): + """Test get parameters from complicated schedule.""" + test_block = deepcopy(self.test_sched) + + value_dict = { + self.amp1_1: 0.1, + self.amp1_2: 0.2, + self.amp2: 0.3, + self.amp3: 0.4, + self.dur1: 100, + self.dur2: 125, + self.dur3: 150, + self.ch1: 0, + self.ch2: 2, + self.ch3: 4, + self.phi1: 1.0, + self.phi2: 2.0, + self.phi3: 3.0, + self.meas_dur: 300, + self.mem1: 3, + self.reg1: 0, + self.context_dur: 1000, + } + + visitor = ParameterSetter(param_map=value_dict) + assigned = visitor.visit(test_block) + + # create ref schedule + subroutine = pulse.ScheduleBlock(alignment_context=AlignLeft()) + subroutine += pulse.ShiftPhase(1.0, pulse.DriveChannel(0)) + subroutine += pulse.Play(pulse.Gaussian(100, 0.3, 25), pulse.DriveChannel(0)) + + ref_obj = pulse.ScheduleBlock(alignment_context=AlignEquispaced(1000), name="long_schedule") + + ref_obj += subroutine + ref_obj += pulse.ShiftPhase(2.0, pulse.DriveChannel(2)) + ref_obj += pulse.Play(pulse.Gaussian(125, 0.3, 25), pulse.DriveChannel(2)) + ref_obj += pulse.ShiftPhase(3.0, pulse.DriveChannel(4)) + ref_obj += pulse.Play(pulse.Gaussian(150, 0.4, 25), pulse.DriveChannel(4)) + + ref_obj += pulse.Acquire( + 300, pulse.AcquireChannel(0), pulse.MemorySlot(3), pulse.RegisterSlot(0) + ) + + self.assertEqual(assigned, ref_obj) + + +class TestAssignFromProgram(QiskitTestCase): + """Test managing parameters from programs. Parameter manager is implicitly called.""" + + def test_attribute_parameters(self): + """Test the ``parameter`` attributes.""" + sigma = Parameter("sigma") + amp = Parameter("amp") + + waveform = pulse.library.Gaussian(duration=128, sigma=sigma, amp=amp) + + block = pulse.ScheduleBlock() + block += pulse.Play(waveform, pulse.DriveChannel(10)) + + ref_set = {amp, sigma} + + self.assertSetEqual(set(block.parameters), ref_set) + + def test_parametric_pulses(self): + """Test Parametric Pulses with parameters determined by ParameterExpressions + in the Play instruction.""" + sigma = Parameter("sigma") + amp = Parameter("amp") + + waveform = pulse.library.Gaussian(duration=128, sigma=sigma, amp=amp) + + block = pulse.ScheduleBlock() + block += pulse.Play(waveform, pulse.DriveChannel(10)) + block.assign_parameters({amp: 0.2, sigma: 4}, inplace=True) + + self.assertEqual(block.blocks[0].pulse.amp, 0.2) + self.assertEqual(block.blocks[0].pulse.sigma, 4.0) + + def test_parametric_pulses_with_parameter_vector(self): + """Test Parametric Pulses with parameters determined by a ParameterVector + in the Play instruction.""" + param_vec = ParameterVector("param_vec", 3) + param = Parameter("param") + + waveform = pulse.library.Gaussian(duration=128, sigma=param_vec[0], amp=param_vec[1]) + + block = pulse.ScheduleBlock() + block += pulse.Play(waveform, pulse.DriveChannel(10)) + block += pulse.ShiftPhase(param_vec[2], pulse.DriveChannel(10)) + block1 = block.assign_parameters({param_vec: [4, 0.2, 0.1]}, inplace=False) + block2 = block.assign_parameters({param_vec: [4, param, 0.1]}, inplace=False) + self.assertEqual(block1.blocks[0].pulse.amp, 0.2) + self.assertEqual(block1.blocks[0].pulse.sigma, 4.0) + self.assertEqual(block1.blocks[1].phase, 0.1) + self.assertEqual(block2.blocks[0].pulse.amp, param) + self.assertEqual(block2.blocks[0].pulse.sigma, 4.0) + self.assertEqual(block2.blocks[1].phase, 0.1) + + sched = pulse.Schedule() + sched += pulse.Play(waveform, pulse.DriveChannel(10)) + sched += pulse.ShiftPhase(param_vec[2], pulse.DriveChannel(10)) + sched1 = sched.assign_parameters({param_vec: [4, 0.2, 0.1]}, inplace=False) + sched2 = sched.assign_parameters({param_vec: [4, param, 0.1]}, inplace=False) + self.assertEqual(sched1.instructions[0][1].pulse.amp, 0.2) + self.assertEqual(sched1.instructions[0][1].pulse.sigma, 4.0) + self.assertEqual(sched1.instructions[1][1].phase, 0.1) + self.assertEqual(sched2.instructions[0][1].pulse.amp, param) + self.assertEqual(sched2.instructions[0][1].pulse.sigma, 4.0) + self.assertEqual(sched2.instructions[1][1].phase, 0.1) + + def test_pulse_assignment_with_parameter_names(self): + """Test pulse assignment with parameter names.""" + sigma = Parameter("sigma") + amp = Parameter("amp") + param_vec = ParameterVector("param_vec", 2) + + waveform = pulse.library.Gaussian(duration=128, sigma=sigma, amp=amp) + waveform2 = pulse.library.Gaussian(duration=128, sigma=40, amp=amp) + block = pulse.ScheduleBlock() + block += pulse.Play(waveform, pulse.DriveChannel(10)) + block += pulse.Play(waveform2, pulse.DriveChannel(10)) + block += pulse.ShiftPhase(param_vec[0], pulse.DriveChannel(10)) + block += pulse.ShiftPhase(param_vec[1], pulse.DriveChannel(10)) + block1 = block.assign_parameters( + {"amp": 0.2, "sigma": 4, "param_vec": [3.14, 1.57]}, inplace=False + ) + + self.assertEqual(block1.blocks[0].pulse.amp, 0.2) + self.assertEqual(block1.blocks[0].pulse.sigma, 4.0) + self.assertEqual(block1.blocks[1].pulse.amp, 0.2) + self.assertEqual(block1.blocks[2].phase, 3.14) + self.assertEqual(block1.blocks[3].phase, 1.57) + + sched = pulse.Schedule() + sched += pulse.Play(waveform, pulse.DriveChannel(10)) + sched += pulse.Play(waveform2, pulse.DriveChannel(10)) + sched += pulse.ShiftPhase(param_vec[0], pulse.DriveChannel(10)) + sched += pulse.ShiftPhase(param_vec[1], pulse.DriveChannel(10)) + sched1 = sched.assign_parameters( + {"amp": 0.2, "sigma": 4, "param_vec": [3.14, 1.57]}, inplace=False + ) + + self.assertEqual(sched1.instructions[0][1].pulse.amp, 0.2) + self.assertEqual(sched1.instructions[0][1].pulse.sigma, 4.0) + self.assertEqual(sched1.instructions[1][1].pulse.amp, 0.2) + self.assertEqual(sched1.instructions[2][1].phase, 3.14) + self.assertEqual(sched1.instructions[3][1].phase, 1.57) + + def test_pulse_assignment_with_parameter_sequence(self): + """ + Test pulse assignment with parameter sequence. Sequence can be a list or tuple of parameters, + parameter names, or parameter vectors. This is useful when using BindingsArray in + conjunction with Pulse. + """ + + sigma = Parameter("sigma") + amp = Parameter("amp") + param_vec = ParameterVector("param_vec", 2) + + waveform = pulse.library.Gaussian(duration=128, sigma=sigma, amp=amp) + waveform2 = pulse.library.Gaussian(duration=128, sigma=40, amp=amp) + block = pulse.ScheduleBlock() + block += pulse.Play(waveform, pulse.DriveChannel(10)) + block += pulse.Play(waveform2, pulse.DriveChannel(10)) + block += pulse.ShiftPhase(param_vec[0], pulse.DriveChannel(10)) + block += pulse.ShiftPhase(param_vec[1], pulse.DriveChannel(10)) + block1 = block.assign_parameters( + {("amp", "sigma", "param_vec[0]", "param_vec[1]"): [0.2, 4, 3.14, 1.57]}, inplace=False + ) + + self.assertEqual(block1.blocks[0].pulse.amp, 0.2) + self.assertEqual(block1.blocks[0].pulse.sigma, 4.0) + self.assertEqual(block1.blocks[1].pulse.amp, 0.2) + self.assertEqual(block1.blocks[2].phase, 3.14) + self.assertEqual(block1.blocks[3].phase, 1.57) + + sched = pulse.Schedule() + sched += pulse.Play(waveform, pulse.DriveChannel(10)) + sched += pulse.Play(waveform2, pulse.DriveChannel(10)) + sched += pulse.ShiftPhase(param_vec[0], pulse.DriveChannel(10)) + sched += pulse.ShiftPhase(param_vec[1], pulse.DriveChannel(10)) + sched1 = sched.assign_parameters( + {("amp", "sigma", "param_vec[0]", "param_vec[1]"): [0.2, 4, 3.14, 1.57]}, inplace=False + ) + + self.assertEqual(sched1.instructions[0][1].pulse.amp, 0.2) + self.assertEqual(sched1.instructions[0][1].pulse.sigma, 4.0) + self.assertEqual(sched1.instructions[1][1].pulse.amp, 0.2) + self.assertEqual(sched1.instructions[2][1].phase, 3.14) + self.assertEqual(sched1.instructions[3][1].phase, 1.57) + + sched2 = sched.assign_parameters( + {("amp", "sigma", "param_vec"): [0.2, 4, [3.14, 1.57]]}, inplace=False + ) + self.assertEqual(sched2.instructions[0][1].pulse.amp, 0.2) + self.assertEqual(sched2.instructions[0][1].pulse.sigma, 4.0) + self.assertEqual(sched2.instructions[1][1].pulse.amp, 0.2) + self.assertEqual(sched2.instructions[2][1].phase, 3.14) + self.assertEqual(sched2.instructions[3][1].phase, 1.57) + + +class TestScheduleTimeslots(QiskitTestCase): + """Test for edge cases of timing overlap on parametrized channels. + + Note that this test is dedicated to `Schedule` since `ScheduleBlock` implicitly + assigns instruction time t0 that doesn't overlap with existing instructions. + """ + + def test_overlapping_pulses(self): + """Test that an error is still raised when overlapping instructions are assigned.""" + param_idx = Parameter("q") + + schedule = pulse.Schedule() + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(param_idx)) + with self.assertRaises(PulseError): + schedule |= pulse.Play( + pulse.Waveform([0.5, 0.5, 0.5, 0.5]), pulse.DriveChannel(param_idx) + ) + + def test_overlapping_on_assignment(self): + """Test that assignment will catch against existing instructions.""" + param_idx = Parameter("q") + + schedule = pulse.Schedule() + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(1)) + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(param_idx)) + with self.assertRaises(PulseError): + schedule.assign_parameters({param_idx: 1}) + + def test_overlapping_on_expression_assigment_to_zero(self): + """Test constant*zero expression conflict.""" + param_idx = Parameter("q") + + schedule = pulse.Schedule() + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(param_idx)) + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(2 * param_idx)) + with self.assertRaises(PulseError): + schedule.assign_parameters({param_idx: 0}) + + def test_merging_upon_assignment(self): + """Test that schedule can match instructions on a channel.""" + param_idx = Parameter("q") + + schedule = pulse.Schedule() + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(1)) + schedule = schedule.insert( + 4, pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(param_idx)) + ) + schedule.assign_parameters({param_idx: 1}) + + self.assertEqual(schedule.ch_duration(pulse.DriveChannel(1)), 8) + self.assertEqual(schedule.channels, (pulse.DriveChannel(1),)) + + def test_overlapping_on_multiple_assignment(self): + """Test that assigning one qubit then another raises error when overlapping.""" + param_idx1 = Parameter("q1") + param_idx2 = Parameter("q2") + + schedule = pulse.Schedule() + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(param_idx1)) + schedule |= pulse.Play(pulse.Waveform([1, 1, 1, 1]), pulse.DriveChannel(param_idx2)) + schedule.assign_parameters({param_idx1: 2}) + + with self.assertRaises(PulseError): + schedule.assign_parameters({param_idx2: 2}) + + def test_cannot_build_schedule_with_unassigned_duration(self): + """Test we cannot build schedule with parameterized instructions""" + dur = Parameter("dur") + ch = pulse.DriveChannel(0) + + test_play = pulse.Play(pulse.Gaussian(dur, 0.1, dur / 4), ch) + + sched = pulse.Schedule() + with self.assertRaises(UnassignedDurationError): + sched.insert(0, test_play) + + +@ddt.ddt +class TestFormatParameter(QiskitTestCase): + """Test format_parameter_value function.""" + + def test_format_unassigned(self): + """Format unassigned parameter expression.""" + p1 = Parameter("P1") + p2 = Parameter("P2") + expr = p1 + p2 + + self.assertEqual(format_parameter_value(expr), expr) + + def test_partly_unassigned(self): + """Format partly assigned parameter expression.""" + p1 = Parameter("P1") + p2 = Parameter("P2") + expr = (p1 + p2).assign(p1, 3.0) + + self.assertEqual(format_parameter_value(expr), expr) + + @ddt.data(1, 1.0, 1.00000000001, np.int64(1)) + def test_integer(self, value): + """Format integer parameter expression.""" + p1 = Parameter("P1") + expr = p1.assign(p1, value) + out = format_parameter_value(expr) + self.assertIsInstance(out, int) + self.assertEqual(out, 1) + + @ddt.data(1.2, np.float64(1.2)) + def test_float(self, value): + """Format float parameter expression.""" + p1 = Parameter("P1") + expr = p1.assign(p1, value) + out = format_parameter_value(expr) + self.assertIsInstance(out, float) + self.assertEqual(out, 1.2) + + @ddt.data(1.2 + 3.4j, np.complex128(1.2 + 3.4j)) + def test_complex(self, value): + """Format float parameter expression.""" + p1 = Parameter("P1") + expr = p1.assign(p1, value) + out = format_parameter_value(expr) + self.assertIsInstance(out, complex) + self.assertEqual(out, 1.2 + 3.4j) + + def test_complex_rounding_error(self): + """Format float parameter expression.""" + p1 = Parameter("P1") + expr = p1.assign(p1, 1.2 + 1j * 1e-20) + out = format_parameter_value(expr) + self.assertIsInstance(out, float) + self.assertEqual(out, 1.2) + + def test_builtin_float(self): + """Format float parameter expression.""" + expr = 1.23 + out = format_parameter_value(expr) + self.assertIsInstance(out, float) + self.assertEqual(out, 1.23) + + @ddt.data(15482812500000, 8465625000000, 4255312500000) + def test_edge_case(self, edge_case_val): + """Format integer parameter expression with + a particular integer number that causes rounding error at typecast.""" + + # Numbers to be tested here are chosen randomly. + # These numbers had caused mis-typecast into float before qiskit/#11972. + + p1 = Parameter("P1") + expr = p1.assign(p1, edge_case_val) + out = format_parameter_value(expr) + self.assertIsInstance(out, int) + self.assertEqual(out, edge_case_val) diff --git a/test/dynamics/pulse/test_pulse_lib.py b/test/dynamics/pulse/test_pulse_lib.py new file mode 100644 index 000000000..e621f8fe3 --- /dev/null +++ b/test/dynamics/pulse/test_pulse_lib.py @@ -0,0 +1,899 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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. + +"""Unit tests for pulse waveforms.""" + +import unittest +from unittest.mock import patch +import numpy as np +import symengine as sym + +from qiskit.circuit import Parameter +from qiskit_dynamics.pulse.library import ( + SymbolicPulse, + ScalableSymbolicPulse, + Waveform, + Constant, + Gaussian, + GaussianSquare, + GaussianSquareDrag, + gaussian_square_echo, + GaussianDeriv, + Drag, + Sin, + Cos, + Sawtooth, + Triangle, + Square, + Sech, + SechDeriv, +) +from qiskit_dynamics.pulse import functional_pulse, PulseError +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestWaveform(QiskitTestCase): + """Waveform tests.""" + + def test_sample_pulse(self): + """Test pulse initialization.""" + n_samples = 100 + samples = np.linspace(0, 1.0, n_samples, dtype=np.complex128) + name = "test" + sample_pulse = Waveform(samples, name=name) + + self.assertEqual(sample_pulse.samples.dtype, np.complex128) + np.testing.assert_almost_equal(sample_pulse.samples, samples) + + self.assertEqual(sample_pulse.duration, n_samples) + self.assertEqual(sample_pulse.name, name) + + def test_waveform_hashing(self): + """Test waveform hashing.""" + n_samples = 100 + samples = np.linspace(0, 1.0, n_samples, dtype=np.complex128) + name = "test" + sample_pulse = Waveform(samples, name=name) + sample_pulse2 = Waveform(samples, name="test2") + + self.assertEqual({sample_pulse, sample_pulse2}, {sample_pulse}) + + def test_type_casting(self): + """Test casting of input samples to numpy array.""" + n_samples = 100 + samples_f64 = np.linspace(0, 1.0, n_samples, dtype=np.float64) + + sample_pulse_f64 = Waveform(samples_f64) + self.assertEqual(sample_pulse_f64.samples.dtype, np.complex128) + + samples_c64 = np.linspace(0, 1.0, n_samples, dtype=np.complex64) + + sample_pulse_c64 = Waveform(samples_c64) + self.assertEqual(sample_pulse_c64.samples.dtype, np.complex128) + + samples_list = np.linspace(0, 1.0, n_samples).tolist() + + sample_pulse_list = Waveform(samples_list) + self.assertEqual(sample_pulse_list.samples.dtype, np.complex128) + + def test_pulse_limits(self): + """Test that limits of pulse norm of one are enforced properly.""" + + # test norm is correct for complex128 numpy data + unit_pulse_c128 = np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000), dtype=np.complex128) + # test does not raise error + try: + Waveform(unit_pulse_c128) + except PulseError: + self.fail("Waveform incorrectly failed on approximately unit norm samples.") + + invalid_const = 1.1 + with self.assertRaises(PulseError): + Waveform(invalid_const * np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000))) + + with patch("qiskit_dynamics.pulse.library.pulse.Pulse.limit_amplitude", new=False): + wave = Waveform(invalid_const * np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000))) + self.assertGreater(np.max(np.abs(wave.samples)), 1.0) + + # Test case where data is converted to python types with complex as a list + # with form [re, im] and back to a numpy array. + # This is how the transport layer handles samples in the qobj so it is important + # to test. + unit_pulse_c64 = np.exp(1j * 2 * np.pi * np.linspace(0, 1, 1000), dtype=np.complex64) + sample_components = np.stack( + np.transpose([np.real(unit_pulse_c64), np.imag(unit_pulse_c64)]) + ) + pulse_list = sample_components.tolist() + recombined_pulse = [sample[0] + sample[1] * 1j for sample in pulse_list] + + # test does not raise error + try: + Waveform(recombined_pulse) + except PulseError: + self.fail("Waveform incorrectly failed to approximately unit norm samples.") + + +class TestSymbolicPulses(QiskitTestCase): + """Tests for all subclasses of SymbolicPulse.""" + + def test_construction(self): + """Test that symbolic pulses can be constructed without error.""" + Gaussian(duration=25, sigma=4, amp=0.5, angle=np.pi / 2) + GaussianSquare(duration=150, amp=0.2, sigma=8, width=140) + GaussianSquare(duration=150, amp=0.2, sigma=8, risefall_sigma_ratio=2.5) + Constant(duration=150, amp=0.5, angle=np.pi * 0.23) + Drag(duration=25, amp=0.6, sigma=7.8, beta=4, angle=np.pi * 0.54) + GaussianDeriv(duration=150, amp=0.2, sigma=8) + Sin(duration=25, amp=0.5, freq=0.1, phase=0.5, angle=0.5) + Cos(duration=30, amp=0.5, freq=0.1, phase=-0.5) + Sawtooth(duration=40, amp=0.5, freq=0.2, phase=3.14) + Triangle(duration=50, amp=0.5, freq=0.01, phase=0.5) + Square(duration=50, amp=0.5, freq=0.01, phase=0.5) + Sech(duration=50, amp=0.5, sigma=10) + Sech(duration=50, amp=0.5, sigma=10, zero_ends=False) + SechDeriv(duration=50, amp=0.5, sigma=10) + + def test_gauss_square_extremes(self): + """Test that the gaussian square pulse can build a gaussian.""" + duration = 125 + sigma = 4 + amp = 0.5 + angle = np.pi / 2 + gaus_square = GaussianSquare(duration=duration, sigma=sigma, amp=amp, width=0, angle=angle) + gaus = Gaussian(duration=duration, sigma=sigma, amp=amp, angle=angle) + np.testing.assert_almost_equal( + gaus_square.get_waveform().samples, gaus.get_waveform().samples + ) + gaus_square = GaussianSquare( + duration=duration, sigma=sigma, amp=amp, width=121, angle=angle + ) + const = Constant(duration=duration, amp=amp, angle=angle) + np.testing.assert_almost_equal( + gaus_square.get_waveform().samples[2:-2], const.get_waveform().samples[2:-2] + ) + + def test_gauss_square_passes_validation_after_construction(self): + """Test that parameter validation is consistent before and after construction. + + This previously used to raise an exception: see gh-7882.""" + pulse = GaussianSquare(duration=125, sigma=4, amp=0.5, width=100, angle=np.pi / 2) + pulse.validate_parameters() + + def test_gaussian_square_drag_pulse(self): + """Test that GaussianSquareDrag sample pulse matches expectations. + + Test that the real part of the envelop matches GaussianSquare and that + the rise and fall match Drag. + """ + risefall = 32 + sigma = 4 + amp = 0.5 + width = 100 + beta = 1 + duration = width + 2 * risefall + + gsd = GaussianSquareDrag(duration=duration, sigma=sigma, amp=amp, width=width, beta=beta) + gsd_samples = gsd.get_waveform().samples + + gs_pulse = GaussianSquare(duration=duration, sigma=sigma, amp=amp, width=width) + np.testing.assert_almost_equal( + np.real(gsd_samples), + np.real(gs_pulse.get_waveform().samples), + ) + gsd2 = GaussianSquareDrag( + duration=duration, + sigma=sigma, + amp=amp, + beta=beta, + risefall_sigma_ratio=risefall / sigma, + ) + np.testing.assert_almost_equal( + gsd_samples, + gsd2.get_waveform().samples, + ) + + drag_pulse = Drag(duration=2 * risefall, amp=amp, sigma=sigma, beta=beta) + np.testing.assert_almost_equal( + gsd_samples[:risefall], + drag_pulse.get_waveform().samples[:risefall], + ) + np.testing.assert_almost_equal( + gsd_samples[-risefall:], + drag_pulse.get_waveform().samples[-risefall:], + ) + + def test_gauss_square_drag_extreme(self): + """Test that the gaussian square drag pulse can build a drag pulse.""" + duration = 125 + sigma = 4 + amp = 0.5 + angle = 1.5 + beta = 1 + gsd = GaussianSquareDrag( + duration=duration, sigma=sigma, amp=amp, width=0, beta=beta, angle=angle + ) + drag = Drag(duration=duration, sigma=sigma, amp=amp, beta=beta, angle=angle) + np.testing.assert_almost_equal(gsd.get_waveform().samples, drag.get_waveform().samples) + + def test_gaussian_square_drag_validation(self): + """Test drag beta parameter validation.""" + + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=1, beta=2) + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=1, beta=4) + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=0.5, beta=20) + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=-1, beta=2) + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=1, beta=-2) + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=1, beta=6) + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=-0.5, beta=25, angle=1.5) + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=50, width=0, sigma=16, amp=1, beta=20) + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=50, width=0, sigma=4, amp=0.8, beta=20) + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=50, width=0, sigma=4, amp=0.8, beta=-20) + + def test_gaussian_square_echo_pulse(self): + """Test that gaussian_square_echo sample pulse matches expectations. + + Test that the real part of the envelop matches GaussianSquare with + given amplitude and phase active for half duration with another + GaussianSquare active for the other half duration with opposite + amplitude and a GaussianSquare active on the entire duration with + its own amplitude and phase + """ + risefall = 32 + sigma = 4 + amp = 0.5 + width = 100 + duration = width + 2 * risefall + active_amp = 0.1 + width_echo = (duration - 2 * (duration - width)) / 2 + + gse = gaussian_square_echo( + duration=duration, sigma=sigma, amp=amp, width=width, active_amp=active_amp + ) + gse_samples = gse.get_waveform().samples + + gs_echo_pulse_pos = GaussianSquare( + duration=duration / 2, sigma=sigma, amp=amp, width=width_echo + ) + gs_echo_pulse_neg = GaussianSquare( + duration=duration / 2, sigma=sigma, amp=-amp, width=width_echo + ) + gs_active_pulse = GaussianSquare( + duration=duration, sigma=sigma, amp=active_amp, width=width + ) + gs_echo_pulse_pos_samples = np.array( + gs_echo_pulse_pos.get_waveform().samples.tolist() + [0] * int(duration / 2) + ) + gs_echo_pulse_neg_samples = np.array( + [0] * int(duration / 2) + gs_echo_pulse_neg.get_waveform().samples.tolist() + ) + gs_active_pulse_samples = gs_active_pulse.get_waveform().samples + + np.testing.assert_almost_equal( + gse_samples, + gs_echo_pulse_pos_samples + gs_echo_pulse_neg_samples + gs_active_pulse_samples, + ) + + def test_gaussian_square_echo_active_amp_validation(self): + """Test gaussian square echo active amp parameter validation.""" + + gaussian_square_echo(duration=50, width=0, sigma=16, amp=0.1, active_amp=0.2) + gaussian_square_echo(duration=50, width=0, sigma=16, amp=0.1, active_amp=0.4) + gaussian_square_echo(duration=50, width=0, sigma=16, amp=0.5, active_amp=0.3) + gaussian_square_echo(duration=50, width=0, sigma=16, amp=-0.1, active_amp=0.2) + gaussian_square_echo(duration=50, width=0, sigma=16, amp=0.1, active_amp=-0.2) + gaussian_square_echo(duration=50, width=0, sigma=16, amp=0.1, active_amp=0.6) + gaussian_square_echo(duration=50, width=0, sigma=16, amp=-0.5, angle=1.5, active_amp=0.25) + with self.assertRaises(PulseError): + gaussian_square_echo(duration=50, width=0, sigma=16, amp=0.1, active_amp=1.1) + with self.assertRaises(PulseError): + gaussian_square_echo(duration=50, width=0, sigma=4, amp=-0.8, active_amp=-0.3) + + def test_drag_validation(self): + """Test drag parameter validation, specifically the beta validation.""" + duration = 25 + sigma = 4 + amp = 0.5 + angle = np.pi / 2 + beta = 1 + wf = Drag(duration=duration, sigma=sigma, amp=amp, beta=beta, angle=angle) + samples = wf.get_waveform().samples + self.assertTrue(max(np.abs(samples)) <= 1) + with self.assertRaises(PulseError): + wf = Drag(duration=duration, sigma=sigma, amp=1.2, beta=beta) + beta = sigma**2 + with self.assertRaises(PulseError): + wf = Drag(duration=duration, sigma=sigma, amp=amp, beta=beta, angle=angle) + # If sigma is high enough, side peaks fall out of range and norm restriction is met + sigma = 100 + wf = Drag(duration=duration, sigma=sigma, amp=amp, beta=beta, angle=angle) + + def test_drag_beta_validation(self): + """Test drag beta parameter validation.""" + + def check_drag(duration, sigma, amp, beta, angle=0): + wf = Drag(duration=duration, sigma=sigma, amp=amp, beta=beta, angle=angle) + samples = wf.get_waveform().samples + self.assertTrue(max(np.abs(samples)) <= 1) + + check_drag(duration=50, sigma=16, amp=1, beta=2) + check_drag(duration=50, sigma=16, amp=1, beta=4) + check_drag(duration=50, sigma=16, amp=0.5, beta=20) + check_drag(duration=50, sigma=16, amp=-1, beta=2) + check_drag(duration=50, sigma=16, amp=1, beta=-2) + check_drag(duration=50, sigma=16, amp=1, beta=6) + check_drag(duration=50, sigma=16, amp=0.5, beta=25, angle=-np.pi / 2) + with self.assertRaises(PulseError): + check_drag(duration=50, sigma=16, amp=1, beta=20) + with self.assertRaises(PulseError): + check_drag(duration=50, sigma=4, amp=0.8, beta=20) + with self.assertRaises(PulseError): + check_drag(duration=50, sigma=4, amp=0.8, beta=-20) + + def test_sin_pulse(self): + """Test that Sin creation""" + duration = 100 + amp = 0.5 + freq = 0.1 + phase = 0 + + Sin(duration=duration, amp=amp, freq=freq, phase=phase) + + with self.assertRaises(PulseError): + Sin(duration=duration, amp=amp, freq=5, phase=phase) + + def test_cos_pulse(self): + """Test that Cos creation""" + duration = 100 + amp = 0.5 + freq = 0.1 + phase = 0 + cos_pulse = Cos(duration=duration, amp=amp, freq=freq, phase=phase) + + shifted_sin_pulse = Sin(duration=duration, amp=amp, freq=freq, phase=phase + np.pi / 2) + np.testing.assert_almost_equal( + shifted_sin_pulse.get_waveform().samples, cos_pulse.get_waveform().samples + ) + with self.assertRaises(PulseError): + Cos(duration=duration, amp=amp, freq=5, phase=phase) + + def test_square_pulse(self): + """Test that Square pulse creation""" + duration = 100 + amp = 0.5 + freq = 0.1 + phase = 0.3 + Square(duration=duration, amp=amp, freq=freq, phase=phase) + + with self.assertRaises(PulseError): + Square(duration=duration, amp=amp, freq=5, phase=phase) + + def test_sawtooth_pulse(self): + """Test that Sawtooth pulse creation""" + duration = 100 + amp = 0.5 + freq = 0.1 + phase = 0.5 + sawtooth_pulse = Sawtooth(duration=duration, amp=amp, freq=freq, phase=phase) + + sawtooth_pulse_2 = Sawtooth(duration=duration, amp=amp, freq=freq, phase=phase + 2 * np.pi) + np.testing.assert_almost_equal( + sawtooth_pulse.get_waveform().samples, sawtooth_pulse_2.get_waveform().samples + ) + + with self.assertRaises(PulseError): + Sawtooth(duration=duration, amp=amp, freq=5, phase=phase) + + def test_triangle_pulse(self): + """Test that Triangle pulse creation""" + duration = 100 + amp = 0.5 + freq = 0.1 + phase = 0.5 + triangle_pulse = Triangle(duration=duration, amp=amp, freq=freq, phase=phase) + + triangle_pulse_2 = Triangle(duration=duration, amp=amp, freq=freq, phase=phase + 2 * np.pi) + np.testing.assert_almost_equal( + triangle_pulse.get_waveform().samples, triangle_pulse_2.get_waveform().samples + ) + + with self.assertRaises(PulseError): + Triangle(duration=duration, amp=amp, freq=5, phase=phase) + + def test_gaussian_deriv_pulse(self): + """Test that GaussianDeriv pulse creation""" + duration = 300 + amp = 0.5 + sigma = 100 + GaussianDeriv(duration=duration, amp=amp, sigma=sigma) + + with self.assertRaises(PulseError): + Sech(duration=duration, amp=amp, sigma=0) + + def test_sech_pulse(self): + """Test that Sech pulse creation""" + duration = 100 + amp = 0.5 + sigma = 10 + # Zero ends = True + Sech(duration=duration, amp=amp, sigma=sigma) + + # Zero ends = False + Sech(duration=duration, amp=amp, sigma=sigma, zero_ends=False) + + with self.assertRaises(PulseError): + Sech(duration=duration, amp=amp, sigma=-5) + + def test_sech_deriv_pulse(self): + """Test that SechDeriv pulse creation""" + duration = 100 + amp = 0.5 + sigma = 10 + SechDeriv(duration=duration, amp=amp, sigma=sigma) + + with self.assertRaises(PulseError): + SechDeriv(duration=duration, amp=amp, sigma=-5) + + def test_constant_samples(self): + """Test the constant pulse and its sampled construction.""" + amp = 0.6 + angle = np.pi * 0.7 + const = Constant(duration=150, amp=amp, angle=angle) + self.assertEqual(const.get_waveform().samples[0], amp * np.exp(1j * angle)) + self.assertEqual(len(const.get_waveform().samples), 150) + + def test_parameters(self): + """Test that the parameters can be extracted as a dict through the `parameters` + attribute.""" + drag = Drag(duration=25, amp=0.2, sigma=7.8, beta=4, angle=0.2) + self.assertEqual(set(drag.parameters.keys()), {"duration", "amp", "sigma", "beta", "angle"}) + const = Constant(duration=150, amp=1) + self.assertEqual(set(const.parameters.keys()), {"duration", "amp", "angle"}) + + def test_repr(self): + """Test the repr methods for symbolic pulses.""" + gaus = Gaussian(duration=25, amp=0.7, sigma=4, angle=0.3) + self.assertEqual(repr(gaus), "Gaussian(duration=25, sigma=4, amp=0.7, angle=0.3)") + gaus_square = GaussianSquare(duration=20, sigma=30, amp=1.0, width=3) + self.assertEqual( + repr(gaus_square), "GaussianSquare(duration=20, sigma=30, width=3, amp=1.0, angle=0.0)" + ) + gaus_square = GaussianSquare( + duration=20, sigma=30, amp=1.0, angle=0.2, risefall_sigma_ratio=0.1 + ) + self.assertEqual( + repr(gaus_square), + "GaussianSquare(duration=20, sigma=30, width=14.0, amp=1.0, angle=0.2)", + ) + gsd = GaussianSquareDrag(duration=20, sigma=30, amp=1.0, width=3, beta=1) + self.assertEqual( + repr(gsd), + "GaussianSquareDrag(duration=20, sigma=30, width=3, beta=1, amp=1.0, angle=0.0)", + ) + gsd = GaussianSquareDrag(duration=20, sigma=30, amp=1.0, risefall_sigma_ratio=0.1, beta=1) + self.assertEqual( + repr(gsd), + "GaussianSquareDrag(duration=20, sigma=30, width=14.0, beta=1, amp=1.0, angle=0.0)", + ) + gse = gaussian_square_echo(duration=20, sigma=30, amp=1.0, width=3) + self.assertEqual( + repr(gse), + ( + "gaussian_square_echo(duration=20, amp=1.0, angle=0.0, sigma=30, width=3," + " active_amp=0.0, active_angle=0.0)" + ), + ) + gse = gaussian_square_echo(duration=20, sigma=30, amp=1.0, risefall_sigma_ratio=0.1) + self.assertEqual( + repr(gse), + ( + "gaussian_square_echo(duration=20, amp=1.0, angle=0.0, sigma=30, width=14.0," + " active_amp=0.0, active_angle=0.0)" + ), + ) + drag = Drag(duration=5, amp=0.5, sigma=7, beta=1) + self.assertEqual(repr(drag), "Drag(duration=5, sigma=7, beta=1, amp=0.5, angle=0.0)") + const = Constant(duration=150, amp=0.1, angle=0.3) + self.assertEqual(repr(const), "Constant(duration=150, amp=0.1, angle=0.3)") + sin_pulse = Sin(duration=150, amp=0.1, angle=0.3, freq=0.2, phase=0) + self.assertEqual( + repr(sin_pulse), "Sin(duration=150, freq=0.2, phase=0, amp=0.1, angle=0.3)" + ) + cos_pulse = Cos(duration=150, amp=0.1, angle=0.3, freq=0.2, phase=0) + self.assertEqual( + repr(cos_pulse), "Cos(duration=150, freq=0.2, phase=0, amp=0.1, angle=0.3)" + ) + triangle_pulse = Triangle(duration=150, amp=0.1, angle=0.3, freq=0.2, phase=0) + self.assertEqual( + repr(triangle_pulse), "Triangle(duration=150, freq=0.2, phase=0, amp=0.1, angle=0.3)" + ) + sawtooth_pulse = Sawtooth(duration=150, amp=0.1, angle=0.3, freq=0.2, phase=0) + self.assertEqual( + repr(sawtooth_pulse), "Sawtooth(duration=150, freq=0.2, phase=0, amp=0.1, angle=0.3)" + ) + sech_pulse = Sech(duration=150, amp=0.1, angle=0.3, sigma=10) + self.assertEqual(repr(sech_pulse), "Sech(duration=150, sigma=10, amp=0.1, angle=0.3)") + sech_deriv_pulse = SechDeriv(duration=150, amp=0.1, angle=0.3, sigma=10) + self.assertEqual( + repr(sech_deriv_pulse), "SechDeriv(duration=150, sigma=10, amp=0.1, angle=0.3)" + ) + gaussian_deriv_pulse = GaussianDeriv(duration=150, amp=0.1, angle=0.3, sigma=10) + self.assertEqual( + repr(gaussian_deriv_pulse), "GaussianDeriv(duration=150, sigma=10, amp=0.1, angle=0.3)" + ) + + def test_param_validation(self): + """Test that symbolic pulse parameters are validated when initialized.""" + with self.assertRaises(PulseError): + Gaussian(duration=25, sigma=0, amp=0.5, angle=np.pi / 2) + with self.assertRaises(PulseError): + GaussianSquare(duration=150, amp=0.2, sigma=8) + with self.assertRaises(PulseError): + GaussianSquare(duration=150, amp=0.2, sigma=8, width=100, risefall_sigma_ratio=5) + with self.assertRaises(PulseError): + GaussianSquare(duration=150, amp=0.2, sigma=8, width=160) + with self.assertRaises(PulseError): + GaussianSquare(duration=150, amp=0.2, sigma=8, risefall_sigma_ratio=10) + + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=150, amp=0.2, sigma=8, beta=1) + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=150, amp=0.2, sigma=8, width=160, beta=1) + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=150, amp=0.2, sigma=8, risefall_sigma_ratio=10, beta=1) + + with self.assertRaises(PulseError): + gaussian_square_echo( + duration=150, + amp=0.2, + sigma=8, + ) + with self.assertRaises(PulseError): + gaussian_square_echo(duration=150, amp=0.2, sigma=8, width=160) + with self.assertRaises(PulseError): + gaussian_square_echo(duration=150, amp=0.2, sigma=8, risefall_sigma_ratio=10) + + with self.assertRaises(PulseError): + Constant(duration=150, amp=1.5, angle=np.pi * 0.8) + with self.assertRaises(PulseError): + Drag(duration=25, amp=0.5, sigma=-7.8, beta=4, angle=np.pi / 3) + + def test_class_level_limit_amplitude(self): + """Test that the check for amplitude less than or equal to 1 can + be disabled on the class level. + + Tests for representative examples. + """ + with self.assertRaises(PulseError): + Gaussian(duration=100, sigma=1.0, amp=1.7, angle=np.pi * 1.1) + + with patch("qiskit_dynamics.pulse.library.pulse.Pulse.limit_amplitude", new=False): + waveform = Gaussian(duration=100, sigma=1.0, amp=1.7, angle=np.pi * 1.1) + self.assertGreater(np.abs(waveform.amp), 1.0) + waveform = GaussianSquare(duration=100, sigma=1.0, amp=1.5, width=10, angle=np.pi / 5) + self.assertGreater(np.abs(waveform.amp), 1.0) + waveform = GaussianSquareDrag(duration=100, sigma=1.0, amp=1.1, beta=0.1, width=10) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_class_level_disable_validation(self): + """Test that pulse validation can be disabled on the class level. + + Tests for representative examples. + """ + with self.assertRaises(PulseError): + Gaussian(duration=100, sigma=-1.0, amp=0.5, angle=np.pi * 1.1) + + with patch( + "qiskit_dynamics.pulse.library.symbolic_pulses.SymbolicPulse.disable_validation", + new=True, + ): + waveform = Gaussian(duration=100, sigma=-1.0, amp=0.5, angle=np.pi * 1.1) + self.assertLess(waveform.sigma, 0) + waveform = GaussianSquare(duration=100, sigma=1.0, amp=0.5, width=1000, angle=np.pi / 5) + self.assertGreater(waveform.width, waveform.duration) + waveform = GaussianSquareDrag(duration=100, sigma=1.0, amp=1.1, beta=0.1, width=-1) + self.assertLess(waveform.width, 0) + + def test_gaussian_limit_amplitude_per_instance(self): + """Test limit amplitude option per Gaussian instance.""" + with self.assertRaises(PulseError): + Gaussian(duration=100, sigma=1.0, amp=1.6, angle=np.pi / 2.5) + + waveform = Gaussian( + duration=100, sigma=1.0, amp=1.6, angle=np.pi / 2.5, limit_amplitude=False + ) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_gaussian_square_limit_amplitude_per_instance(self): + """Test limit amplitude option per GaussianSquare instance.""" + with self.assertRaises(PulseError): + GaussianSquare(duration=100, sigma=1.0, amp=1.5, width=10, angle=np.pi / 3) + + waveform = GaussianSquare( + duration=100, sigma=1.0, amp=1.5, width=10, angle=np.pi / 3, limit_amplitude=False + ) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_gaussian_square_drag_limit_amplitude_per_instance(self): + """Test limit amplitude option per GaussianSquareDrag instance.""" + with self.assertRaises(PulseError): + GaussianSquareDrag(duration=100, sigma=1.0, amp=1.1, beta=0.1, width=10) + + waveform = GaussianSquareDrag( + duration=100, sigma=1.0, amp=1.1, beta=0.1, width=10, limit_amplitude=False + ) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_gaussian_square_echo_limit_amplitude_per_instance(self): + """Test limit amplitude option per GaussianSquareEcho instance.""" + with self.assertRaises(PulseError): + gaussian_square_echo(duration=1000, sigma=4.0, amp=1.01, width=100) + + waveform = gaussian_square_echo( + duration=1000, sigma=4.0, amp=1.01, width=100, limit_amplitude=False + ) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_drag_limit_amplitude_per_instance(self): + """Test limit amplitude option per DRAG instance.""" + with self.assertRaises(PulseError): + Drag(duration=100, sigma=1.0, beta=1.0, amp=1.8, angle=np.pi * 0.3) + + waveform = Drag( + duration=100, sigma=1.0, beta=1.0, amp=1.8, angle=np.pi * 0.3, limit_amplitude=False + ) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_constant_limit_amplitude_per_instance(self): + """Test limit amplitude option per Constant instance.""" + with self.assertRaises(PulseError): + Constant(duration=100, amp=1.6, angle=0.5) + + waveform = Constant(duration=100, amp=1.6, angle=0.5, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_sin_limit_amplitude_per_instance(self): + """Test limit amplitude option per Sin instance.""" + with self.assertRaises(PulseError): + Sin(duration=100, amp=1.1, phase=0) + + waveform = Sin(duration=100, amp=1.1, phase=0, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_sawtooth_limit_amplitude_per_instance(self): + """Test limit amplitude option per Sawtooth instance.""" + with self.assertRaises(PulseError): + Sawtooth(duration=100, amp=1.1, phase=0) + + waveform = Sawtooth(duration=100, amp=1.1, phase=0, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_triangle_limit_amplitude_per_instance(self): + """Test limit amplitude option per Triangle instance.""" + with self.assertRaises(PulseError): + Triangle(duration=100, amp=1.1, phase=0) + + waveform = Triangle(duration=100, amp=1.1, phase=0, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_square_limit_amplitude_per_instance(self): + """Test limit amplitude option per Square instance.""" + with self.assertRaises(PulseError): + Square(duration=100, amp=1.1, phase=0) + + waveform = Square(duration=100, amp=1.1, phase=0, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_gaussian_deriv_limit_amplitude_per_instance(self): + """Test limit amplitude option per GaussianDeriv instance.""" + with self.assertRaises(PulseError): + GaussianDeriv(duration=100, amp=5, sigma=1) + + waveform = GaussianDeriv(duration=100, amp=5, sigma=1, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp / waveform.sigma), np.exp(0.5)) + + def test_sech_limit_amplitude_per_instance(self): + """Test limit amplitude option per Sech instance.""" + with self.assertRaises(PulseError): + Sech(duration=100, amp=5, sigma=1) + + waveform = Sech(duration=100, amp=5, sigma=1, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp), 1.0) + + def test_sech_deriv_limit_amplitude_per_instance(self): + """Test limit amplitude option per SechDeriv instance.""" + with self.assertRaises(PulseError): + SechDeriv(duration=100, amp=5, sigma=1) + + waveform = SechDeriv(duration=100, amp=5, sigma=1, limit_amplitude=False) + self.assertGreater(np.abs(waveform.amp) / waveform.sigma, 2.0) + + def test_get_parameters(self): + """Test getting pulse parameters as attribute.""" + drag_pulse = Drag(duration=100, amp=0.1, sigma=40, beta=3) + self.assertEqual(drag_pulse.duration, 100) + self.assertEqual(drag_pulse.amp, 0.1) + self.assertEqual(drag_pulse.sigma, 40) + self.assertEqual(drag_pulse.beta, 3) + + with self.assertRaises(AttributeError): + _ = drag_pulse.non_existing_parameter + + def test_envelope_cache(self): + """Test speed up of instantiation with lambdify envelope cache.""" + drag_instance1 = Drag(duration=100, amp=0.1, sigma=40, beta=3) + drag_instance2 = Drag(duration=100, amp=0.1, sigma=40, beta=3) + self.assertTrue(drag_instance1._envelope_lam is drag_instance2._envelope_lam) + + def test_constraints_cache(self): + """Test speed up of instantiation with lambdify constraints cache.""" + drag_instance1 = Drag(duration=100, amp=0.1, sigma=40, beta=3) + drag_instance2 = Drag(duration=100, amp=0.1, sigma=40, beta=3) + self.assertTrue(drag_instance1._constraints_lam is drag_instance2._constraints_lam) + + def test_deepcopy(self): + """Test deep copying instance.""" + import copy + + drag = Drag(duration=100, amp=0.1, sigma=40, beta=3) + drag_copied = copy.deepcopy(drag) + + self.assertNotEqual(id(drag), id(drag_copied)) + + orig_wf = drag.get_waveform() + copied_wf = drag_copied.get_waveform() + + np.testing.assert_almost_equal(orig_wf.samples, copied_wf.samples) + + def test_fully_parametrized_pulse(self): + """Test instantiating a pulse with parameters.""" + amp = Parameter("amp") + duration = Parameter("duration") + sigma = Parameter("sigma") + beta = Parameter("beta") + + # doesn't raise an error + drag = Drag(duration=duration, amp=amp, sigma=sigma, beta=beta) + + with self.assertRaises(PulseError): + drag.get_waveform() + + # pylint: disable=invalid-name + def test_custom_pulse(self): + """Test defining a custom pulse which is not in the form of amp * F(t).""" + t, t1, t2, amp1, amp2 = sym.symbols("t, t1, t2, amp1, amp2") + envelope = sym.Piecewise((amp1, sym.And(t > t1, t < t2)), (amp2, sym.true)) + + custom_pulse = SymbolicPulse( + pulse_type="Custom", + duration=100, + parameters={"t1": 30, "t2": 80, "amp1": 0.1j, "amp2": -0.1}, + envelope=envelope, + ) + waveform = custom_pulse.get_waveform() + reference = np.concatenate([-0.1 * np.ones(30), 0.1j * np.ones(50), -0.1 * np.ones(20)]) + np.testing.assert_array_almost_equal(waveform.samples, reference) + + def test_gaussian_deprecated_type_check(self): + """Test isinstance check works with deprecation.""" + gaussian_pulse = Gaussian(160, 0.1, 40) + + self.assertTrue(isinstance(gaussian_pulse, SymbolicPulse)) + with self.assertWarns(PendingDeprecationWarning): + self.assertTrue(isinstance(gaussian_pulse, Gaussian)) + self.assertFalse(isinstance(gaussian_pulse, GaussianSquare)) + self.assertFalse(isinstance(gaussian_pulse, Drag)) + self.assertFalse(isinstance(gaussian_pulse, Constant)) + + def test_gaussian_square_deprecated_type_check(self): + """Test isinstance check works with deprecation.""" + gaussian_square_pulse = GaussianSquare(800, 0.1, 64, 544) + + self.assertTrue(isinstance(gaussian_square_pulse, SymbolicPulse)) + with self.assertWarns(PendingDeprecationWarning): + self.assertFalse(isinstance(gaussian_square_pulse, Gaussian)) + self.assertTrue(isinstance(gaussian_square_pulse, GaussianSquare)) + self.assertFalse(isinstance(gaussian_square_pulse, Drag)) + self.assertFalse(isinstance(gaussian_square_pulse, Constant)) + + def test_drag_deprecated_type_check(self): + """Test isinstance check works with deprecation.""" + drag_pulse = Drag(160, 0.1, 40, 1.5) + + self.assertTrue(isinstance(drag_pulse, SymbolicPulse)) + with self.assertWarns(PendingDeprecationWarning): + self.assertFalse(isinstance(drag_pulse, Gaussian)) + self.assertFalse(isinstance(drag_pulse, GaussianSquare)) + self.assertTrue(isinstance(drag_pulse, Drag)) + self.assertFalse(isinstance(drag_pulse, Constant)) + + def test_constant_deprecated_type_check(self): + """Test isinstance check works with deprecation.""" + constant_pulse = Constant(160, 0.1, 40, 1.5) + + self.assertTrue(isinstance(constant_pulse, SymbolicPulse)) + with self.assertWarns(PendingDeprecationWarning): + self.assertFalse(isinstance(constant_pulse, Gaussian)) + self.assertFalse(isinstance(constant_pulse, GaussianSquare)) + self.assertFalse(isinstance(constant_pulse, Drag)) + self.assertTrue(isinstance(constant_pulse, Constant)) + + +class TestFunctionalPulse(QiskitTestCase): + """Waveform tests.""" + + # pylint: disable=invalid-name + def test_gaussian(self): + """Test gaussian pulse.""" + + @functional_pulse + def local_gaussian(duration, amp, t0, sig): + x = np.linspace(0, duration - 1, duration) + return amp * np.exp(-((x - t0) ** 2) / sig**2) + + pulse_wf_inst = local_gaussian(duration=10, amp=1, t0=5, sig=1, name="test_pulse") + _y = 1 * np.exp(-((np.linspace(0, 9, 10) - 5) ** 2) / 1**2) + + self.assertListEqual(list(pulse_wf_inst.samples), list(_y)) + + # check name + self.assertEqual(pulse_wf_inst.name, "test_pulse") + + # check duration + self.assertEqual(pulse_wf_inst.duration, 10) + + # pylint: disable=invalid-name + def test_variable_duration(self): + """Test generation of sample pulse with variable duration.""" + + @functional_pulse + def local_gaussian(duration, amp, t0, sig): + x = np.linspace(0, duration - 1, duration) + return amp * np.exp(-((x - t0) ** 2) / sig**2) + + _durations = np.arange(10, 15, 1) + + for _duration in _durations: + pulse_wf_inst = local_gaussian(duration=_duration, amp=1, t0=5, sig=1) + self.assertEqual(len(pulse_wf_inst.samples), _duration) + + +class TestScalableSymbolicPulse(QiskitTestCase): + """ScalableSymbolicPulse tests""" + + def test_scalable_comparison(self): + """Test equating of pulses""" + # amp,angle comparison + gaussian_negamp = Gaussian(duration=25, sigma=4, amp=-0.5, angle=0) + gaussian_piphase = Gaussian(duration=25, sigma=4, amp=0.5, angle=np.pi) + self.assertEqual(gaussian_negamp, gaussian_piphase) + + # Parameterized library pulses + amp = Parameter("amp") + gaussian1 = Gaussian(duration=25, sigma=4, amp=amp, angle=0) + gaussian2 = Gaussian(duration=25, sigma=4, amp=amp, angle=0) + self.assertEqual(gaussian1, gaussian2) + + # pulses with different parameters + gaussian1._params["sigma"] = 10 + self.assertNotEqual(gaussian1, gaussian2) + + def test_complex_amp_error(self): + """Test that initializing a pulse with complex amp raises an error""" + with self.assertRaises(PulseError): + ScalableSymbolicPulse("test", duration=100, amp=0.1j, angle=0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/dynamics/pulse/test_pulse_to_signals.py b/test/dynamics/pulse/test_pulse_to_signals.py index d56fb8bb4..8fa59e471 100644 --- a/test/dynamics/pulse/test_pulse_to_signals.py +++ b/test/dynamics/pulse/test_pulse_to_signals.py @@ -19,8 +19,8 @@ import sympy as sym from qiskit import pulse -from qiskit.pulse import Schedule -from qiskit.pulse.transforms.canonicalization import block_to_schedule +from qiskit_dynamics.pulse import Schedule +from qiskit_dynamics.pulse.transforms.canonicalization import block_to_schedule from qiskit import QiskitError from qiskit_ibm_runtime.fake_provider import FakeQuito diff --git a/test/dynamics/pulse/test_reference.py b/test/dynamics/pulse/test_reference.py new file mode 100644 index 000000000..1a59cbf96 --- /dev/null +++ b/test/dynamics/pulse/test_reference.py @@ -0,0 +1,637 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2020. +# +# 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 schedule block subroutine reference mechanism.""" + +import numpy as np + +from qiskit import circuit, pulse +from qiskit_dynamics.pulse import ScheduleBlock, builder +from qiskit_dynamics.pulse.transforms import inline_subroutines +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestReference(QiskitTestCase): + """Test for basic behavior of reference mechanism.""" + + def test_append_schedule(self): + """Test appending schedule without calling. + + Appended schedules are not subroutines. + These are directly exposed to the outer block. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.append_schedule(sched_x1) + + with pulse.build() as sched_z1: + builder.append_schedule(sched_y1) + + self.assertEqual(len(sched_z1.references), 0) + + def test_refer_schedule(self): + """Test refer to schedule by name. + + Outer block is only aware of its inner reference. + Nested reference is not directly exposed to the most outer block. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.reference("x1", "d0") + + with pulse.build() as sched_z1: + builder.reference("y1", "d0") + + sched_y1.assign_references({("x1", "d0"): sched_x1}) + sched_z1.assign_references({("y1", "d0"): sched_y1}) + + self.assertEqual(len(sched_z1.references), 1) + self.assertEqual(sched_z1.references[("y1", "d0")], sched_y1) + + self.assertEqual(len(sched_y1.references), 1) + self.assertEqual(sched_y1.references[("x1", "d0")], sched_x1) + + def test_refer_schedule_parameter_scope(self): + """Test refer to schedule by name. + + Parameter in the called schedule has the scope of called schedule. + """ + param = circuit.Parameter("name") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.reference("x1", "d0") + + with pulse.build() as sched_z1: + builder.reference("y1", "d0") + + sched_y1.assign_references({("x1", "d0"): sched_x1}) + sched_z1.assign_references({("y1", "d0"): sched_y1}) + + self.assertEqual(sched_z1.parameters, sched_x1.parameters) + self.assertEqual(sched_z1.parameters, sched_y1.parameters) + + def test_refer_schedule_parameter_assignment(self): + """Test assigning to parameter in referenced schedule""" + param = circuit.Parameter("name") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.reference("x1", "d0") + + with pulse.build() as sched_z1: + builder.reference("y1", "d0") + + sched_y1.assign_references({("x1", "d0"): sched_x1}) + sched_z1.assign_references({("y1", "d0"): sched_y1}) + + assigned_z1 = sched_z1.assign_parameters({param: 0.5}, inplace=False) + + assigned_x1 = sched_x1.assign_parameters({param: 0.5}, inplace=False) + ref_assigned_y1 = ScheduleBlock() + ref_assigned_y1.append(assigned_x1) + ref_assigned_z1 = ScheduleBlock() + ref_assigned_z1.append(ref_assigned_y1) + + # Test that assignment was successful and resolved references + self.assertEqual(assigned_z1, ref_assigned_z1) + + # Test that inplace=False for sched_z1 also did not modify sched_z1 or subroutine sched_x1 + self.assertEqual(sched_z1.parameters, {param}) + self.assertEqual(sched_x1.parameters, {param}) + self.assertEqual(assigned_z1.parameters, set()) + + # Now test inplace=True + sched_z1.assign_parameters({param: 0.5}, inplace=True) + self.assertEqual(sched_z1, assigned_z1) + # assign_references copies the subroutine, so the original subschedule + # is still not modified here: + self.assertNotEqual(sched_x1, assigned_x1) + + def test_call_schedule(self): + """Test call schedule. + + Outer block is only aware of its inner reference. + Nested reference is not directly exposed to the most outer block. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.call(sched_x1, name="x1") + + with pulse.build() as sched_z1: + builder.call(sched_y1, name="y1") + + self.assertEqual(len(sched_z1.references), 1) + self.assertEqual(sched_z1.references[("y1",)], sched_y1) + + self.assertEqual(len(sched_y1.references), 1) + self.assertEqual(sched_y1.references[("x1",)], sched_x1) + + def test_call_schedule_parameter_scope(self): + """Test call schedule. + + Parameter in the called schedule has the scope of called schedule. + """ + param = circuit.Parameter("name") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.call(sched_x1, name="x1") + + with pulse.build() as sched_z1: + builder.call(sched_y1, name="y1") + + self.assertEqual(sched_z1.parameters, sched_x1.parameters) + self.assertEqual(sched_z1.parameters, sched_y1.parameters) + + def test_append_and_call_schedule(self): + """Test call and append schedule. + + Reference is copied to the outer schedule by appending. + Original reference remains unchanged. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + builder.call(sched_x1, name="x1") + + with pulse.build() as sched_z1: + builder.append_schedule(sched_y1) + + self.assertEqual(len(sched_z1.references), 1) + self.assertEqual(sched_z1.references[("x1",)], sched_x1) + + # blocks[0] is sched_y1 and its reference is now point to outer block reference + self.assertIs(sched_z1.blocks[0].references, sched_z1.references) + + # however the original program is protected to prevent unexpected mutation + self.assertIsNot(sched_y1.references, sched_z1.references) + + # appended schedule is preserved + self.assertEqual(len(sched_y1.references), 1) + self.assertEqual(sched_y1.references[("x1",)], sched_x1) + + def test_calling_similar_schedule(self): + """Test calling schedules with the same representation. + + sched_x1 and sched_y1 are the different subroutines, but same representation. + Two references should be created. + """ + param1 = circuit.Parameter("param") + param2 = circuit.Parameter("param") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param1, name="p"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + pulse.play(pulse.Constant(100, param2, name="p"), pulse.DriveChannel(0)) + + with pulse.build() as sched_z1: + pulse.call(sched_x1) + pulse.call(sched_y1) + + self.assertEqual(len(sched_z1.references), 2) + + def test_calling_same_schedule(self): + """Test calling same schedule twice. + + Because it calls the same schedule, no duplication should occur in reference table. + """ + param = circuit.Parameter("param") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_z1: + pulse.call(sched_x1, name="same_sched") + pulse.call(sched_x1, name="same_sched") + + self.assertEqual(len(sched_z1.references), 1) + + def test_calling_same_schedule_with_different_assignment(self): + """Test calling same schedule twice but with different parameters. + + Same schedule is called twice but with different assignment. + Two references should be created. + """ + param = circuit.Parameter("param") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_z1: + pulse.call(sched_x1, param=0.1) + pulse.call(sched_x1, param=0.2) + + self.assertEqual(len(sched_z1.references), 2) + + def test_alignment_context(self): + """Test nested alignment context. + + Inline alignment is identical to append_schedule operation. + Thus scope is not newly generated. + """ + with pulse.build(name="x1") as sched_x1: + with pulse.align_right(): + with pulse.align_left(): + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + self.assertEqual(len(sched_x1.references), 0) + + def test_appending_child_block(self): + """Test for edge case. + + User can append blocks which is an element of another schedule block. + But this is not standard use case. + + In this case, references may contain subroutines which don't exist in the context. + This is because all references within the program are centrally + managed in the most outer block. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + pulse.play(pulse.Constant(100, 0.2, name="y1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_x2: + builder.call(sched_x1, name="x1") + self.assertEqual(list(sched_x2.references.keys()), [("x1",)]) + + with pulse.build() as sched_y2: + builder.call(sched_y1, name="y1") + self.assertEqual(list(sched_y2.references.keys()), [("y1",)]) + + with pulse.build() as sched_z1: + builder.append_schedule(sched_x2) + builder.append_schedule(sched_y2) + self.assertEqual(list(sched_z1.references.keys()), [("x1",), ("y1",)]) + + # child block references point to its parent, i.e. sched_z1 + self.assertIs(sched_z1.blocks[0].references, sched_z1._reference_manager) + self.assertIs(sched_z1.blocks[1].references, sched_z1._reference_manager) + + with pulse.build() as sched_z2: + # Append child block + # The reference of this block is sched_z1.reference thus it contains both x1 and y1. + # However, y1 doesn't exist in the context, so only x1 should be added. + + # Usually, user will append sched_x2 directly here, rather than sched_z1.blocks[0] + # This is why this situation is an edge case. + builder.append_schedule(sched_z1.blocks[0]) + + self.assertEqual(len(sched_z2.references), 1) + self.assertEqual(sched_z2.references[("x1",)], sched_x1) + + def test_replacement(self): + """Test nested alignment context. + + Replacing schedule block with schedule block. + Removed block contains own reference, that should be removed with replacement. + New block also contains reference, that should be passed to the current reference. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1, name="x1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + pulse.play(pulse.Constant(100, 0.2, name="y1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_x2: + builder.call(sched_x1, name="x1") + + with pulse.build() as sched_y2: + builder.call(sched_y1, name="y1") + + with pulse.build() as sched_z1: + builder.append_schedule(sched_x2) + builder.append_schedule(sched_y2) + self.assertEqual(len(sched_z1.references), 2) + self.assertEqual(sched_z1.references[("x1",)], sched_x1) + self.assertEqual(sched_z1.references[("y1",)], sched_y1) + + # Define schedule to replace + with pulse.build() as sched_r1: + pulse.play(pulse.Constant(100, 0.1, name="r1"), pulse.DriveChannel(0)) + + with pulse.build() as sched_r2: + pulse.call(sched_r1, name="r1") + + sched_z2 = sched_z1.replace(sched_x2, sched_r2) + self.assertEqual(len(sched_z2.references), 2) + self.assertEqual(sched_z2.references[("r1",)], sched_r1) + self.assertEqual(sched_z2.references[("y1",)], sched_y1) + + def test_parameter_in_multiple_scope(self): + """Test that using parameter in multiple scopes causes no error""" + param = circuit.Parameter("name") + + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, param), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + pulse.play(pulse.Constant(100, param), pulse.DriveChannel(1)) + + with pulse.build() as sched_z1: + pulse.call(sched_x1, name="x1") + pulse.call(sched_y1, name="y1") + + self.assertEqual(len(sched_z1.parameters), 1) + self.assertEqual(sched_z1.parameters, {param}) + + def test_parallel_alignment_equality(self): + """Testcase for potential edge case. + + In parallel alignment context, reference instruction is broadcasted to + all channels. When new channel is added after reference, this should be + connected with reference node. + """ + + with pulse.build() as subroutine: + pulse.reference("unassigned") + + with pulse.build() as sched1: + with pulse.align_left(): + pulse.delay(10, pulse.DriveChannel(0)) + pulse.call(subroutine) # This should be broadcasted to d1 as well + pulse.delay(10, pulse.DriveChannel(1)) + + with pulse.build() as sched2: + with pulse.align_left(): + pulse.delay(10, pulse.DriveChannel(0)) + pulse.delay(10, pulse.DriveChannel(1)) + pulse.call(subroutine) + + self.assertNotEqual(sched1, sched2) + + def test_subroutine_conflict(self): + """Test for edge case of appending two schedule blocks having the + references with conflicting reference key. + + This operation should fail because one of references will be gone after assignment. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1), pulse.DriveChannel(0)) + + with pulse.build() as sched_x2: + pulse.call(sched_x1, name="conflict_name") + + self.assertEqual(sched_x2.references[("conflict_name",)], sched_x1) + + with pulse.build() as sched_y1: + pulse.play(pulse.Constant(100, 0.2), pulse.DriveChannel(0)) + + with pulse.build() as sched_y2: + pulse.call(sched_y1, name="conflict_name") + + self.assertEqual(sched_y2.references[("conflict_name",)], sched_y1) + + with self.assertRaises(pulse.exceptions.PulseError): + with pulse.build(): + builder.append_schedule(sched_x2) + builder.append_schedule(sched_y2) + + def test_assign_existing_reference(self): + """Test for explicitly assign existing reference. + + This operation should fail because overriding reference is not allowed. + """ + with pulse.build() as sched_x1: + pulse.play(pulse.Constant(100, 0.1), pulse.DriveChannel(0)) + + with pulse.build() as sched_y1: + pulse.play(pulse.Constant(100, 0.2), pulse.DriveChannel(0)) + + with pulse.build() as sched_z1: + pulse.call(sched_x1, name="conflict_name") + + with self.assertRaises(pulse.exceptions.PulseError): + sched_z1.assign_references({("conflict_name",): sched_y1}) + + +class TestSubroutineWithCXGate(QiskitTestCase): + """Test called program scope with practical example of building fully parametrized CX gate.""" + + def setUp(self): + super().setUp() + + # parameters of X pulse + self.xp_dur = circuit.Parameter("dur") + self.xp_amp = circuit.Parameter("amp") + self.xp_sigma = circuit.Parameter("sigma") + self.xp_beta = circuit.Parameter("beta") + + # amplitude of SX pulse + self.sxp_amp = circuit.Parameter("amp") + + # parameters of CR pulse + self.cr_dur = circuit.Parameter("dur") + self.cr_amp = circuit.Parameter("amp") + self.cr_sigma = circuit.Parameter("sigma") + self.cr_risefall = circuit.Parameter("risefall") + + # channels + self.control_ch = circuit.Parameter("ctrl") + self.target_ch = circuit.Parameter("tgt") + self.cr_ch = circuit.Parameter("cr") + + # echo pulse on control qubit + with pulse.build(name="xp") as xp_sched_q0: + pulse.play( + pulse.Drag( + duration=self.xp_dur, + amp=self.xp_amp, + sigma=self.xp_sigma, + beta=self.xp_beta, + ), + channel=pulse.DriveChannel(self.control_ch), + ) + self.xp_sched = xp_sched_q0 + + # local rotation on target qubit + with pulse.build(name="sx") as sx_sched_q1: + pulse.play( + pulse.Drag( + duration=self.xp_dur, + amp=self.sxp_amp, + sigma=self.xp_sigma, + beta=self.xp_beta, + ), + channel=pulse.DriveChannel(self.target_ch), + ) + self.sx_sched = sx_sched_q1 + + # cross resonance + with pulse.build(name="cr") as cr_sched: + pulse.play( + pulse.GaussianSquare( + duration=self.cr_dur, + amp=self.cr_amp, + sigma=self.cr_sigma, + risefall_sigma_ratio=self.cr_risefall, + ), + channel=pulse.ControlChannel(self.cr_ch), + ) + self.cr_sched = cr_sched + + def test_lazy_ecr(self): + """Test lazy subroutines through ECR schedule construction.""" + + with pulse.build(name="lazy_ecr") as sched: + with pulse.align_sequential(): + pulse.reference("cr", "q0", "q1") + pulse.reference("xp", "q0") + with pulse.phase_offset(np.pi, pulse.ControlChannel(self.cr_ch)): + pulse.reference("cr", "q0", "q1") + pulse.reference("xp", "q0") + + # Schedule has references + self.assertTrue(sched.is_referenced()) + + # Schedule is not schedulable because of unassigned references + self.assertFalse(sched.is_schedulable()) + + # Two references cr and xp are called + self.assertEqual(len(sched.references), 2) + + # Parameters in the current scope are Parameter("cr") which is used in phase_offset + # References are not assigned yet. + params = {p.name for p in sched.parameters} + self.assertSetEqual(params, {"cr"}) + + # Assign CR and XP schedule to the empty reference + sched.assign_references({("cr", "q0", "q1"): self.cr_sched}) + sched.assign_references({("xp", "q0"): self.xp_sched}) + + # Check updated references + assigned_refs = sched.references + self.assertEqual(assigned_refs[("cr", "q0", "q1")], self.cr_sched) + self.assertEqual(assigned_refs[("xp", "q0")], self.xp_sched) + + # Parameter added from subroutines + ref_params = {self.cr_ch} | self.cr_sched.parameters | self.xp_sched.parameters + self.assertSetEqual(sched.parameters, ref_params) + + # Get parameter without scope, cr amp and xp amp are hit. + params = sched.get_parameters(parameter_name="amp") + self.assertEqual(len(params), 2) + + def test_cnot(self): + """Integration test with CNOT schedule construction.""" + # echoed cross resonance + with pulse.build(name="ecr", default_alignment="sequential") as ecr_sched: + pulse.call(self.cr_sched, name="cr") + pulse.call(self.xp_sched, name="xp") + with pulse.phase_offset(np.pi, pulse.ControlChannel(self.cr_ch)): + pulse.call(self.cr_sched, name="cr") + pulse.call(self.xp_sched, name="xp") + + # cnot gate, locally equivalent to ecr + with pulse.build(name="cx", default_alignment="sequential") as cx_sched: + pulse.shift_phase(np.pi / 2, pulse.DriveChannel(self.control_ch)) + pulse.call(self.sx_sched, name="sx") + pulse.call(ecr_sched, name="ecr") + + # assign parameters + assigned_cx = cx_sched.assign_parameters( + value_dict={ + self.cr_ch: 0, + self.control_ch: 0, + self.target_ch: 1, + self.sxp_amp: 0.1, + self.xp_amp: 0.2, + self.xp_dur: 160, + self.xp_sigma: 40, + self.xp_beta: 3.0, + self.cr_amp: 0.5, + self.cr_dur: 800, + self.cr_sigma: 64, + self.cr_risefall: 2, + }, + inplace=True, + ) + flatten_cx = inline_subroutines(assigned_cx) + + with pulse.build(default_alignment="sequential") as ref_cx: + # sz + pulse.shift_phase(np.pi / 2, pulse.DriveChannel(0)) + with pulse.align_left(): + # sx + pulse.play( + pulse.Drag( + duration=160, + amp=0.1, + sigma=40, + beta=3.0, + ), + channel=pulse.DriveChannel(1), + ) + with pulse.align_sequential(): + # cr + with pulse.align_left(): + pulse.play( + pulse.GaussianSquare( + duration=800, + amp=0.5, + sigma=64, + risefall_sigma_ratio=2, + ), + channel=pulse.ControlChannel(0), + ) + # xp + with pulse.align_left(): + pulse.play( + pulse.Drag( + duration=160, + amp=0.2, + sigma=40, + beta=3.0, + ), + channel=pulse.DriveChannel(0), + ) + with pulse.phase_offset(np.pi, pulse.ControlChannel(0)): + # cr + with pulse.align_left(): + pulse.play( + pulse.GaussianSquare( + duration=800, + amp=0.5, + sigma=64, + risefall_sigma_ratio=2, + ), + channel=pulse.ControlChannel(0), + ) + # xp + with pulse.align_left(): + pulse.play( + pulse.Drag( + duration=160, + amp=0.2, + sigma=40, + beta=3.0, + ), + channel=pulse.DriveChannel(0), + ) + + self.assertEqual(flatten_cx, ref_cx) diff --git a/test/dynamics/pulse/test_samplers.py b/test/dynamics/pulse/test_samplers.py new file mode 100644 index 000000000..a9bcbb881 --- /dev/null +++ b/test/dynamics/pulse/test_samplers.py @@ -0,0 +1,94 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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. + + +"""Tests pulse function samplers.""" + +import numpy as np + +from qiskit_dynamics.pulse import library +from qiskit_dynamics.pulse.library import samplers +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +def linear(times: np.ndarray, m: float, b: float = 0.1) -> np.ndarray: + """Linear test function + Args: + times: Input times. + m: Slope. + b: Intercept + Returns: + np.ndarray + """ + return m * times + b + + +class TestSampler(QiskitTestCase): + """Test continuous pulse function samplers.""" + + def test_left_sampler(self): + """Test left sampler.""" + m = 0.1 + b = 0.1 + duration = 2 + left_linear_pulse_fun = samplers.left(linear) + reference = np.array([0.1, 0.2], dtype=complex) + + pulse = left_linear_pulse_fun(duration, m=m, b=b) + self.assertIsInstance(pulse, library.Waveform) + np.testing.assert_array_almost_equal(pulse.samples, reference) + + def test_right_sampler(self): + """Test right sampler.""" + m = 0.1 + b = 0.1 + duration = 2 + right_linear_pulse_fun = samplers.right(linear) + reference = np.array([0.2, 0.3], dtype=complex) + + pulse = right_linear_pulse_fun(duration, m=m, b=b) + self.assertIsInstance(pulse, library.Waveform) + np.testing.assert_array_almost_equal(pulse.samples, reference) + + def test_midpoint_sampler(self): + """Test midpoint sampler.""" + m = 0.1 + b = 0.1 + duration = 2 + midpoint_linear_pulse_fun = samplers.midpoint(linear) + reference = np.array([0.15, 0.25], dtype=complex) + + pulse = midpoint_linear_pulse_fun(duration, m=m, b=b) + self.assertIsInstance(pulse, library.Waveform) + np.testing.assert_array_almost_equal(pulse.samples, reference) + + def test_sampler_name(self): + """Test that sampler setting of pulse name works.""" + m = 0.1 + b = 0.1 + duration = 2 + left_linear_pulse_fun = samplers.left(linear) + + pulse = left_linear_pulse_fun(duration, m=m, b=b, name="test") + self.assertIsInstance(pulse, library.Waveform) + self.assertEqual(pulse.name, "test") + + def test_default_arg_sampler(self): + """Test that default arguments work with sampler.""" + m = 0.1 + duration = 2 + left_linear_pulse_fun = samplers.left(linear) + reference = np.array([0.1, 0.2], dtype=complex) + + pulse = left_linear_pulse_fun(duration, m=m) + self.assertIsInstance(pulse, library.Waveform) + np.testing.assert_array_almost_equal(pulse.samples, reference) diff --git a/test/dynamics/pulse/test_schedule.py b/test/dynamics/pulse/test_schedule.py new file mode 100644 index 000000000..0c7b488c9 --- /dev/null +++ b/test/dynamics/pulse/test_schedule.py @@ -0,0 +1,1018 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# 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 cases for the pulse schedule.""" +import unittest +from unittest.mock import patch + +import numpy as np + +from qiskit_dynamics.pulse import ( + Play, + Waveform, + ShiftPhase, + Instruction, + SetFrequency, + Acquire, + Snapshot, + Delay, + library, + Gaussian, + Drag, + GaussianSquare, + Constant, + functional_pulse, + ShiftFrequency, + SetPhase, +) +from qiskit_dynamics.pulse.channels import ( + MemorySlot, + RegisterSlot, + DriveChannel, + ControlChannel, + AcquireChannel, + SnapshotChannel, + MeasureChannel, +) +from qiskit_dynamics.pulse.exceptions import PulseError +from qiskit_dynamics.pulse.schedule import Schedule, _overlaps, _find_insertion_index +from qiskit.providers.fake_provider import FakeOpenPulse2Q +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class BaseTestSchedule(QiskitTestCase): + """Schedule tests.""" + + def setUp(self): + super().setUp() + + @functional_pulse + def linear(duration, slope, intercept): + x = np.linspace(0, duration - 1, duration) + return slope * x + intercept + + self.linear = linear + with self.assertWarns(DeprecationWarning): + self.config = FakeOpenPulse2Q().configuration() + + +class TestScheduleBuilding(BaseTestSchedule): + """Test construction of schedules.""" + + def test_append_an_instruction_to_empty_schedule(self): + """Test append instructions to an empty schedule.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + + sched = Schedule() + sched = sched.append(Play(lp0, self.config.drive(0))) + self.assertEqual(0, sched.start_time) + self.assertEqual(3, sched.stop_time) + + def test_append_instructions_applying_to_different_channels(self): + """Test append instructions to schedule.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + + sched = Schedule() + sched = sched.append(Play(lp0, self.config.drive(0))) + sched = sched.append(Play(lp0, self.config.drive(1))) + self.assertEqual(0, sched.start_time) + # appending to separate channel so should be at same time. + self.assertEqual(3, sched.stop_time) + + def test_insert_an_instruction_into_empty_schedule(self): + """Test insert an instruction into an empty schedule.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + + sched = Schedule() + sched = sched.insert(10, Play(lp0, self.config.drive(0))) + self.assertEqual(10, sched.start_time) + self.assertEqual(13, sched.stop_time) + + def test_insert_an_instruction_before_an_existing_instruction(self): + """Test insert an instruction before an existing instruction.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + + sched = Schedule() + sched = sched.insert(10, Play(lp0, self.config.drive(0))) + sched = sched.insert(5, Play(lp0, self.config.drive(0))) + self.assertEqual(5, sched.start_time) + self.assertEqual(13, sched.stop_time) + + def test_fail_to_insert_instruction_into_occupied_timing(self): + """Test insert an instruction before an existing instruction.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + + sched = Schedule() + sched = sched.insert(10, Play(lp0, self.config.drive(0))) + with self.assertRaises(PulseError): + sched.insert(11, Play(lp0, self.config.drive(0))) + + def test_can_create_valid_schedule(self): + """Test valid schedule creation without error.""" + gp0 = library.Gaussian(duration=20, amp=0.7, sigma=3) + gp1 = library.Gaussian(duration=20, amp=0.7, sigma=3) + + sched = Schedule() + sched = sched.append(Play(gp0, self.config.drive(0))) + sched = sched.insert(60, ShiftPhase(-1.57, self.config.drive(0))) + sched = sched.insert(30, Play(gp1, self.config.drive(0))) + sched = sched.insert(60, Play(gp0, self.config.control([0, 1])[0])) + sched = sched.insert(80, Snapshot("label", "snap_type")) + sched = sched.insert(90, ShiftPhase(1.57, self.config.drive(0))) + sched = sched.insert( + 90, Acquire(10, self.config.acquire(0), MemorySlot(0), RegisterSlot(0)) + ) + self.assertEqual(0, sched.start_time) + self.assertEqual(100, sched.stop_time) + self.assertEqual(100, sched.duration) + new_sched = Schedule() + new_sched = new_sched.append(sched) + new_sched = new_sched.append(sched) + self.assertEqual(0, new_sched.start_time) + self.assertEqual(200, new_sched.stop_time) + self.assertEqual(200, new_sched.duration) + ids = set() + for _, inst in sched.instructions: + self.assertFalse(inst.id in ids) + ids.add(inst.id) + + def test_can_create_valid_schedule_with_syntax_sugar(self): + """Test that in place operations on schedule are still immutable + and return equivalent schedules.""" + gp0 = library.Gaussian(duration=20, amp=0.7, sigma=3) + gp1 = library.Gaussian(duration=20, amp=0.5, sigma=3) + + sched = Schedule() + sched += Play(gp0, self.config.drive(0)) + sched |= ShiftPhase(-1.57, self.config.drive(0)) << 60 + sched |= Play(gp1, self.config.drive(0)) << 30 + sched |= Play(gp0, self.config.control(qubits=[0, 1])[0]) << 60 + sched |= Snapshot("label", "snap_type") << 60 + sched |= ShiftPhase(1.57, self.config.drive(0)) << 90 + sched |= Acquire(10, self.config.acquire(0), MemorySlot(0)) << 90 + sched += sched + + def test_immutability(self): + """Test that operations are immutable.""" + gp0 = library.Gaussian(duration=100, amp=0.7, sigma=3) + gp1 = library.Gaussian(duration=20, amp=0.5, sigma=3) + + sched = Play(gp1, self.config.drive(0)) << 100 + # if schedule was mutable the next two sequences would overlap and an error + # would be raised. + sched.insert(0, Play(gp0, self.config.drive(0))) + sched.insert(0, Play(gp0, self.config.drive(0))) + + def test_inplace(self): + """Test that in place operations on schedule are still immutable.""" + gp0 = library.Gaussian(duration=100, amp=0.7, sigma=3) + gp1 = library.Gaussian(duration=20, amp=0.5, sigma=3) + + sched = Schedule() + sched = sched + Play(gp1, self.config.drive(0)) + sched2 = sched + sched += Play(gp0, self.config.drive(0)) + self.assertNotEqual(sched, sched2) + + def test_empty_schedule(self): + """Test empty schedule.""" + sched = Schedule() + self.assertEqual(0, sched.start_time) + self.assertEqual(0, sched.stop_time) + self.assertEqual(0, sched.duration) + self.assertEqual(0, len(sched)) + self.assertEqual((), sched.children) + self.assertEqual({}, sched.timeslots) + self.assertEqual([], list(sched.instructions)) + self.assertFalse(sched) + + def test_overlapping_schedules(self): + """Test overlapping schedules.""" + + def my_test_make_schedule(acquire: int, memoryslot: int, shift: int): + sched1 = Acquire(acquire, AcquireChannel(0), MemorySlot(memoryslot)) + sched2 = Acquire(acquire, AcquireChannel(1), MemorySlot(memoryslot)).shift(shift) + + return Schedule(sched1, sched2) + + self.assertIsInstance(my_test_make_schedule(4, 0, 4), Schedule) + self.assertRaisesRegex( + PulseError, r".*MemorySlot\(0\).*overlaps .*", my_test_make_schedule, 4, 0, 2 + ) + self.assertRaisesRegex( + PulseError, r".*MemorySlot\(1\).*overlaps .*", my_test_make_schedule, 4, 1, 0 + ) + + def test_flat_instruction_sequence_returns_instructions(self): + """Test if `flat_instruction_sequence` returns `Instruction`s.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + + # empty schedule with empty schedule + empty = Schedule().append(Schedule()) + for _, instr in empty.instructions: + self.assertIsInstance(instr, Instruction) + + # normal schedule + subsched = Schedule() + subsched = subsched.insert(20, Play(lp0, self.config.drive(0))) # grand child 1 + subsched = subsched.append(Play(lp0, self.config.drive(0))) # grand child 2 + + sched = Schedule() + sched = sched.append(Play(lp0, self.config.drive(0))) # child + sched = sched.append(subsched) + for _, instr in sched.instructions: + self.assertIsInstance(instr, Instruction) + + def test_absolute_start_time_of_grandchild(self): + """Test correct calculation of start time of grandchild of a schedule.""" + lp0 = self.linear(duration=10, slope=0.02, intercept=0.01) + + subsched = Schedule() + subsched = subsched.insert(20, Play(lp0, self.config.drive(0))) # grand child 1 + subsched = subsched.append(Play(lp0, self.config.drive(0))) # grand child 2 + + sched = Schedule() + sched = sched.append(Play(lp0, self.config.drive(0))) # child + sched = sched.append(subsched) + + start_times = sorted(shft + instr.start_time for shft, instr in sched.instructions) + self.assertEqual([0, 30, 40], start_times) + + def test_shift_schedule(self): + """Test shift schedule.""" + lp0 = self.linear(duration=10, slope=0.02, intercept=0.01) + + subsched = Schedule() + subsched = subsched.insert(20, Play(lp0, self.config.drive(0))) # grand child 1 + subsched = subsched.append(Play(lp0, self.config.drive(0))) # grand child 2 + + sched = Schedule() + sched = sched.append(Play(lp0, self.config.drive(0))) # child + sched = sched.append(subsched) + + shift = sched.shift(100) + + start_times = sorted(shft + instr.start_time for shft, instr in shift.instructions) + + self.assertEqual([100, 130, 140], start_times) + + def test_keep_original_schedule_after_attached_to_another_schedule(self): + """Test if a schedule keeps its children after attached to another schedule.""" + children = Acquire(10, self.config.acquire(0), MemorySlot(0)).shift(20) + Acquire( + 10, self.config.acquire(0), MemorySlot(0) + ) + self.assertEqual(2, len(list(children.instructions))) + + sched = Acquire(10, self.config.acquire(0), MemorySlot(0)).append(children) + self.assertEqual(3, len(list(sched.instructions))) + + # add 2 instructions to children (2 instructions -> 4 instructions) + children = children.append(Acquire(10, self.config.acquire(0), MemorySlot(0))) + children = children.insert(100, Acquire(10, self.config.acquire(0), MemorySlot(0))) + self.assertEqual(4, len(list(children.instructions))) + # sched must keep 3 instructions (must not update to 5 instructions) + self.assertEqual(3, len(list(sched.instructions))) + + @patch("qiskit.utils.is_main_process", return_value=True) + def test_auto_naming(self, is_main_process_mock): + """Test that a schedule gets a default name, incremented per instance""" + + del is_main_process_mock + + sched_0 = Schedule() + sched_0_name_count = int(sched_0.name[len("sched") :]) + + sched_1 = Schedule() + sched_1_name_count = int(sched_1.name[len("sched") :]) + self.assertEqual(sched_1_name_count, sched_0_name_count + 1) + + sched_2 = Schedule() + sched_2_name_count = int(sched_2.name[len("sched") :]) + self.assertEqual(sched_2_name_count, sched_1_name_count + 1) + + def test_name_inherited(self): + """Test that schedule keeps name if an instruction is added.""" + gp0 = library.Gaussian(duration=100, amp=0.7, sigma=3, name="pulse_name") + snapshot = Snapshot("snapshot_label", "state") + + sched1 = Schedule(name="test_name") + sched2 = Schedule(name=None) + sched3 = sched1 | sched2 + self.assertEqual(sched3.name, "test_name") + + sched_acq = Acquire(10, self.config.acquire(1), MemorySlot(1), name="acq_name") | sched1 + self.assertEqual(sched_acq.name, "acq_name") + + sched_pulse = Play(gp0, self.config.drive(0)) | sched1 + self.assertEqual(sched_pulse.name, "pulse_name") + + sched_fc = ShiftPhase(0.1, self.config.drive(0), name="fc_name") | sched1 + self.assertEqual(sched_fc.name, "fc_name") + + sched_snapshot = snapshot | sched1 + self.assertEqual(sched_snapshot.name, "snapshot_label") + + def test_schedule_with_acquire_on_single_qubit(self): + """Test schedule with acquire on single qubit.""" + sched_single = Schedule() + for i in range(self.config.n_qubits): + sched_single = sched_single.insert( + 10, + Acquire( + 10, self.config.acquire(i), mem_slot=MemorySlot(i), reg_slot=RegisterSlot(i) + ), + ) + + self.assertEqual(len(sched_single.instructions), 2) + self.assertEqual(len(sched_single.channels), 6) + + def test_parametric_commands_in_sched(self): + """Test that schedules can be built with parametric commands.""" + sched = Schedule(name="test_parametric") + sched += Play(Gaussian(duration=25, sigma=4, amp=0.5, angle=np.pi / 2), DriveChannel(0)) + sched += Play(Drag(duration=25, amp=0.4, angle=0.5, sigma=7.8, beta=4), DriveChannel(1)) + sched += Play(Constant(duration=25, amp=1), DriveChannel(2)) + sched_duration = sched.duration + sched += ( + Play(GaussianSquare(duration=1500, amp=0.2, sigma=8, width=140), MeasureChannel(0)) + << sched_duration + ) + self.assertEqual(sched.duration, 1525) + self.assertTrue("sigma" in sched.instructions[0][1].pulse.parameters) + + def test_numpy_integer_input(self): + """Test that mixed integer duration types can build a schedule (#5754).""" + sched = Schedule() + sched += Delay(np.int32(25), DriveChannel(0)) + sched += Play(Constant(duration=30, amp=0.1), DriveChannel(0)) + self.assertEqual(sched.duration, 55) + + def test_negative_time_raises(self): + """Test that a negative time will raise an error.""" + sched = Schedule() + sched += Delay(1, DriveChannel(0)) + with self.assertRaises(PulseError): + sched.shift(-10) + + def test_shift_float_time_raises(self): + """Test that a floating time will raise an error with shift.""" + sched = Schedule() + sched += Delay(1, DriveChannel(0)) + with self.assertRaises(PulseError): + sched.shift(0.1) + + def test_insert_float_time_raises(self): + """Test that a floating time will raise an error with insert.""" + sched = Schedule() + sched += Delay(1, DriveChannel(0)) + with self.assertRaises(PulseError): + sched.insert(10.1, sched) + + def test_shift_unshift(self): + """Test shift and then unshifting of schedule""" + reference_sched = Schedule() + reference_sched += Delay(10, DriveChannel(0)) + shifted_sched = reference_sched.shift(10).shift(-10) + self.assertEqual(shifted_sched, reference_sched) + + def test_duration(self): + """Test schedule.duration.""" + reference_sched = Schedule() + reference_sched = reference_sched.insert(10, Delay(10, DriveChannel(0))) + reference_sched = reference_sched.insert(10, Delay(50, DriveChannel(1))) + reference_sched = reference_sched.insert(10, ShiftPhase(0.1, DriveChannel(0))) + + reference_sched = reference_sched.insert(100, ShiftPhase(0.1, DriveChannel(1))) + + self.assertEqual(reference_sched.duration, 100) + self.assertEqual(reference_sched.duration, 100) + + def test_ch_duration(self): + """Test schedule.ch_duration.""" + reference_sched = Schedule() + reference_sched = reference_sched.insert(10, Delay(10, DriveChannel(0))) + reference_sched = reference_sched.insert(10, Delay(50, DriveChannel(1))) + reference_sched = reference_sched.insert(10, ShiftPhase(0.1, DriveChannel(0))) + + reference_sched = reference_sched.insert(100, ShiftPhase(0.1, DriveChannel(1))) + + self.assertEqual(reference_sched.ch_duration(DriveChannel(0)), 20) + self.assertEqual(reference_sched.ch_duration(DriveChannel(1)), 100) + self.assertEqual( + reference_sched.ch_duration(*reference_sched.channels), reference_sched.duration + ) + + def test_ch_start_time(self): + """Test schedule.ch_start_time.""" + reference_sched = Schedule() + reference_sched = reference_sched.insert(10, Delay(10, DriveChannel(0))) + reference_sched = reference_sched.insert(10, Delay(50, DriveChannel(1))) + reference_sched = reference_sched.insert(10, ShiftPhase(0.1, DriveChannel(0))) + + reference_sched = reference_sched.insert(100, ShiftPhase(0.1, DriveChannel(1))) + + self.assertEqual(reference_sched.ch_start_time(DriveChannel(0)), 10) + self.assertEqual(reference_sched.ch_start_time(DriveChannel(1)), 10) + + def test_ch_stop_time(self): + """Test schedule.ch_stop_time.""" + reference_sched = Schedule() + reference_sched = reference_sched.insert(10, Delay(10, DriveChannel(0))) + reference_sched = reference_sched.insert(10, Delay(50, DriveChannel(1))) + reference_sched = reference_sched.insert(10, ShiftPhase(0.1, DriveChannel(0))) + + reference_sched = reference_sched.insert(100, ShiftPhase(0.1, DriveChannel(1))) + + self.assertEqual(reference_sched.ch_stop_time(DriveChannel(0)), 20) + self.assertEqual(reference_sched.ch_stop_time(DriveChannel(1)), 100) + + def test_timeslots(self): + """Test schedule.timeslots.""" + reference_sched = Schedule() + reference_sched = reference_sched.insert(10, Delay(10, DriveChannel(0))) + reference_sched = reference_sched.insert(10, Delay(50, DriveChannel(1))) + reference_sched = reference_sched.insert(10, ShiftPhase(0.1, DriveChannel(0))) + + reference_sched = reference_sched.insert(100, ShiftPhase(0.1, DriveChannel(1))) + + self.assertEqual(reference_sched.timeslots[DriveChannel(0)], [(10, 10), (10, 20)]) + self.assertEqual(reference_sched.timeslots[DriveChannel(1)], [(10, 60), (100, 100)]) + + def test_len(self): + """Test __len__ method""" + sched = Schedule() + self.assertEqual(len(sched), 0) + + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + for j in range(1, 10): + sched = sched.append(Play(lp0, self.config.drive(0))) + self.assertEqual(len(sched), j) + + def test_inherit_from(self): + """Test creating schedule with another schedule.""" + ref_metadata = {"test": "value"} + ref_name = "test" + + base_sched = Schedule(name=ref_name, metadata=ref_metadata) + new_sched = Schedule.initialize_from(base_sched) + + self.assertEqual(new_sched.name, ref_name) + self.assertDictEqual(new_sched.metadata, ref_metadata) + + +class TestReplace(BaseTestSchedule): + """Test schedule replacement.""" + + def test_replace_instruction(self): + """Test replacement of simple instruction""" + old = Play(Constant(100, 1.0), DriveChannel(0)) + new = Play(Constant(100, 0.1), DriveChannel(0)) + + sched = Schedule(old) + new_sched = sched.replace(old, new) + + self.assertEqual(new_sched, Schedule(new)) + + # test replace inplace + sched.replace(old, new, inplace=True) + self.assertEqual(sched, Schedule(new)) + + def test_replace_schedule(self): + """Test replacement of schedule.""" + + old = Schedule( + Delay(10, DriveChannel(0)), + Delay(100, DriveChannel(1)), + ) + new = Schedule( + Play(Constant(10, 1.0), DriveChannel(0)), + Play(Constant(100, 0.1), DriveChannel(1)), + ) + const = Play(Constant(100, 1.0), DriveChannel(0)) + + sched = Schedule() + sched += const + sched += old + + new_sched = sched.replace(old, new) + + ref_sched = Schedule() + ref_sched += const + ref_sched += new + self.assertEqual(new_sched, ref_sched) + + # test replace inplace + sched.replace(old, new, inplace=True) + self.assertEqual(sched, ref_sched) + + def test_replace_fails_on_overlap(self): + """Test that replacement fails on overlap.""" + old = Play(Constant(20, 1.0), DriveChannel(0)) + new = Play(Constant(100, 0.1), DriveChannel(0)) + + sched = Schedule() + sched += old + sched += Delay(100, DriveChannel(0)) + + with self.assertRaises(PulseError): + sched.replace(old, new) + + +class TestDelay(BaseTestSchedule): + """Test Delay Instruction""" + + def setUp(self): + super().setUp() + self.delay_time = 10 + + def test_delay_drive_channel(self): + """Test Delay on DriveChannel""" + drive_ch = self.config.drive(0) + pulse = Waveform(np.full(10, 0.1)) + # should pass as is an append + sched = Delay(self.delay_time, drive_ch) + Play(pulse, drive_ch) + self.assertIsInstance(sched, Schedule) + pulse_instr = sched.instructions[-1] + # assert last instruction is pulse + self.assertIsInstance(pulse_instr[1], Play) + # assert pulse is scheduled at time 10 + self.assertEqual(pulse_instr[0], 10) + # should fail due to overlap + with self.assertRaises(PulseError): + sched = Delay(self.delay_time, drive_ch) | Play(pulse, drive_ch) + + def test_delay_measure_channel(self): + """Test Delay on MeasureChannel""" + + measure_ch = self.config.measure(0) + pulse = Waveform(np.full(10, 0.1)) + # should pass as is an append + sched = Delay(self.delay_time, measure_ch) + Play(pulse, measure_ch) + self.assertIsInstance(sched, Schedule) + # should fail due to overlap + with self.assertRaises(PulseError): + sched = Delay(self.delay_time, measure_ch) | Play(pulse, measure_ch) + + def test_delay_control_channel(self): + """Test Delay on ControlChannel""" + + control_ch = self.config.control([0, 1])[0] + pulse = Waveform(np.full(10, 0.1)) + # should pass as is an append + sched = Delay(self.delay_time, control_ch) + Play(pulse, control_ch) + self.assertIsInstance(sched, Schedule) + # should fail due to overlap + with self.assertRaises(PulseError): + sched = Delay(self.delay_time, control_ch) | Play(pulse, control_ch) + self.assertIsInstance(sched, Schedule) + + def test_delay_acquire_channel(self): + """Test Delay on DriveChannel""" + + acquire_ch = self.config.acquire(0) + # should pass as is an append + sched = Delay(self.delay_time, acquire_ch) + Acquire(10, acquire_ch, MemorySlot(0)) + self.assertIsInstance(sched, Schedule) + # should fail due to overlap + with self.assertRaises(PulseError): + sched = Delay(self.delay_time, acquire_ch) | Acquire(10, acquire_ch, MemorySlot(0)) + self.assertIsInstance(sched, Schedule) + + def test_delay_snapshot_channel(self): + """Test Delay on DriveChannel""" + + snapshot_ch = SnapshotChannel() + snapshot = Snapshot(label="test") + # should pass as is an append + sched = Delay(self.delay_time, snapshot_ch) + snapshot + self.assertIsInstance(sched, Schedule) + # should fail due to overlap + with self.assertRaises(PulseError): + sched = Delay(self.delay_time, snapshot_ch) | snapshot << 5 + self.assertIsInstance(sched, Schedule) + + +class TestScheduleFilter(BaseTestSchedule): + """Test Schedule filtering methods""" + + def test_filter_channels(self): + """Test filtering over channels.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + sched = Schedule(name="fake_experiment") + sched = sched.insert(0, Play(lp0, self.config.drive(0))) + sched = sched.insert(10, Play(lp0, self.config.drive(1))) + sched = sched.insert(30, ShiftPhase(-1.57, self.config.drive(0))) + sched = sched.insert(60, Acquire(5, AcquireChannel(0), MemorySlot(0))) + sched = sched.insert(60, Acquire(5, AcquireChannel(1), MemorySlot(1))) + sched = sched.insert(90, Play(lp0, self.config.drive(0))) + + # split instructions for those on AcquireChannel(1) and those not + filtered, excluded = self._filter_and_test_consistency(sched, channels=[AcquireChannel(1)]) + self.assertEqual(len(filtered.instructions), 1) + self.assertEqual(len(excluded.instructions), 5) + + # Split schedule into the part with channels on 1 and into a part without + channels = [AcquireChannel(1), DriveChannel(1)] + filtered, excluded = self._filter_and_test_consistency(sched, channels=channels) + for _, inst in filtered.instructions: + self.assertTrue(any(chan in channels for chan in inst.channels)) + + for _, inst in excluded.instructions: + self.assertFalse(any(chan in channels for chan in inst.channels)) + + def test_filter_exclude_name(self): + """Test the name of the schedules after applying filter and exclude functions.""" + sched = Schedule(name="test-schedule") + sched = sched.insert(10, Acquire(5, AcquireChannel(0), MemorySlot(0))) + sched = sched.insert(10, Acquire(5, AcquireChannel(1), MemorySlot(1))) + excluded = sched.exclude(channels=[AcquireChannel(0)]) + filtered = sched.filter(channels=[AcquireChannel(1)]) + + # check if the excluded and filtered schedule have the same name as sched + self.assertEqual(sched.name, filtered.name) + self.assertEqual(sched.name, excluded.name) + + def test_filter_inst_types(self): + """Test filtering on instruction types.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + sched = Schedule(name="fake_experiment") + sched = sched.insert(0, Play(lp0, self.config.drive(0))) + sched = sched.insert(10, Play(lp0, self.config.drive(1))) + sched = sched.insert(30, ShiftPhase(-1.57, self.config.drive(0))) + sched = sched.insert(40, SetFrequency(8.0, self.config.drive(0))) + sched = sched.insert(50, ShiftFrequency(4.0e6, self.config.drive(0))) + sched = sched.insert(55, SetPhase(3.14, self.config.drive(0))) + for i in range(2): + sched = sched.insert(60, Acquire(5, self.config.acquire(i), MemorySlot(i))) + sched = sched.insert(90, Play(lp0, self.config.drive(0))) + + # test on Acquire + only_acquire, no_acquire = self._filter_and_test_consistency( + sched, instruction_types=[Acquire] + ) + for _, inst in only_acquire.instructions: + self.assertIsInstance(inst, Acquire) + for _, inst in no_acquire.instructions: + self.assertFalse(isinstance(inst, Acquire)) + + # test two instruction types + only_pulse_and_fc, no_pulse_and_fc = self._filter_and_test_consistency( + sched, instruction_types=[Play, ShiftPhase] + ) + for _, inst in only_pulse_and_fc.instructions: + self.assertIsInstance(inst, (Play, ShiftPhase)) + for _, inst in no_pulse_and_fc.instructions: + self.assertFalse(isinstance(inst, (Play, ShiftPhase))) + self.assertEqual(len(only_pulse_and_fc.instructions), 4) + self.assertEqual(len(no_pulse_and_fc.instructions), 5) + + # test on ShiftPhase + only_fc, no_fc = self._filter_and_test_consistency(sched, instruction_types={ShiftPhase}) + self.assertEqual(len(only_fc.instructions), 1) + self.assertEqual(len(no_fc.instructions), 8) + + # test on SetPhase + only_setp, no_setp = self._filter_and_test_consistency(sched, instruction_types={SetPhase}) + self.assertEqual(len(only_setp.instructions), 1) + self.assertEqual(len(no_setp.instructions), 8) + + # test on SetFrequency + only_setf, no_setf = self._filter_and_test_consistency( + sched, instruction_types=[SetFrequency] + ) + for _, inst in only_setf.instructions: + self.assertTrue(isinstance(inst, SetFrequency)) + self.assertEqual(len(only_setf.instructions), 1) + self.assertEqual(len(no_setf.instructions), 8) + + # test on ShiftFrequency + only_shiftf, no_shiftf = self._filter_and_test_consistency( + sched, instruction_types=[ShiftFrequency] + ) + for _, inst in only_shiftf.instructions: + self.assertTrue(isinstance(inst, ShiftFrequency)) + self.assertEqual(len(only_shiftf.instructions), 1) + self.assertEqual(len(no_shiftf.instructions), 8) + + def test_filter_intervals(self): + """Test filtering on intervals.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + sched = Schedule(name="fake_experiment") + sched = sched.insert(0, Play(lp0, self.config.drive(0))) + sched = sched.insert(10, Play(lp0, self.config.drive(1))) + sched = sched.insert(30, ShiftPhase(-1.57, self.config.drive(0))) + for i in range(2): + sched = sched.insert(60, Acquire(5, self.config.acquire(i), MemorySlot(i))) + sched = sched.insert(90, Play(lp0, self.config.drive(0))) + + # split schedule into instructions occurring in (0,13), and those outside + filtered, excluded = self._filter_and_test_consistency(sched, time_ranges=((0, 13),)) + for start_time, inst in filtered.instructions: + self.assertTrue((start_time >= 0) and (start_time + inst.stop_time <= 13)) + for start_time, inst in excluded.instructions: + self.assertFalse((start_time >= 0) and (start_time + inst.stop_time <= 13)) + self.assertEqual(len(filtered.instructions), 2) + self.assertEqual(len(excluded.instructions), 4) + + # split into schedule occurring in and outside of interval (59,65) + filtered, excluded = self._filter_and_test_consistency(sched, time_ranges=[(59, 65)]) + self.assertEqual(len(filtered.instructions), 2) + self.assertEqual(filtered.instructions[0][0], 60) + self.assertIsInstance(filtered.instructions[0][1], Acquire) + self.assertEqual(len(excluded.instructions), 4) + self.assertEqual(excluded.instructions[3][0], 90) + self.assertIsInstance(excluded.instructions[3][1], Play) + + # split instructions based on the interval + # (none should be, though they have some overlap with some of the instructions) + filtered, excluded = self._filter_and_test_consistency( + sched, time_ranges=[(0, 2), (8, 11), (61, 70)] + ) + self.assertEqual(len(filtered.instructions), 0) + self.assertEqual(len(excluded.instructions), 6) + + # split instructions from multiple non-overlapping intervals, specified + # as time ranges + filtered, excluded = self._filter_and_test_consistency( + sched, time_ranges=[(10, 15), (63, 93)] + ) + self.assertEqual(len(filtered.instructions), 2) + self.assertEqual(len(excluded.instructions), 4) + + # split instructions from non-overlapping intervals, specified as Intervals + filtered, excluded = self._filter_and_test_consistency( + sched, intervals=[(10, 15), (63, 93)] + ) + self.assertEqual(len(filtered.instructions), 2) + self.assertEqual(len(excluded.instructions), 4) + + def test_filter_multiple(self): + """Test filter composition.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + sched = Schedule(name="fake_experiment") + sched = sched.insert(0, Play(lp0, self.config.drive(0))) + sched = sched.insert(10, Play(lp0, self.config.drive(1))) + sched = sched.insert(30, ShiftPhase(-1.57, self.config.drive(0))) + for i in range(2): + sched = sched.insert(60, Acquire(5, self.config.acquire(i), MemorySlot(i))) + + sched = sched.insert(90, Play(lp0, self.config.drive(0))) + + # split instructions with filters on channel 0, of type Play + # occurring in the time interval (25, 100) + filtered, excluded = self._filter_and_test_consistency( + sched, + channels={self.config.drive(0)}, + instruction_types=[Play], + time_ranges=[(25, 100)], + ) + for time, inst in filtered.instructions: + self.assertIsInstance(inst, Play) + self.assertTrue(all(chan.index == 0 for chan in inst.channels)) + self.assertTrue(25 <= time <= 100) + self.assertEqual(len(excluded.instructions), 5) + self.assertTrue(excluded.instructions[0][1].channels[0] == DriveChannel(0)) + self.assertTrue(excluded.instructions[2][0] == 30) + + # split based on Plays in the specified intervals + filtered, excluded = self._filter_and_test_consistency( + sched, instruction_types=[Play], time_ranges=[(25, 100), (0, 11)] + ) + self.assertTrue(len(excluded.instructions), 3) + for time, inst in filtered.instructions: + self.assertIsInstance(inst, (ShiftPhase, Play)) + self.assertTrue(len(filtered.instructions), 4) + # make sure the Play instruction is not in the intervals + self.assertIsInstance(excluded.instructions[0][1], Play) + + # split based on Acquire in the specified intervals + filtered, excluded = self._filter_and_test_consistency( + sched, instruction_types=[Acquire], time_ranges=[(25, 100)] + ) + self.assertTrue(len(excluded.instructions), 4) + for _, inst in filtered.instructions: + self.assertIsInstance(inst, Acquire) + self.assertTrue(len(filtered.instructions), 2) + + def test_custom_filters(self): + """Test custom filters.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + sched = Schedule(name="fake_experiment") + sched = sched.insert(0, Play(lp0, self.config.drive(0))) + sched = sched.insert(10, Play(lp0, self.config.drive(1))) + sched = sched.insert(30, ShiftPhase(-1.57, self.config.drive(0))) + + filtered, excluded = self._filter_and_test_consistency(sched, lambda x: True) + for i in filtered.instructions: + self.assertTrue(i in sched.instructions) + for i in excluded.instructions: + self.assertFalse(i in sched.instructions) + + filtered, excluded = self._filter_and_test_consistency(sched, lambda x: False) + self.assertEqual(len(filtered.instructions), 0) + self.assertEqual(len(excluded.instructions), 3) + + filtered, excluded = self._filter_and_test_consistency(sched, lambda x: x[0] < 30) + self.assertEqual(len(filtered.instructions), 2) + self.assertEqual(len(excluded.instructions), 1) + + # multiple custom filters + filtered, excluded = self._filter_and_test_consistency( + sched, lambda x: x[0] > 0, lambda x: x[0] < 30 + ) + self.assertEqual(len(filtered.instructions), 1) + self.assertEqual(len(excluded.instructions), 2) + + def test_empty_filters(self): + """Test behavior on empty filters.""" + lp0 = self.linear(duration=3, slope=0.2, intercept=0.1) + sched = Schedule(name="fake_experiment") + sched = sched.insert(0, Play(lp0, self.config.drive(0))) + sched = sched.insert(10, Play(lp0, self.config.drive(1))) + sched = sched.insert(30, ShiftPhase(-1.57, self.config.drive(0))) + for i in range(2): + sched = sched.insert(60, Acquire(5, self.config.acquire(i), MemorySlot(i))) + sched = sched.insert(90, Play(lp0, self.config.drive(0))) + + # empty channels + filtered, excluded = self._filter_and_test_consistency(sched, channels=[]) + self.assertTrue(len(filtered.instructions) == 0) + self.assertTrue(len(excluded.instructions) == 6) + + # empty instruction_types + filtered, excluded = self._filter_and_test_consistency(sched, instruction_types=[]) + self.assertTrue(len(filtered.instructions) == 0) + self.assertTrue(len(excluded.instructions) == 6) + + # empty time_ranges + filtered, excluded = self._filter_and_test_consistency(sched, time_ranges=[]) + self.assertTrue(len(filtered.instructions) == 0) + self.assertTrue(len(excluded.instructions) == 6) + + # empty intervals + filtered, excluded = self._filter_and_test_consistency(sched, intervals=[]) + self.assertTrue(len(filtered.instructions) == 0) + self.assertTrue(len(excluded.instructions) == 6) + + # empty channels with other non-empty filters + filtered, excluded = self._filter_and_test_consistency( + sched, channels=[], instruction_types=[Play] + ) + self.assertTrue(len(filtered.instructions) == 0) + self.assertTrue(len(excluded.instructions) == 6) + + def _filter_and_test_consistency(self, schedule: Schedule, *args, **kwargs): + """ + Returns the tuple + (schedule.filter(*args, **kwargs), schedule.exclude(*args, **kwargs)), + including a test that schedule.filter | schedule.exclude == schedule + """ + filtered = schedule.filter(*args, **kwargs) + excluded = schedule.exclude(*args, **kwargs) + self.assertEqual(filtered | excluded, schedule) + return filtered, excluded + + +class TestScheduleEquality(BaseTestSchedule): + """Test equality of schedules.""" + + def test_different_channels(self): + """Test equality is False if different channels.""" + self.assertNotEqual( + Schedule(ShiftPhase(0, DriveChannel(0))), Schedule(ShiftPhase(0, DriveChannel(1))) + ) + + def test_same_time_equal(self): + """Test equal if instruction at same time.""" + + self.assertEqual( + Schedule((0, ShiftPhase(0, DriveChannel(1)))), + Schedule((0, ShiftPhase(0, DriveChannel(1)))), + ) + + def test_different_time_not_equal(self): + """Test that not equal if instruction at different time.""" + self.assertNotEqual( + Schedule((0, ShiftPhase(0, DriveChannel(1)))), + Schedule((1, ShiftPhase(0, DriveChannel(1)))), + ) + + def test_single_channel_out_of_order(self): + """Test that schedule with single channel equal when out of order.""" + instructions = [ + (0, ShiftPhase(0, DriveChannel(0))), + (15, Play(Waveform(np.ones(10)), DriveChannel(0))), + (5, Play(Waveform(np.ones(10)), DriveChannel(0))), + ] + + self.assertEqual(Schedule(*instructions), Schedule(*reversed(instructions))) + + def test_multiple_channels_out_of_order(self): + """Test that schedule with multiple channels equal when out of order.""" + instructions = [ + (0, ShiftPhase(0, DriveChannel(1))), + (1, Acquire(10, AcquireChannel(0), MemorySlot(1))), + ] + + self.assertEqual(Schedule(*instructions), Schedule(*reversed(instructions))) + + def test_same_commands_on_two_channels_at_same_time_out_of_order(self): + """Test that schedule with same commands on two channels at the same time equal + when out of order.""" + sched1 = Schedule() + sched1 = sched1.append(Delay(100, DriveChannel(1))) + sched1 = sched1.append(Delay(100, ControlChannel(1))) + sched2 = Schedule() + sched2 = sched2.append(Delay(100, ControlChannel(1))) + sched2 = sched2.append(Delay(100, DriveChannel(1))) + self.assertEqual(sched1, sched2) + + def test_different_name_equal(self): + """Test that names are ignored when checking equality.""" + + self.assertEqual( + Schedule((0, ShiftPhase(0, DriveChannel(1), name="fc1")), name="s1"), + Schedule((0, ShiftPhase(0, DriveChannel(1), name="fc2")), name="s2"), + ) + + +class TestTimingUtils(QiskitTestCase): + """Test the Schedule helper functions.""" + + def test_overlaps(self): + """Test the `_overlaps` function.""" + a = (0, 1) + b = (1, 4) + c = (2, 3) + d = (3, 5) + self.assertFalse(_overlaps(a, b)) + self.assertFalse(_overlaps(b, a)) + self.assertFalse(_overlaps(a, d)) + self.assertTrue(_overlaps(b, c)) + self.assertTrue(_overlaps(c, b)) + self.assertTrue(_overlaps(b, d)) + self.assertTrue(_overlaps(d, b)) + + def test_overlaps_zero_duration(self): + """Test the `_overlaps` function for intervals with duration zero.""" + a = 0 + b = 1 + self.assertFalse(_overlaps((a, a), (a, a))) + self.assertFalse(_overlaps((a, a), (a, b))) + self.assertFalse(_overlaps((a, b), (a, a))) + self.assertFalse(_overlaps((a, b), (b, b))) + self.assertFalse(_overlaps((b, b), (a, b))) + self.assertTrue(_overlaps((a, a + 2), (a + 1, a + 1))) + self.assertTrue(_overlaps((a + 1, a + 1), (a, a + 2))) + + def test_find_insertion_index(self): + """Test the `_find_insertion_index` function.""" + intervals = [(1, 2), (4, 5)] + self.assertEqual(_find_insertion_index(intervals, (2, 3)), 1) + self.assertEqual(_find_insertion_index(intervals, (3, 4)), 1) + self.assertEqual(intervals, [(1, 2), (4, 5)]) + intervals = [(1, 2), (4, 5), (6, 7)] + self.assertEqual(_find_insertion_index(intervals, (2, 3)), 1) + self.assertEqual(_find_insertion_index(intervals, (0, 1)), 0) + self.assertEqual(_find_insertion_index(intervals, (5, 6)), 2) + self.assertEqual(_find_insertion_index(intervals, (8, 9)), 3) + + longer_intervals = [(1, 2), (2, 3), (4, 5), (5, 6), (7, 9), (11, 11)] + self.assertEqual(_find_insertion_index(longer_intervals, (4, 4)), 2) + self.assertEqual(_find_insertion_index(longer_intervals, (5, 5)), 3) + self.assertEqual(_find_insertion_index(longer_intervals, (3, 4)), 2) + self.assertEqual(_find_insertion_index(longer_intervals, (3, 4)), 2) + + # test when two identical zero duration timeslots are present + intervals = [(0, 10), (73, 73), (73, 73), (90, 101)] + self.assertEqual(_find_insertion_index(intervals, (42, 73)), 1) + self.assertEqual(_find_insertion_index(intervals, (73, 81)), 3) + + def test_find_insertion_index_when_overlapping(self): + """Test that `_find_insertion_index` raises an error when the new_interval _overlaps.""" + intervals = [(10, 20), (44, 55), (60, 61), (80, 1000)] + with self.assertRaises(PulseError): + _find_insertion_index(intervals, (60, 62)) + with self.assertRaises(PulseError): + _find_insertion_index(intervals, (100, 1500)) + + intervals = [(0, 1), (10, 15)] + with self.assertRaises(PulseError): + _find_insertion_index(intervals, (7, 13)) + + def test_find_insertion_index_empty_list(self): + """Test that the insertion index is properly found for empty lists.""" + self.assertEqual(_find_insertion_index([], (0, 1)), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/dynamics/pulse/test_transforms.py b/test/dynamics/pulse/test_transforms.py new file mode 100644 index 000000000..c3d5b7b0c --- /dev/null +++ b/test/dynamics/pulse/test_transforms.py @@ -0,0 +1,901 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# 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 cases for the pulse Schedule transforms.""" +import unittest +from typing import List, Set + +import numpy as np + +from qiskit import pulse +from qiskit_dynamics.pulse import ( + Play, + Delay, + Acquire, + Schedule, + Waveform, + Drag, + Gaussian, + GaussianSquare, + Constant, +) +from qiskit_dynamics.pulse import transforms, instructions +from qiskit_dynamics.pulse.channels import ( + MemorySlot, + DriveChannel, + AcquireChannel, + RegisterSlot, + SnapshotChannel, +) +from qiskit_dynamics.pulse.instructions import directives +from qiskit.providers.fake_provider import FakeOpenPulse2Q +from test import QiskitTestCase # pylint: disable=wrong-import-order + + +class TestAlignMeasures(QiskitTestCase): + """Test the helper function which aligns acquires.""" + + def setUp(self): + super().setUp() + with self.assertWarns(DeprecationWarning): + self.backend = FakeOpenPulse2Q() + self.config = self.backend.configuration() + self.inst_map = self.backend.defaults().instruction_schedule_map + self.short_pulse = pulse.Waveform( + samples=np.array([0.02739068], dtype=np.complex128), name="p0" + ) + + def test_align_measures(self): + """Test that one acquire is delayed to match the time of the later acquire.""" + sched = pulse.Schedule(name="fake_experiment") + sched.insert(0, Play(self.short_pulse, self.config.drive(0)), inplace=True) + sched.insert(1, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + sched.insert(10, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + sched.insert(10, Play(self.short_pulse, self.config.measure(0)), inplace=True) + sched.insert(11, Play(self.short_pulse, self.config.measure(0)), inplace=True) + sched.insert(10, Play(self.short_pulse, self.config.measure(1)), inplace=True) + aligned = transforms.align_measures([sched])[0] + self.assertEqual(aligned.name, "fake_experiment") + + ref = pulse.Schedule(name="fake_experiment") + ref.insert(0, Play(self.short_pulse, self.config.drive(0)), inplace=True) + ref.insert(10, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + ref.insert(10, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + ref.insert(19, Play(self.short_pulse, self.config.measure(0)), inplace=True) + ref.insert(20, Play(self.short_pulse, self.config.measure(0)), inplace=True) + ref.insert(10, Play(self.short_pulse, self.config.measure(1)), inplace=True) + + self.assertEqual(aligned, ref) + + aligned = transforms.align_measures([sched], self.inst_map, align_time=20)[0] + + ref = pulse.Schedule(name="fake_experiment") + ref.insert(10, Play(self.short_pulse, self.config.drive(0)), inplace=True) + ref.insert(20, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + ref.insert(20, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + ref.insert(29, Play(self.short_pulse, self.config.measure(0)), inplace=True) + ref.insert(30, Play(self.short_pulse, self.config.measure(0)), inplace=True) + ref.insert(20, Play(self.short_pulse, self.config.measure(1)), inplace=True) + self.assertEqual(aligned, ref) + + def test_align_post_u3(self): + """Test that acquires are scheduled no sooner than the duration of the longest X gate.""" + sched = pulse.Schedule(name="fake_experiment") + sched = sched.insert(0, Play(self.short_pulse, self.config.drive(0))) + sched = sched.insert(1, Acquire(5, self.config.acquire(0), MemorySlot(0))) + sched = transforms.align_measures([sched], self.inst_map)[0] + for time, inst in sched.instructions: + if isinstance(inst, Acquire): + self.assertEqual(time, 4) + sched = transforms.align_measures([sched], self.inst_map, max_calibration_duration=10)[0] + for time, inst in sched.instructions: + if isinstance(inst, Acquire): + self.assertEqual(time, 10) + + def test_multi_acquire(self): + """Test that the last acquire is aligned to if multiple acquires occur on the + same channel.""" + sched = pulse.Schedule() + sched.insert(0, Play(self.short_pulse, self.config.drive(0)), inplace=True) + sched.insert(4, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + sched.insert(20, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + sched.insert(10, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + aligned = transforms.align_measures([sched], self.inst_map) + + ref = pulse.Schedule() + ref.insert(0, Play(self.short_pulse, self.config.drive(0)), inplace=True) + ref.insert(20, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + ref.insert(20, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + ref.insert(26, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + self.assertEqual(aligned[0], ref) + + def test_multiple_acquires(self): + """Test that multiple acquires are also aligned.""" + sched = pulse.Schedule(name="fake_experiment") + sched.insert(0, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + sched.insert(5, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + sched.insert(10, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + + ref = pulse.Schedule() + ref.insert(10, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + ref.insert(15, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + ref.insert(10, Acquire(5, self.config.acquire(1), MemorySlot(1)), inplace=True) + + aligned = transforms.align_measures([sched], self.inst_map)[0] + + self.assertEqual(aligned, ref) + + def test_align_across_schedules(self): + """Test that acquires are aligned together across multiple schedules.""" + sched1 = pulse.Schedule(name="fake_experiment") + sched1 = sched1.insert(0, Play(self.short_pulse, self.config.drive(0))) + sched1 = sched1.insert(10, Acquire(5, self.config.acquire(0), MemorySlot(0))) + sched2 = pulse.Schedule(name="fake_experiment") + sched2 = sched2.insert(3, Play(self.short_pulse, self.config.drive(0))) + sched2 = sched2.insert(25, Acquire(5, self.config.acquire(0), MemorySlot(0))) + schedules = transforms.align_measures([sched1, sched2], self.inst_map) + for time, inst in schedules[0].instructions: + if isinstance(inst, Acquire): + self.assertEqual(time, 25) + for time, inst in schedules[0].instructions: + if isinstance(inst, Acquire): + self.assertEqual(time, 25) + + def test_align_all(self): + """Test alignment of all instructions in a schedule.""" + sched0 = pulse.Schedule() + sched0.insert(0, Play(self.short_pulse, self.config.drive(0)), inplace=True) + sched0.insert(10, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + + sched1 = pulse.Schedule() + sched1.insert(25, Play(self.short_pulse, self.config.drive(0)), inplace=True) + sched1.insert(25, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + + all_aligned = transforms.align_measures([sched0, sched1], self.inst_map, align_all=True) + + ref1_aligned = pulse.Schedule() + ref1_aligned.insert(15, Play(self.short_pulse, self.config.drive(0)), inplace=True) + ref1_aligned.insert(25, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + + self.assertEqual(all_aligned[0], ref1_aligned) + self.assertEqual(all_aligned[1], sched1) + + ref1_not_aligned = pulse.Schedule() + ref1_not_aligned.insert(0, Play(self.short_pulse, self.config.drive(0)), inplace=True) + ref1_not_aligned.insert(25, Acquire(5, self.config.acquire(0), MemorySlot(0)), inplace=True) + + all_not_aligned = transforms.align_measures( + [sched0, sched1], + self.inst_map, + align_all=False, + ) + self.assertEqual(all_not_aligned[0], ref1_not_aligned) + self.assertEqual(all_not_aligned[1], sched1) + + def test_measurement_at_zero(self): + """Test that acquire at t=0 works.""" + sched1 = pulse.Schedule(name="fake_experiment") + sched1 = sched1.insert(0, Play(self.short_pulse, self.config.drive(0))) + sched1 = sched1.insert(0, Acquire(5, self.config.acquire(0), MemorySlot(0))) + sched2 = pulse.Schedule(name="fake_experiment") + sched2 = sched2.insert(0, Play(self.short_pulse, self.config.drive(0))) + sched2 = sched2.insert(0, Acquire(5, self.config.acquire(0), MemorySlot(0))) + schedules = transforms.align_measures([sched1, sched2], max_calibration_duration=0) + for time, inst in schedules[0].instructions: + if isinstance(inst, Acquire): + self.assertEqual(time, 0) + for time, inst in schedules[0].instructions: + if isinstance(inst, Acquire): + self.assertEqual(time, 0) + + +class TestAddImplicitAcquires(QiskitTestCase): + """Test the helper function which makes implicit acquires explicit.""" + + def setUp(self): + super().setUp() + with self.assertWarns(DeprecationWarning): + self.backend = FakeOpenPulse2Q() + self.config = self.backend.configuration() + self.short_pulse = pulse.Waveform( + samples=np.array([0.02739068], dtype=np.complex128), name="p0" + ) + sched = pulse.Schedule(name="fake_experiment") + sched = sched.insert(0, Play(self.short_pulse, self.config.drive(0))) + sched = sched.insert(5, Acquire(5, self.config.acquire(0), MemorySlot(0))) + sched = sched.insert(5, Acquire(5, self.config.acquire(1), MemorySlot(1))) + self.sched = sched + + def test_add_implicit(self): + """Test that implicit acquires are made explicit according to the meas map.""" + sched = transforms.add_implicit_acquires(self.sched, [[0, 1]]) + acquired_qubits = set() + for _, inst in sched.instructions: + if isinstance(inst, Acquire): + acquired_qubits.add(inst.acquire.index) + self.assertEqual(acquired_qubits, {0, 1}) + + def test_add_across_meas_map_sublists(self): + """Test that implicit acquires in separate meas map sublists are all added.""" + sched = transforms.add_implicit_acquires(self.sched, [[0, 2], [1, 3]]) + acquired_qubits = set() + for _, inst in sched.instructions: + if isinstance(inst, Acquire): + acquired_qubits.add(inst.acquire.index) + self.assertEqual(acquired_qubits, {0, 1, 2, 3}) + + def test_dont_add_all(self): + """Test that acquires aren't added if no qubits in the sublist aren't being acquired.""" + sched = transforms.add_implicit_acquires(self.sched, [[4, 5], [0, 2], [1, 3]]) + acquired_qubits = set() + for _, inst in sched.instructions: + if isinstance(inst, Acquire): + acquired_qubits.add(inst.acquire.index) + self.assertEqual(acquired_qubits, {0, 1, 2, 3}) + + def test_multiple_acquires(self): + """Test for multiple acquires.""" + sched = pulse.Schedule() + acq_q0 = pulse.Acquire(1200, AcquireChannel(0), MemorySlot(0)) + sched += acq_q0 + sched += acq_q0 << sched.duration + sched = transforms.add_implicit_acquires(sched, meas_map=[[0]]) + self.assertEqual(sched.instructions, ((0, acq_q0), (2400, acq_q0))) + + +class TestPad(QiskitTestCase): + """Test padding of schedule with delays.""" + + def test_padding_empty_schedule(self): + """Test padding of empty schedule.""" + self.assertEqual(pulse.Schedule(), transforms.pad(pulse.Schedule())) + + def test_padding_schedule(self): + """Test padding schedule.""" + delay = 10 + sched = ( + Delay(delay, DriveChannel(0)).shift(10) + + Delay(delay, DriveChannel(0)).shift(10) + + Delay(delay, DriveChannel(1)).shift(10) + ) + + ref_sched = ( + sched # pylint: disable=unsupported-binary-operation + | Delay(delay, DriveChannel(0)) + | Delay(delay, DriveChannel(0)).shift(20) + | Delay(delay, DriveChannel(1)) + | Delay( # pylint: disable=unsupported-binary-operation + 2 * delay, DriveChannel(1) + ).shift(20) + ) + + self.assertEqual(transforms.pad(sched), ref_sched) + + def test_padding_schedule_inverse_order(self): + """Test padding schedule is insensitive to order in which commands were added. + + This test is the same as `test_adding_schedule` but the order by channel + in which commands were added to the schedule to be padded has been reversed. + """ + delay = 10 + sched = ( + Delay(delay, DriveChannel(1)).shift(10) + + Delay(delay, DriveChannel(0)).shift(10) + + Delay(delay, DriveChannel(0)).shift(10) + ) + + ref_sched = ( + sched # pylint: disable=unsupported-binary-operation + | Delay(delay, DriveChannel(0)) + | Delay(delay, DriveChannel(0)).shift(20) + | Delay(delay, DriveChannel(1)) + | Delay( # pylint: disable=unsupported-binary-operation + 2 * delay, DriveChannel(1) + ).shift(20) + ) + + self.assertEqual(transforms.pad(sched), ref_sched) + + def test_padding_until_less(self): + """Test padding until time that is less than schedule duration.""" + delay = 10 + + sched = Delay(delay, DriveChannel(0)).shift(10) + Delay(delay, DriveChannel(1)) + + ref_sched = sched | Delay(delay, DriveChannel(0)) | Delay(5, DriveChannel(1)).shift(10) + + self.assertEqual(transforms.pad(sched, until=15), ref_sched) + + def test_padding_until_greater(self): + """Test padding until time that is greater than schedule duration.""" + delay = 10 + + sched = Delay(delay, DriveChannel(0)).shift(10) + Delay(delay, DriveChannel(1)) + + ref_sched = ( + sched # pylint: disable=unsupported-binary-operation + | Delay(delay, DriveChannel(0)) + | Delay(30, DriveChannel(0)).shift(20) + | Delay(40, DriveChannel(1)).shift(10) # pylint: disable=unsupported-binary-operation + ) + + self.assertEqual(transforms.pad(sched, until=50), ref_sched) + + def test_padding_supplied_channels(self): + """Test padding of only specified channels.""" + delay = 10 + sched = Delay(delay, DriveChannel(0)).shift(10) + Delay(delay, DriveChannel(1)) + + ref_sched = sched | Delay(delay, DriveChannel(0)) | Delay(2 * delay, DriveChannel(2)) + + channels = [DriveChannel(0), DriveChannel(2)] + + self.assertEqual(transforms.pad(sched, channels=channels), ref_sched) + + def test_padding_less_than_sched_duration(self): + """Test that the until arg is respected even for less than the input schedule duration.""" + delay = 10 + sched = Delay(delay, DriveChannel(0)) + Delay(delay, DriveChannel(0)).shift(20) + ref_sched = sched | pulse.Delay(5, DriveChannel(0)).shift(10) + self.assertEqual(transforms.pad(sched, until=15), ref_sched) + + def test_padding_prepended_delay(self): + """Test that there is delay before the first instruction.""" + delay = 10 + sched = Delay(delay, DriveChannel(0)).shift(10) + Delay(delay, DriveChannel(0)) + + ref_sched = ( + Delay(delay, DriveChannel(0)) + + Delay(delay, DriveChannel(0)) + + Delay(delay, DriveChannel(0)) + ) + + self.assertEqual(transforms.pad(sched, until=30, inplace=True), ref_sched) + + def test_pad_no_delay_on_classical_io_channels(self): + """Test padding does not apply to classical IO channels.""" + delay = 10 + sched = ( + Delay(delay, MemorySlot(0)).shift(20) + + Delay(delay, RegisterSlot(0)).shift(10) + + Delay(delay, SnapshotChannel()) + ) + + ref_sched = ( + Delay(delay, MemorySlot(0)).shift(20) + + Delay(delay, RegisterSlot(0)).shift(10) + + Delay(delay, SnapshotChannel()) + ) + + self.assertEqual(transforms.pad(sched, until=15), ref_sched) + + +def get_pulse_ids(schedules: List[Schedule]) -> Set[int]: + """Returns ids of pulses used in Schedules.""" + ids = set() + for schedule in schedules: + for _, inst in schedule.instructions: + ids.add(inst.pulse.id) + return ids + + +class TestCompressTransform(QiskitTestCase): + """Compress function test.""" + + def test_with_duplicates(self): + """Test compression of schedule.""" + schedule = Schedule() + drive_channel = DriveChannel(0) + schedule += Play(Waveform([0.0, 0.1]), drive_channel) + schedule += Play(Waveform([0.0, 0.1]), drive_channel) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + + self.assertEqual(len(compressed_pulse_ids), 1) + self.assertEqual(len(original_pulse_ids), 2) + self.assertTrue(next(iter(compressed_pulse_ids)) in original_pulse_ids) + + def test_sample_pulse_with_clipping(self): + """Test sample pulses with clipping.""" + schedule = Schedule() + drive_channel = DriveChannel(0) + schedule += Play(Waveform([0.0, 1.0]), drive_channel) + schedule += Play(Waveform([0.0, 1.001], epsilon=1e-3), drive_channel) + schedule += Play(Waveform([0.0, 1.0000000001]), drive_channel) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + + self.assertEqual(len(compressed_pulse_ids), 1) + self.assertEqual(len(original_pulse_ids), 3) + self.assertTrue(next(iter(compressed_pulse_ids)) in original_pulse_ids) + + def test_no_duplicates(self): + """Test with no pulse duplicates.""" + schedule = Schedule() + drive_channel = DriveChannel(0) + schedule += Play(Waveform([0.0, 1.0]), drive_channel) + schedule += Play(Waveform([0.0, 0.9]), drive_channel) + schedule += Play(Waveform([0.0, 0.3]), drive_channel) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + self.assertEqual(len(original_pulse_ids), len(compressed_pulse_ids)) + + def test_parametric_pulses_with_duplicates(self): + """Test with parametric pulses.""" + schedule = Schedule() + drive_channel = DriveChannel(0) + schedule += Play(Gaussian(duration=25, sigma=4, amp=0.5, angle=np.pi / 2), drive_channel) + schedule += Play(Gaussian(duration=25, sigma=4, amp=0.5, angle=np.pi / 2), drive_channel) + schedule += Play(GaussianSquare(duration=150, amp=0.2, sigma=8, width=140), drive_channel) + schedule += Play(GaussianSquare(duration=150, amp=0.2, sigma=8, width=140), drive_channel) + schedule += Play(Constant(duration=150, amp=0.5, angle=0.7), drive_channel) + schedule += Play(Constant(duration=150, amp=0.5, angle=0.7), drive_channel) + schedule += Play(Drag(duration=25, amp=0.4, angle=-0.3, sigma=7.8, beta=4), drive_channel) + schedule += Play(Drag(duration=25, amp=0.4, angle=-0.3, sigma=7.8, beta=4), drive_channel) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + self.assertEqual(len(original_pulse_ids), 8) + self.assertEqual(len(compressed_pulse_ids), 4) + + def test_parametric_pulses_with_no_duplicates(self): + """Test parametric pulses with no duplicates.""" + schedule = Schedule() + drive_channel = DriveChannel(0) + schedule += Play(Gaussian(duration=25, sigma=4, amp=0.5, angle=np.pi / 2), drive_channel) + schedule += Play(Gaussian(duration=25, sigma=4, amp=0.49, angle=np.pi / 2), drive_channel) + schedule += Play(GaussianSquare(duration=150, amp=0.2, sigma=8, width=140), drive_channel) + schedule += Play(GaussianSquare(duration=150, amp=0.19, sigma=8, width=140), drive_channel) + schedule += Play(Constant(duration=150, amp=0.5, angle=0.3), drive_channel) + schedule += Play(Constant(duration=150, amp=0.51, angle=0.3), drive_channel) + schedule += Play(Drag(duration=25, amp=0.5, angle=0.5, sigma=7.8, beta=4), drive_channel) + schedule += Play(Drag(duration=25, amp=0.5, angle=0.51, sigma=7.8, beta=4), drive_channel) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + self.assertEqual(len(original_pulse_ids), len(compressed_pulse_ids)) + + def test_with_different_channels(self): + """Test with different channels.""" + schedule = Schedule() + schedule += Play(Waveform([0.0, 0.1]), DriveChannel(0)) + schedule += Play(Waveform([0.0, 0.1]), DriveChannel(1)) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + self.assertEqual(len(original_pulse_ids), 2) + self.assertEqual(len(compressed_pulse_ids), 1) + + def test_sample_pulses_with_tolerance(self): + """Test sample pulses with tolerance.""" + schedule = Schedule() + schedule += Play(Waveform([0.0, 0.1001], epsilon=1e-3), DriveChannel(0)) + schedule += Play(Waveform([0.0, 0.1], epsilon=1e-3), DriveChannel(1)) + + compressed_schedule = transforms.compress_pulses([schedule]) + original_pulse_ids = get_pulse_ids([schedule]) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + self.assertEqual(len(original_pulse_ids), 2) + self.assertEqual(len(compressed_pulse_ids), 1) + + def test_multiple_schedules(self): + """Test multiple schedules.""" + schedules = [] + for _ in range(2): + schedule = Schedule() + drive_channel = DriveChannel(0) + schedule += Play(Waveform([0.0, 0.1]), drive_channel) + schedule += Play(Waveform([0.0, 0.1]), drive_channel) + schedule += Play(Waveform([0.0, 0.2]), drive_channel) + schedules.append(schedule) + + compressed_schedule = transforms.compress_pulses(schedules) + original_pulse_ids = get_pulse_ids(schedules) + compressed_pulse_ids = get_pulse_ids(compressed_schedule) + self.assertEqual(len(original_pulse_ids), 6) + self.assertEqual(len(compressed_pulse_ids), 2) + + +class TestAlignSequential(QiskitTestCase): + """Test sequential alignment transform.""" + + def test_align_sequential(self): + """Test sequential alignment without a barrier.""" + context = transforms.AlignSequential() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + schedule = pulse.Schedule() + schedule.insert(1, instructions.Delay(3, d0), inplace=True) + schedule.insert(4, instructions.Delay(5, d1), inplace=True) + schedule.insert(12, instructions.Delay(7, d0), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + # d0 + reference.insert(0, instructions.Delay(3, d0), inplace=True) + reference.insert(8, instructions.Delay(7, d0), inplace=True) + # d1 + reference.insert(3, instructions.Delay(5, d1), inplace=True) + + self.assertEqual(schedule, reference) + + def test_align_sequential_with_barrier(self): + """Test sequential alignment with a barrier.""" + context = transforms.AlignSequential() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + schedule = pulse.Schedule() + schedule.insert(1, instructions.Delay(3, d0), inplace=True) + schedule.append(directives.RelativeBarrier(d0, d1), inplace=True) + schedule.insert(4, instructions.Delay(5, d1), inplace=True) + schedule.insert(12, instructions.Delay(7, d0), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(0, instructions.Delay(3, d0), inplace=True) + reference.insert(3, directives.RelativeBarrier(d0, d1), inplace=True) + reference.insert(3, instructions.Delay(5, d1), inplace=True) + reference.insert(8, instructions.Delay(7, d0), inplace=True) + + self.assertEqual(schedule, reference) + + +class TestAlignLeft(QiskitTestCase): + """Test left alignment transform.""" + + def test_align_left(self): + """Test left alignment without a barrier.""" + context = transforms.AlignLeft() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + d2 = pulse.DriveChannel(2) + + schedule = pulse.Schedule() + schedule.insert(1, instructions.Delay(3, d0), inplace=True) + schedule.insert(17, instructions.Delay(11, d2), inplace=True) + + sched_grouped = pulse.Schedule() + sched_grouped += instructions.Delay(5, d1) + sched_grouped += instructions.Delay(7, d0) + schedule.append(sched_grouped, inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + # d0 + reference.insert(0, instructions.Delay(3, d0), inplace=True) + reference.insert(3, instructions.Delay(7, d0), inplace=True) + # d1 + reference.insert(3, instructions.Delay(5, d1), inplace=True) + # d2 + reference.insert(0, instructions.Delay(11, d2), inplace=True) + + self.assertEqual(schedule, reference) + + def test_align_left_with_barrier(self): + """Test left alignment with a barrier.""" + context = transforms.AlignLeft() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + d2 = pulse.DriveChannel(2) + + schedule = pulse.Schedule() + schedule.insert(1, instructions.Delay(3, d0), inplace=True) + schedule.append(directives.RelativeBarrier(d0, d1, d2), inplace=True) + schedule.insert(17, instructions.Delay(11, d2), inplace=True) + + sched_grouped = pulse.Schedule() + sched_grouped += instructions.Delay(5, d1) + sched_grouped += instructions.Delay(7, d0) + schedule.append(sched_grouped, inplace=True) + schedule = transforms.remove_directives(context.align(schedule)) + + reference = pulse.Schedule() + # d0 + reference.insert(0, instructions.Delay(3, d0), inplace=True) + reference.insert(3, instructions.Delay(7, d0), inplace=True) + # d1 + reference = reference.insert(3, instructions.Delay(5, d1)) + # d2 + reference = reference.insert(3, instructions.Delay(11, d2)) + + self.assertEqual(schedule, reference) + + +class TestAlignRight(QiskitTestCase): + """Test right alignment transform.""" + + def test_align_right(self): + """Test right alignment without a barrier.""" + context = transforms.AlignRight() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + d2 = pulse.DriveChannel(2) + + schedule = pulse.Schedule() + schedule.insert(1, instructions.Delay(3, d0), inplace=True) + schedule.insert(17, instructions.Delay(11, d2), inplace=True) + + sched_grouped = pulse.Schedule() + sched_grouped.insert(2, instructions.Delay(5, d1), inplace=True) + sched_grouped += instructions.Delay(7, d0) + + schedule.append(sched_grouped, inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + # d0 + reference.insert(1, instructions.Delay(3, d0), inplace=True) + reference.insert(4, instructions.Delay(7, d0), inplace=True) + # d1 + reference.insert(6, instructions.Delay(5, d1), inplace=True) + # d2 + reference.insert(0, instructions.Delay(11, d2), inplace=True) + self.assertEqual(schedule, reference) + + def test_align_right_with_barrier(self): + """Test right alignment with a barrier.""" + context = transforms.AlignRight() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + d2 = pulse.DriveChannel(2) + + schedule = pulse.Schedule() + schedule.insert(1, instructions.Delay(3, d0), inplace=True) + schedule.append(directives.RelativeBarrier(d0, d1, d2), inplace=True) + schedule.insert(17, instructions.Delay(11, d2), inplace=True) + + sched_grouped = pulse.Schedule() + sched_grouped.insert(2, instructions.Delay(5, d1), inplace=True) + sched_grouped += instructions.Delay(7, d0) + + schedule.append(sched_grouped, inplace=True) + schedule = transforms.remove_directives(context.align(schedule)) + + reference = pulse.Schedule() + # d0 + reference.insert(0, instructions.Delay(3, d0), inplace=True) + reference.insert(7, instructions.Delay(7, d0), inplace=True) + # d1 + reference.insert(9, instructions.Delay(5, d1), inplace=True) + # d2 + reference.insert(3, instructions.Delay(11, d2), inplace=True) + + self.assertEqual(schedule, reference) + + +class TestAlignEquispaced(QiskitTestCase): + """Test equispaced alignment transform.""" + + def test_equispaced_with_short_duration(self): + """Test equispaced context with duration shorter than the schedule duration.""" + context = transforms.AlignEquispaced(duration=20) + + d0 = pulse.DriveChannel(0) + + schedule = pulse.Schedule() + for _ in range(3): + schedule.append(Delay(10, d0), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(0, Delay(10, d0), inplace=True) + reference.insert(10, Delay(10, d0), inplace=True) + reference.insert(20, Delay(10, d0), inplace=True) + + self.assertEqual(schedule, reference) + + def test_equispaced_with_longer_duration(self): + """Test equispaced context with duration longer than the schedule duration.""" + context = transforms.AlignEquispaced(duration=50) + + d0 = pulse.DriveChannel(0) + + schedule = pulse.Schedule() + for _ in range(3): + schedule.append(Delay(10, d0), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(0, Delay(10, d0), inplace=True) + reference.insert(20, Delay(10, d0), inplace=True) + reference.insert(40, Delay(10, d0), inplace=True) + + self.assertEqual(schedule, reference) + + def test_equispaced_with_multiple_channels_short_duration(self): + """Test equispaced context with multiple channels and duration shorter than the total + duration.""" + context = transforms.AlignEquispaced(duration=20) + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + schedule = pulse.Schedule() + schedule.append(Delay(10, d0), inplace=True) + schedule.append(Delay(20, d1), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(0, Delay(10, d0), inplace=True) + reference.insert(0, Delay(20, d1), inplace=True) + + self.assertEqual(schedule, reference) + + def test_equispaced_with_multiple_channels_longer_duration(self): + """Test equispaced context with multiple channels and duration longer than the total + duration.""" + context = transforms.AlignEquispaced(duration=30) + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + schedule = pulse.Schedule() + schedule.append(Delay(10, d0), inplace=True) + schedule.append(Delay(20, d1), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(0, Delay(10, d0), inplace=True) + reference.insert(10, Delay(20, d1), inplace=True) + + self.assertEqual(schedule, reference) + + +class TestAlignFunc(QiskitTestCase): + """Test callback alignment transform.""" + + @staticmethod + def _position(ind): + """Returns 0.25, 0.5, 0.75 for ind = 1, 2, 3.""" + return ind / (3 + 1) + + def test_numerical_with_short_duration(self): + """Test numerical alignment context with duration shorter than the schedule duration.""" + context = transforms.AlignFunc(duration=20, func=self._position) + + d0 = pulse.DriveChannel(0) + + schedule = pulse.Schedule() + for _ in range(3): + schedule.append(Delay(10, d0), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(0, Delay(10, d0), inplace=True) + reference.insert(10, Delay(10, d0), inplace=True) + reference.insert(20, Delay(10, d0), inplace=True) + + self.assertEqual(schedule, reference) + + def test_numerical_with_longer_duration(self): + """Test numerical alignment context with duration longer than the schedule duration.""" + context = transforms.AlignFunc(duration=80, func=self._position) + + d0 = pulse.DriveChannel(0) + + schedule = pulse.Schedule() + for _ in range(3): + schedule.append(Delay(10, d0), inplace=True) + schedule = context.align(schedule) + + reference = pulse.Schedule() + reference.insert(15, Delay(10, d0), inplace=True) + reference.insert(35, Delay(10, d0), inplace=True) + reference.insert(55, Delay(10, d0), inplace=True) + + self.assertEqual(schedule, reference) + + +class TestFlatten(QiskitTestCase): + """Test flattening transform.""" + + def test_flatten(self): + """Test the flatten transform.""" + context_left = transforms.AlignLeft() + + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + schedule = pulse.Schedule() + schedule += instructions.Delay(3, d0) + + grouped = pulse.Schedule() + grouped += instructions.Delay(5, d1) + grouped += instructions.Delay(7, d0) + # include a grouped schedule + grouped = schedule + grouped + + # flatten the schedule inline internal groups + flattened = transforms.flatten(grouped) + + # align all the instructions to the left after flattening + flattened = context_left.align(flattened) + grouped = context_left.align(grouped) + + reference = pulse.Schedule() + # d0 + reference.insert(0, instructions.Delay(3, d0), inplace=True) + reference.insert(3, instructions.Delay(7, d0), inplace=True) + # d1 + reference.insert(0, instructions.Delay(5, d1), inplace=True) + + self.assertEqual(flattened, reference) + self.assertNotEqual(grouped, reference) + + +class _TestDirective(directives.Directive): + """Pulse ``RelativeBarrier`` directive.""" + + def __init__(self, *channels): + """Test directive""" + super().__init__(operands=tuple(channels)) + + @property + def channels(self): + return self.operands + + +class TestRemoveDirectives(QiskitTestCase): + """Test removing of directives.""" + + def test_remove_directives(self): + """Test that all directives are removed.""" + d0 = pulse.DriveChannel(0) + d1 = pulse.DriveChannel(1) + + schedule = pulse.Schedule() + schedule += _TestDirective(d0, d1) + schedule += instructions.Delay(3, d0) + schedule += _TestDirective(d0, d1) + schedule = transforms.remove_directives(schedule) + + reference = pulse.Schedule() + # d0 + reference += instructions.Delay(3, d0) + self.assertEqual(schedule, reference) + + +class TestRemoveTrivialBarriers(QiskitTestCase): + """Test scheduling transforms.""" + + def test_remove_trivial_barriers(self): + """Test that trivial barriers are properly removed.""" + schedule = pulse.Schedule() + schedule += directives.RelativeBarrier() + schedule += directives.RelativeBarrier(pulse.DriveChannel(0)) + schedule += directives.RelativeBarrier(pulse.DriveChannel(0), pulse.DriveChannel(1)) + schedule = transforms.remove_trivial_barriers(schedule) + + reference = pulse.Schedule() + reference += directives.RelativeBarrier(pulse.DriveChannel(0), pulse.DriveChannel(1)) + self.assertEqual(schedule, reference) + + +if __name__ == "__main__": + unittest.main()