"""Pulse envelope models for quantum control.
Envelopes define the local complex pulse shape ``E(t)`` that a drive line plays
between ``t = 0`` and ``t = duration``. Subclasses are auto-registered for
serialization *and* as JAX pytrees, so envelope parameters stay differentiable
end-to-end. These include duration, amplitude, edge width, DRAG coefficient,
and all other values stored on the instance.
Conventions
-----------
- Times are in ns. Values are complex, and the real and imaginary parts carry
the relative I/Q structure.
- Global phase belongs to scheduling, not to an envelope.
- ``value(local_time)`` stays JAX-traceable and must not concretize time or
stored parameters.
References
----------
- Motzoi et al., *Simple Pulses for Elimination of Leakage*,
PRL 103, 110501 (2009) — motivates Gaussian envelopes with DRAG
corrections for short transmon pulses.
- Krantz et al., APR 6, 021318 (2019), Sec. IV.C — flat-top
(Gaussian-edge) pulses for two-qubit gates.
Examples
--------
>>> from quchip import Gaussian, LinearRamp, Square, SquareWithGaussianEdges
>>> g = Gaussian(duration=20.0, sigmas=3.0, amplitude=0.05)
>>> sq = Square(duration=10.0, amplitude=0.1)
>>> fg = SquareWithGaussianEdges(duration=40.0, amplitude=0.1)
>>> lr = LinearRamp(duration=60.0, ramp_duration=50.0, amplitude=4.0)
"""
# Envelope subclasses are auto-registered via `__init_subclass__`.
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Mapping, Self
import jax.tree_util as jtu
import numpy as np
from jax.scipy.special import erf
from quchip.declarative import qnp
from quchip.declarative.parameters import (
_synthesize_init,
DeclarativeMeta,
Scalar,
build_declared_signature,
parameter,
parameter_fields,
resolve_declared_params,
serializable_value,
validate_sign,
)
from quchip.utils.jax_utils import array_namespace as _pick_namespace
from quchip.utils.jax_utils import maybe_concrete_scalar
from quchip.utils.registry import Registrable
def _synthesize_envelope_init(cls: type["Envelope"]) -> Any:
"""Build a constructor from declared envelope parameters."""
signature = build_declared_signature(parameter_fields(cls))
return _synthesize_init(
cls, signature, Envelope.__init__,
doc=f"Initialize {cls.__name__} from its declared parameters.",
)
[docs]
class Envelope(Registrable, ABC, registry_root=True, metaclass=DeclarativeMeta):
"""Local complex pulse shape evaluated relative to its scheduled start.
Concrete subclasses declare ``duration`` and shape parameters with
:func:`~quchip.declarative.parameters.parameter`. Constructors are
synthesized from those declarations. Times are in ns and values are complex
I/Q amplitudes.
Parameters
----------
**params : Any
Declared envelope fields. Concrete subclasses define their names,
defaults, units, and validation constraints.
"""
duration: Scalar
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
if "__init__" not in cls.__dict__:
cls.__init__ = _synthesize_envelope_init(cls) # type: ignore[method-assign]
def _flatten(obj: Any) -> tuple[tuple[Any, ...], tuple[str, ...]]:
names = tuple(parameter_fields(type(obj)))
return tuple(getattr(obj, n) for n in names), names
def _unflatten(field_names: tuple[str, ...], children: tuple[Any, ...]) -> Any:
new = cls.__new__(cls)
for name, value in zip(field_names, children):
setattr(new, name, value)
return new
jtu.register_pytree_node(cls, _flatten, _unflatten)
def __init__(self, **params: Any) -> None:
values = resolve_declared_params(type(self), params)
if "duration" not in values:
raise TypeError(
f"{type(self).__name__} must declare "
"`duration: Scalar = parameter(positive=True)`."
)
for name, value in values.items():
setattr(self, name, value)
self.validate()
def __setattr__(self, name: str, value: Any) -> None:
"""Validate declared fields on concrete writes without tracing values."""
if not name.startswith("_"):
spec = parameter_fields(type(self)).get(name)
if spec is not None:
validate_sign(name, spec, value)
super().__setattr__(name, value)
[docs]
def validate(self) -> None:
"""Validate relations between concrete parameters."""
[docs]
def parameter_values(self) -> dict[str, Any]:
"""Return declared numerical fields available for rebinding."""
names = parameter_fields(type(self)) or tuple(name for name in vars(self) if not name.startswith("_"))
return {name: getattr(self, name) for name in names}
[docs]
def with_params(self, bindings: Mapping[str, Any]) -> Self:
"""Return an independent envelope after applying parameter bindings.
Parameters
----------
bindings : mapping[str, Any]
Declared field names and replacement values. Unknown names raise
``ValueError``.
"""
from quchip.utils.values import copy_value
unknown = set(bindings) - self.parameter_values().keys()
if unknown:
raise ValueError(f"Envelope {type(self).__name__!r} has no parameter fields {sorted(unknown)}")
candidate = copy_value(self)
for name, value in bindings.items():
setattr(candidate, name, copy_value(value))
candidate.validate()
return candidate
[docs]
@abstractmethod
def value(self, local_time: Any) -> Any:
"""Return complex I/Q shape at local time in ns.
Parameters
----------
local_time : scalar or array-like
Time relative to the scheduled pulse start.
"""
...
[docs]
def sampling_times(self) -> Any:
"""Return local feature samples for automatic grids, overridable for narrow shapes.
The default probes 65 evenly spaced points, and built-in envelopes
refine their characteristic widths. The QuTiP backend also interpolates
the envelope linearly between knots that include these times, with a
close knot on each side of each time. List the time of each step, so
that the step stays sharp at any pulse start, up to float precision.
QuTiP otherwise ramps a step across one interpolation interval of up to
25 ps. The dynamiqs backend evaluates the envelope directly.
"""
return qnp.linspace(0.0, self.duration, 65)
[docs]
def area(self) -> Any:
"""Return the complex area ``∫₀^τ E(t) dt``, in ns times the amplitude unit.
Built-in envelopes return closed forms. Other envelopes integrate
:meth:`value` with 16-point Gauss-Legendre quadrature on each interval
between the sorted :meth:`sampling_times`, so the area stays traceable
under JAX. Its accuracy depends on how well these times resolve the
shape, so list the time of each step or narrow feature in
:meth:`sampling_times` and check convergence.
"""
knots = qnp.sort(qnp.clip(qnp.concatenate((
qnp.asarray([0.0]), qnp.ravel(qnp.asarray(self.sampling_times(), dtype=float)),
qnp.reshape(qnp.asarray(self.duration, dtype=float), (1,)),
)), 0.0, self.duration))
left, right = knots[:-1], knots[1:]
nodes, weights = np.polynomial.legendre.leggauss(16)
half = (right - left) / 2.0
times = (left + right)[:, None] / 2.0 + half[:, None] * qnp.asarray(nodes)[None, :]
values = qnp.asarray(self.value(times), dtype=complex)
return qnp.sum(half * qnp.sum(qnp.asarray(weights)[None, :] * values, axis=1))
[docs]
def sample(self, local_time: Any, *, real: bool = False) -> Any:
"""Evaluate the shape, optionally returning only its real I component.
Parameters
----------
local_time : scalar or array-like
Local sample times in ns.
real : bool, default False
Return real I values instead of complex I/Q values.
"""
xp = _pick_namespace(local_time)
values = xp.asarray(
self.value(xp.asarray(local_time, dtype=float)),
dtype=complex,
)
return values.real if real else values
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize the concrete type and its declared parameters."""
data = super().to_dict()
for name, spec in parameter_fields(type(self)).items():
if spec.serialize:
data[name] = serializable_value(getattr(self, name))
return data
@classmethod
def _from_dict_payload(cls, data: dict[str, Any]) -> "Envelope":
"""Reconstruct one concrete envelope from declared parameters."""
params = {
name: data[name]
for name, spec in parameter_fields(cls).items()
if spec.serialize and name in data
}
return cls(**params)
def __repr__(self) -> str:
"""Return a constructor-like representation of declared parameters."""
params = ", ".join(
f"{name}={getattr(self, name)}" for name in parameter_fields(type(self))
)
return f"{type(self).__name__}({params})"
def _gaussian_flat_top(t: Any, duration: Any, edge_duration: Any, sigmas: Any, amplitude: Any) -> Any:
r"""Complex flat-top waveform with Gaussian ramp-up / ramp-down edges.
Shared by :class:`GaussianEdge` and :class:`SquareWithGaussianEdges`,
which differ only in how they derive ``edge_duration`` (a stored
parameter vs. ``edge_frac * duration``). Each edge is a Gaussian of
width :math:`\sigma = \tau_e / (2 N_\sigma)` (``edge_duration`` =
:math:`\tau_e`, ``sigmas`` = :math:`N_\sigma`); the plateau between the
edges holds constant ``amplitude``.
Pure ``qnp`` arithmetic throughout, so every argument (including
``duration``, ``edge_duration``, and ``amplitude``) may be a JAX tracer
and the waveform stays differentiable end-to-end.
"""
sigma = edge_duration / (2.0 * sigmas)
two_sigma_sq = 2.0 * sigma**2
out = qnp.ones_like(t, dtype=complex) * amplitude
out = qnp.where(
t < edge_duration,
amplitude * qnp.exp(-((t - edge_duration) ** 2) / two_sigma_sq),
out,
)
fall_center = duration - edge_duration
out = qnp.where(
t > fall_center,
amplitude * qnp.exp(-((t - fall_center) ** 2) / two_sigma_sq),
out,
)
return qnp.asarray(out, dtype=complex)
def _gaussian_flat_top_area(duration: Any, edge_duration: Any, sigmas: Any, amplitude: Any) -> Any:
"""Return the area of :func:`_gaussian_flat_top`: the plateau plus two half-Gaussian edges."""
sigma = edge_duration / (2.0 * sigmas)
edges = sigma * qnp.sqrt(2.0 * qnp.pi) * erf(qnp.sqrt(2.0) * sigmas)
return qnp.asarray(amplitude * (duration - 2.0 * edge_duration + edges), dtype=complex)
def _gaussian_area(duration: Any, sigmas: Any, amplitude: Any) -> Any:
"""Return the area of a centered Gaussian whose window ends ``sigmas`` widths from its center."""
sigma = duration / (2.0 * sigmas)
return qnp.asarray(amplitude * sigma * qnp.sqrt(2.0 * qnp.pi) * erf(sigmas / qnp.sqrt(2.0)), dtype=complex)
def _gaussian_sampling_times(duration: Any, sigmas: Any) -> Any:
sigma = duration / (2 * sigmas)
half_width = qnp.minimum(duration / 2, 6 * sigma)
return qnp.concatenate((qnp.asarray([0.0]),
duration / 2 + qnp.linspace(-half_width, half_width, 65),
qnp.asarray([duration])))
def _gaussian_edge_sampling_times(duration: Any, edge_duration: Any, sigmas: Any) -> Any:
width = qnp.minimum(edge_duration, 6 * edge_duration / (2 * sigmas))
rising = qnp.linspace(edge_duration - width, edge_duration, 33)
return qnp.concatenate((qnp.asarray([0.0]), rising, duration - rising, qnp.asarray([duration])))
[docs]
class Gaussian(Envelope):
r"""Centered Gaussian pulse.
.. math::
E(t) = A \exp\!\left[-\frac{(t - \tau/2)^2}{2 \sigma^2}\right],
\qquad \sigma = \frac{\tau}{2 N_\sigma}.
The ``sigmas`` parameter :math:`N_\sigma` is the number of standard
deviations from the pulse center to its edge at ``t = 0`` or
``t = duration``. Gaussian pulses minimize spectral leakage onto higher
transmon levels and are the starting point for DRAG corrections (Motzoi et
al., PRL 103, 110501 (2009)).
The scheduled window ``[0, duration]`` starts and ends at
``amplitude * exp(-sigmas**2 / 2)``, not at zero. This is approximately
``0.011 * amplitude`` at the default ``sigmas=3``. The pulse turns on and
off with that jump, and the Gaussian waveform itself is unchanged.
Parameters
----------
duration : float
Pulse duration in ns. Must be positive.
sigmas : float, default 3
Number of standard deviations from the pulse center to each window
edge, so ``sigma = duration/(2*sigmas)``. Must be positive.
amplitude : float, default 1.0
Peak in-phase envelope amplitude. The consuming drive supplies its
physical unit (for example GHz for a Hamiltonian drive or 1/sqrt(ns)
for an incident field).
References
----------
Motzoi et al., *Phys. Rev. Lett.* 103, 110501 (2009),
https://doi.org/10.1103/PhysRevLett.103.110501.
"""
duration: Scalar = parameter(positive=True, unit="ns")
sigmas: Scalar = parameter(default=3, positive=True)
amplitude: Scalar = parameter(default=1.0)
[docs]
def sampling_times(self) -> Any:
return _gaussian_sampling_times(self.duration, self.sigmas)
[docs]
def area(self) -> Any:
"""Return ``A σ √(2π) erf(N_σ/√2)``, the area of the window."""
return _gaussian_area(self.duration, self.sigmas, self.amplitude)
[docs]
def value(self, t: Any) -> Any:
"""Evaluate the centered Gaussian envelope.
Parameters
----------
t : scalar or array-like
Local times in ns.
"""
center = self.duration / 2.0
sigma = self.duration / (2.0 * self.sigmas)
return qnp.asarray(
self.amplitude * qnp.exp(-((t - center) ** 2) / (2 * sigma**2)),
dtype=complex,
)
[docs]
class GaussianDRAG(Envelope):
r"""Gaussian pulse with a derivative quadrature.
.. math::
E(t) = I(t) + i\,\beta\,\frac{dI}{dt}, \qquad
I(t) = A\exp\!\left[-\frac{(t-\tau/2)^2}{2\sigma^2}\right].
``beta`` is signed and measured in ns, so its sign owns the quadrature
convention without an additional polarity flag.
Parameters
----------
duration : float
Pulse duration in ns. Must be positive.
sigmas : float, default 3
Number of standard deviations from the pulse center to each window
edge, so ``sigma = duration/(2*sigmas)``. Must be positive.
amplitude : float, default 1.0
Peak in-phase envelope amplitude, in units set by the drive.
beta : float, default 0.0
Signed derivative-quadrature coefficient in ns.
References
----------
Motzoi et al., *Phys. Rev. Lett.* 103, 110501 (2009),
https://doi.org/10.1103/PhysRevLett.103.110501.
"""
duration: Scalar = parameter(positive=True, unit="ns")
sigmas: Scalar = parameter(default=3, positive=True)
amplitude: Scalar = parameter(default=1.0)
beta: Scalar = parameter(default=0.0, unit="ns")
[docs]
def sampling_times(self) -> Any:
return _gaussian_sampling_times(self.duration, self.sigmas)
[docs]
def area(self) -> Any:
"""Return the area of the in-phase Gaussian. The derivative quadrature integrates to zero."""
return _gaussian_area(self.duration, self.sigmas, self.amplitude)
[docs]
def value(self, t: Any) -> Any:
"""Evaluate the DRAG envelope.
Parameters
----------
t : scalar or array-like
Local times in ns.
"""
center = self.duration / 2.0
sigma = self.duration / (2.0 * self.sigmas)
in_phase = self.amplitude * qnp.exp(-((t - center) ** 2) / (2.0 * sigma**2))
derivative = -(t - center) * in_phase / sigma**2
return qnp.asarray(in_phase + 1j * self.beta * derivative, dtype=complex)
[docs]
class GaussianEdge(Envelope):
r"""Flat-top pulse with Gaussian ramp-up and ramp-down edges.
Each edge is a Gaussian of width :math:`\sigma = \tau_e / (2 N_\sigma)`
where :math:`\tau_e` = ``edge_duration``. The plateau between edges holds a
constant amplitude :math:`A`. Total ``duration`` includes both edges. This
pulse is common for two-qubit gates (Krantz et al. 2019, Sec. IV.C). The
flat top sets the gate area, and the Gaussian edges suppress spectral
leakage.
Parameters
----------
duration : float
Total pulse length, including both edges, in ns.
edge_duration : float
Ramp time :math:`\tau_e` per edge, in ns. Must satisfy
``2 * edge_duration <= duration``.
sigmas : float
Number of standard deviations spanned by each edge.
amplitude : float
Plateau amplitude :math:`A`.
See Also
--------
:class:`SquareWithGaussianEdges` : Same shape parameterized by
``edge_frac`` (fraction) instead of absolute ``edge_duration``.
References
----------
* Krantz et al., APR **6**, 021318 (2019), Sec. IV.C.
"""
duration: Scalar = parameter(positive=True, unit="ns")
edge_duration: Scalar = parameter(positive=True, unit="ns")
sigmas: Scalar = parameter(default=3, positive=True)
amplitude: Scalar = parameter(default=1.0)
[docs]
def validate(self) -> None:
"""Reject edges that overrun the pulse (``2 * edge_duration > duration``)."""
edge = maybe_concrete_scalar(self.edge_duration)
dur = maybe_concrete_scalar(self.duration)
if edge is not None and dur is not None and 2 * edge > dur:
raise ValueError(f"2 * edge_duration ({2 * self.edge_duration}) exceeds duration ({self.duration})")
[docs]
def sampling_times(self) -> Any:
return _gaussian_edge_sampling_times(self.duration, self.edge_duration, self.sigmas)
[docs]
def area(self) -> Any:
"""Return ``A(τ − 2τ_e) + A σ √(2π) erf(√2 N_σ)``: the plateau and two half-Gaussian edges."""
return _gaussian_flat_top_area(self.duration, self.edge_duration, self.sigmas, self.amplitude)
[docs]
def value(self, t: Any) -> Any:
"""Evaluate the flat-top Gaussian-edge envelope.
Parameters
----------
t : scalar or array-like
Local times in ns.
"""
return _gaussian_flat_top(t, self.duration, self.edge_duration, self.sigmas, self.amplitude)
[docs]
class SquareWithGaussianEdges(Envelope):
r"""Flat-top pulse with Gaussian ramp-up and ramp-down edges.
Each ramp has duration :math:`\tau_e = f_e \cdot \tau` with :math:`f_e` =
``edge_frac``. The plateau between ramps holds amplitude :math:`A`. Total
``duration`` includes both edges. The Gaussian width is
:math:`\sigma = \tau_e / (2 N_\sigma)` with :math:`N_\sigma` = ``sigmas``.
Krantz et al. 2019 (Sec. IV.C) give this as the standard shape for
two-qubit gates. The flat top sets the gate area, and the Gaussian edges
suppress spectral leakage. The ramp is a fraction of the total duration, so
the shape is invariant under changes of ``duration``.
Parameters
----------
duration : float
Total pulse length in ns, including both ramps.
amplitude : float
Plateau amplitude :math:`A`.
edge_frac : float
Ramp length as a fraction of the total duration. Must satisfy
``0 < edge_frac`` and ``2 * edge_frac <= 1``.
sigmas : float
Number of standard deviations spanned by each ramp.
"""
duration: Scalar = parameter(positive=True, unit="ns")
amplitude: Scalar = parameter(default=1.0)
edge_frac: Scalar = parameter(default=0.25, positive=True)
sigmas: Scalar = parameter(default=3, positive=True)
[docs]
def validate(self) -> None:
"""Reject ramps that overrun the pulse (``2 * edge_frac > 1``)."""
frac = maybe_concrete_scalar(self.edge_frac)
if frac is not None and 2 * frac > 1.0:
raise ValueError(f"2 * edge_frac ({2 * self.edge_frac}) exceeds 1.0")
@property
def edge_duration(self) -> float:
"""Ramp duration in ns (``edge_frac * duration``)."""
return self.edge_frac * self.duration
[docs]
def sampling_times(self) -> Any:
return _gaussian_edge_sampling_times(self.duration, self.edge_duration, self.sigmas)
[docs]
def area(self) -> Any:
"""Return ``A(τ − 2τ_e) + A σ √(2π) erf(√2 N_σ)``: the plateau and two half-Gaussian edges."""
return _gaussian_flat_top_area(self.duration, self.edge_duration, self.sigmas, self.amplitude)
[docs]
def value(self, t: Any) -> Any:
"""Evaluate the fraction-parameterized Gaussian-edge envelope.
Parameters
----------
t : scalar or array-like
Local times in ns.
"""
return _gaussian_flat_top(t, self.duration, self.edge_duration, self.sigmas, self.amplitude)
[docs]
class LinearRamp(Envelope):
r"""Linearly rising ramp that holds at peak amplitude.
The envelope rises linearly from 0 to ``amplitude`` over the first
``ramp_duration`` nanoseconds, then holds at ``amplitude`` for the rest of
the pulse.
.. math::
E(t) = A \cdot \min\!\left(\frac{t}{\tau_r},\, 1\right),
\qquad 0 \le t \le \tau,
where :math:`\tau_r` is ``ramp_duration`` and :math:`\tau` is
``duration``.
Parameters
----------
duration : float
Total pulse duration in ns. Must be > 0.
ramp_duration : float
Duration of the linear rise in ns. Must satisfy
``0 < ramp_duration <= duration``.
amplitude : float
Peak amplitude :math:`A` (default 1.0).
Notes
-----
For an adiabatic ramp into a Kerr-cat qubit, choose ``ramp_duration``
long compared to ``1 / (2 * K)`` (the inverse gap at the bifurcation
point). See Grimm et al., Nature 584, 205 (2020).
The waveform is JAX-traceable: ``ramp_duration`` and ``amplitude`` can be
JAX tracers, so the ramp parameters are differentiable.
Examples
--------
>>> from quchip.control.envelopes import LinearRamp
>>> ramp = LinearRamp(duration=60.0, ramp_duration=50.0, amplitude=4.0)
>>> import numpy as np
>>> t = np.array([0.0, 25.0, 50.0, 55.0])
>>> np.real(ramp.value(t)).tolist()
[0.0, 2.0, 4.0, 4.0]
"""
duration: Scalar = parameter(positive=True, unit="ns")
ramp_duration: Scalar = parameter(positive=True, unit="ns")
amplitude: Scalar = parameter(default=1.0)
[docs]
def validate(self) -> None:
"""Reject ramps longer than the pulse (``ramp_duration > duration``)."""
ramp = maybe_concrete_scalar(self.ramp_duration)
dur = maybe_concrete_scalar(self.duration)
if ramp is not None and dur is not None and ramp > dur:
raise ValueError(f"ramp_duration ({self.ramp_duration}) must be <= duration ({self.duration})")
[docs]
def sampling_times(self) -> Any:
return qnp.concatenate((qnp.linspace(0.0, self.ramp_duration, 33), qnp.asarray([self.duration])))
[docs]
def area(self) -> Any:
"""Return ``A(τ − τ_r/2)``: the ramp and the hold."""
return qnp.asarray(self.amplitude * (self.duration - self.ramp_duration / 2.0), dtype=complex)
[docs]
def value(self, t: Any) -> Any:
"""Evaluate the linear-ramp envelope at time points *t* (ns).
Parameters
----------
t : array-like
1-D array of time points in nanoseconds.
Returns
-------
array
Complex-valued waveform. It rises linearly over ``ramp_duration``
and then holds constant at ``amplitude``.
"""
return qnp.asarray(
self.amplitude * qnp.minimum(t / self.ramp_duration, 1.0),
dtype=complex,
)
[docs]
class Square(Envelope):
r"""Constant-amplitude pulse.
.. math::
E(t) = A, \qquad 0 \le t \le \tau.
Parameters
----------
duration : float
Pulse length :math:`\tau` in ns.
amplitude : float
Real amplitude :math:`A` applied on top of :meth:`value`.
"""
duration: Scalar = parameter(positive=True, unit="ns")
amplitude: Scalar = parameter(default=1.0)
[docs]
def sampling_times(self) -> Any:
return qnp.asarray([0.0, self.duration])
[docs]
def area(self) -> Any:
"""Return ``A τ``."""
return qnp.asarray(self.amplitude * self.duration, dtype=complex)
[docs]
def value(self, t: Any) -> Any:
"""Evaluate the constant envelope.
Parameters
----------
t : scalar or array-like
Local times in ns.
"""
return qnp.ones_like(t, dtype=complex) * self.amplitude