Waveforms¶
A waveform describes a pulse envelope. It is pure data; nothing about it involves hardware. The same Gaussian(0.5, 40, 8) shape can be a flux pulse on one platform and a charge-line pulse on another, depending on the bus it ends up on.
Waveforms are the one vocabulary in the package with no top-level re-export, so they are always reached through the submodule:
The one thing a bus cares about is the channel count: a single-channel bus takes a Waveform, an IQ bus takes an IQWaveform. play() and measure() check this, but only when the bus is a schema-bound BusRef, since a raw string bus carries no channel metadata to check against. Playing a Gaussian on the IQ drive bus of a transmon schema raises:
ValidationError: Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Gaussian). Use an IQWaveform (e.g. IQPair, IQDrag) instead.
The mirror case, an IQDrag on the single-channel flux bus, names Square and FlatTop as the shapes that belong there. Both checks run again inside with_waveforms(), when a string alias is replaced by a concrete shape, so a mismatch introduced by calibration data is caught at substitution rather than in the platform compiler. measure() is narrower still: both its waveform and its weights must be IQWaveforms or aliases.
Two base classes¶
Waveform is the base for single-channel (real) shapes. Two methods are abstract, so every subclass supplies them:
| Method | What it returns |
|---|---|
envelope(resolution=1) |
a 1-D numpy array of int(duration / resolution) amplitudes |
get_duration() |
the duration in nanoseconds, as an int |
IQWaveform is the base for IQ pairs, and asks for three:
| Method | What it returns |
|---|---|
get_I() |
the in-phase Waveform |
get_Q() |
the quadrature Waveform |
get_duration() |
the duration in nanoseconds, as an int |
Both bases derive the same measures from those, so any shape, built-in or your own, answers them without extra code:
| Method | What it returns |
|---|---|
area(resolution=1) |
numpy.trapezoid(envelope, dx=resolution), in nanosecond-amplitude. Trapezoidal, so a rectangle integrates to amplitude * (duration - resolution): Square(0.5, 100).area() is 49.5, not 50.0. |
peak_amplitude(resolution=1) |
max(abs(envelope)), and for an IQ shape the peak magnitude max(abs(I + 1j*Q)). |
rms_amplitude(resolution=1) |
the root mean square of the samples, or of the complex magnitudes for an IQ shape. |
spectrum(resolution=1) |
a (frequencies_hz, complex_spectrum) pair. Real shapes use numpy.fft.rfft, so 64 samples at resolution=1 give 33 one-sided bins up to 500 MHz; IQ shapes use a fftshifted two-sided numpy.fft.fft. |
plot(resolution=1, ...) |
whatever the renderer returns, which for matplotlib is an Axes, or an (I, Q) pair of them for an IQ shape. |
envelope() resolves every symbolic parameter before it samples anything, by calling Expression.evaluate_or_raise() on it. A variable with no value therefore fails at the point of use rather than somewhere inside numpy:
UnassignedVariableError: Cannot evaluate expression Variable('amp'): unassigned variable(s) {Variable('amp')}
Every measure above is computed from envelope(), so they all raise the same error under the same conditions.
plot() takes the same three arguments result.plot takes, and means the same things by them:
Waveform.plot(resolution=1, *, style=None, renderer=None, target=None)
IQWaveform.plot(resolution=1, *, style=None, renderer=None, target=None)
The envelope is described as a qp.plotting.Figure and handed to a renderer, which for the default matplotlib one returns the Axes it drew on. An IQ shape draws two panels stacked on a shared x axis, labeled I and Q and in the theme's first two colours, and returns the two handles as an (I, Q) pair. matplotlib is imported when it is first drawn with, from the qprogram[viz] extra, which keeps the rest of the package importable without it; without matplotlib installed the call raises ModuleNotFoundError. Plotting results is the walkthrough for all three arguments; what follows is what a waveform does differently.
The style defaults to a plain Style(), the same as a result's, so a pulse and the sweep it produced sit on one palette instead of looking like two libraries:
import qprogram as qp
pi_pulse = qp.waveforms.Gaussian(amplitude=0.5, duration=40, sigma=8)
pi_pulse.plot(style=qp.plotting.Style(theme=qp.plotting.DARK))
The one thing a Style does not carry by default is a figure size, which is what lets the same one suit a measurement and a pulse. A style that names none is drawn at qp.plotting.ENVELOPE_SIZE, six inches by two, or qp.plotting.IQ_ENVELOPE_SIZE for the stacked pair, which is an inch taller; Style(size=(4, 1.5)) overrides that, and a target you pass in keeps whatever size its figure already has.
target is one surface for a single-channel shape and an (I, Q) pair for an IQ one, which is how a pulse composes into a layout of your own:
import matplotlib.pyplot as plt
drag = qp.waveforms.IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1)
fig, (top, bottom) = plt.subplots(2, 1, sharex=True)
drag.plot(target=(top, bottom))
That pair is also the one place a waveform asks for more than a result does. Two panels sharing a scale is a matplotlib layout rather than anything the figure describes, and it is the only pair the package knows how to build, so a registered renderer other than the built-in one raises ValidationError when it is asked for with no target: the panels it would otherwise be handed are matplotlib's.
Both bases also define _repr_html_, so a bare waveform renders in a Jupyter cell without an explicit plot(). It draws the envelope once per surface and returns a <picture> holding both, with the dark one behind a prefers-color-scheme source, so a cell in a dark notebook is not a white rectangle. It draws with the default renderer, since what a cell wants is an image. That reads the browser's setting, which is the editor's own theme in VS Code and the operating system's under JupyterLab; where the two disagree, plot(style=...) is how to say which surface you are on.
Only the waveform itself takes that path. wf.plot() makes the axes the cell's value instead, which a notebook shows as <Axes: ...> beside the figure, so bind it or end the call with a semicolon.
Single-channel built-ins¶
Every duration and width below is in nanoseconds, every frequency is in hertz, and every phase offset is in radians.
| Constructor | Samples it renders |
|---|---|
Square(amplitude, duration) |
every sample at amplitude |
Gaussian(amplitude, duration, sigma) |
a Gaussian peaked at the center of the window |
GaussianDragCorrection(amplitude, duration, sigma, beta) |
the Gaussian's derivative, scaled by beta |
Sech(amplitude, duration, tau) |
amplitude / cosh((t - center) / tau) |
Tukey(amplitude, duration, alpha=0.5) |
a rectangle with cosine-tapered edges |
FlatTop(amplitude, duration, smooth_duration, buffer=0) |
a rectangle with erf-shaped edges, optionally zero-padded |
Ramp(from_amplitude, to_amplitude, duration) |
a linear interpolation between the two amplitudes |
SuddenNetZero(amplitude, duration, b, t_phi) |
a positive segment, a zero hold, then a negative segment |
Sine(amplitude, duration, frequency, phase=0.0) |
amplitude * sin(2π * frequency * t + phase) |
Cosine(amplitude, duration, frequency, phase=0.0) |
amplitude * cos(2π * frequency * t + phase) |
Arbitrary(samples) |
the samples you gave it |
Chained(waveforms) |
its children's envelopes, concatenated in order |
import numpy as np
import qprogram as qp
readout = qp.waveforms.Square(amplitude=0.5, duration=2000)
pi_pulse = qp.waveforms.Gaussian(amplitude=0.5, duration=40, sigma=8)
flux_step = qp.waveforms.FlatTop(amplitude=0.5, duration=200, smooth_duration=20)
cz_pulse = qp.waveforms.SuddenNetZero(amplitude=0.5, duration=100, b=0.4, t_phi=20)
measured = qp.waveforms.Arbitrary(samples=np.linspace(0.0, 1.0, 64))
Square renders with numpy.full, so its dtype follows its amplitude and an integer amplitude yields an integer array. Tukey does the same in its two untapered cases, and Arbitrary keeps whatever dtype its input had. Every other shape computes in floating point.
Gaussian is not truncation-corrected. The peak sits at the center of the sample window, so an even sample count straddles it and the largest sample falls slightly below amplitude: Gaussian(0.5, 40, 8).envelope().max() is 0.4990, not 0.5. The tails are clipped wherever the window ends rather than forced to zero, so the same shape starts and ends at 0.0513 * amplitude. Widening the window to duration=60 at the same sigma adds tail without changing the pulse. GaussianDragCorrection inherits all three parameters and differs only in the envelope: it is antisymmetric about the center, where it crosses zero. Its derivative is taken with respect to sample index rather than time, so its amplitude scales with resolution while the Gaussian's does not.
Tukey splits alpha between the two edges: the flat top is (1 - alpha) * duration wide and each cosine ramp is (alpha / 2) * duration. alpha=0 is a rectangle and alpha=1 is a Hann window that reaches zero at both endpoints; the default is 0.5. It matches the alpha of scipy.signal.windows.tukey, and needs no erf evaluation, which is the reason to prefer it to FlatTop when either edge shape will do.
FlatTop builds each edge from an error function of width smooth_duration / 3, and multiplies the rising and falling edges together rather than splicing them, which keeps the envelope smooth when the two overlap. The rise crosses half amplitude smooth_duration ns into the pulse and is flat to within a part in 10⁵ by twice that, so a duration that is not comfortably longer than 2 * smooth_duration never reaches full amplitude. buffer adds zero padding on each side, on top of duration rather than inside it: FlatTop(0.5, 200, 20, buffer=10).get_duration() is 220.
Ramp samples with numpy.linspace, so both endpoints are hit exactly once the window holds at least two samples, and the step between samples is (to_amplitude - from_amplitude) / (n - 1) rather than anything derived from resolution alone. A one-sample window yields from_amplitude alone, a window holding less than one sample yields an empty array, and a negative duration raises ValueError: Number of samples, -5, must be non-negative. from linspace.
SuddenNetZero plays a positive square segment, a zero hold of width t_phi, then a negative segment scaled by b. The two segments are meant to cancel the net integrated flux, and the cancellation is exact only when b is 1 and the samples left over after the hold divide evenly between the segments. The positive segment takes (duration - t_phi) // 2 samples and the negative one takes the rest, so an odd remainder gives the negative segment the extra sample: at duration=101, t_phi=20, b=1 the envelope sums to -amplitude instead of zero. In practice b is detuned from 1 to null whatever residual the flux line adds, so the b=0.4 above integrates to a non-zero area.
Sine and Cosine express their sample times in seconds, which is what pairs with a frequency in Hz: 200 ns at 50 MHz is ten cycles. Neither tapers to zero at the endpoints, so pair one with a window shape when the discontinuity matters. Sech is the analogue of Gaussian for adiabatic passage: paired with a quadratic phase ramp it gives analytically solvable population transfer. Its tau plays the role sigma plays for a Gaussian.
Arbitrary and Chained are the escape hatches. Arbitrary takes a sequence or an ndarray through numpy.asarray, so the stored dtype follows the input, and an ndarray is adopted rather than copied: do not mutate an array you have handed to it, because waveforms are compared and hashed by value. envelope() returns a copy for the same reason, and ignores resolution entirely, since the samples are already the envelope at one per nanosecond. Chained passes whatever resolution it is asked for down to each child and sums their durations. a + b builds a Chained too, flattening as it goes, so a + b + c is one three-element chain rather than nested pairs; a non-waveform operand yields NotImplemented, which surfaces as TypeError. An empty chain reports get_duration() == 0 but its envelope() raises ValueError: need at least one array to concatenate.
What the shape parameters mean¶
Every width is the real width of the shape, not a ratio against duration.
| Parameter | Meaning |
|---|---|
Gaussian.sigma, GaussianDragCorrection.sigma, IQDrag.sigma |
The Gaussian standard deviation σ, in nanoseconds. |
Sech.tau |
The sech width τ, in nanoseconds: the sigma analogue for amplitude / cosh((t - center) / tau). |
IQDrag.beta, GaussianDragCorrection.beta |
The DRAG coefficient β from the Motzoi parameterization: the weight on the derivative term. Typically below 0.5 and tuned per qubit. |
Tukey.alpha |
The taper fraction, in [0, 1]: the share of duration taken by the rise and fall combined. |
FlatTop.smooth_duration |
The length of each erf-shaped edge, in nanoseconds. The erf width itself is a third of it. |
SuddenNetZero.b |
The ratio of the negative segment's amplitude to the positive one's, normally near 1. |
SuddenNetZero.t_phi |
The width of the zero hold between the two segments, in nanoseconds. |
duration is independent of the width: it is the window the shape is rendered into, so it controls truncation and nothing else. That is what makes sigma the knob a calibration sweep reaches for, since a Gaussian's rotation angle goes with its area, roughly amplitude * sigma * sqrt(2π).
IQ built-ins¶
| Constructor | What it produces |
|---|---|
IQPair(I, Q) |
the two waveforms you gave it, unchanged |
IQDrag(amplitude, duration, sigma, beta) |
a Gaussian on I and a GaussianDragCorrection on Q |
IQZero(envelope) |
the envelope on I and zeros on Q |
Modulated(envelope, frequency, phase=0.0) |
the envelope multiplied by a cosine on I and a sine on Q |
IQRotation(base, phase) |
another IQ shape's channels mixed by a 2x2 rotation |
import qprogram as qp
drive = qp.waveforms.IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1)
readout = qp.waveforms.IQPair(
I=qp.waveforms.Square(1.0, 2000),
Q=qp.waveforms.Square(0.0, 2000),
)
weights = qp.waveforms.IQZero(envelope=qp.waveforms.Square(1.0, 2000))
sideband = qp.waveforms.Modulated(envelope=qp.waveforms.Gaussian(0.5, 40, 8), frequency=50e6)
virtual_z = qp.waveforms.IQRotation(base=drive, phase=1.5708)
IQPair is the generic pair, and the only IQ shape that hands back exactly the objects it was given: get_I() and get_Q() return them by identity. It rejects a non-Waveform argument with TypeError: I and Q must be Waveform instances, and unequal durations with
The duration check is best-effort. When both durations are symbolic and still unassigned, get_duration() raises UnassignedVariableError, the constructor swallows it and accepts the pair, and the platform compiler verifies the match once values are bound.
IQDrag is the standard DRAG shape, and stores its four parameters rather than two child waveforms: get_I() and get_Q() build a Gaussian and a GaussianDragCorrection on demand, so the channels cannot drift apart from each other or from get_duration().
The remaining three adapt an existing shape and validate its type in __init__, naming what they got: IQZero("pi_pulse") raises TypeError: IQZero envelope must be a Waveform, got str. IQZero puts a single-channel envelope on I and silence on Q, which is how a calibrated single-channel pulse reaches an IQ-typed bus without rewriting the program around it. Modulated lifts one onto an IQ bus at an intermediate frequency, producing envelope * cos(2π * frequency * t + phase) on I and the sine on Q. IQRotation applies I' = I * cos(phase) - Q * sin(phase) and Q' = I * sin(phase) + Q * cos(phase), which is the shape a virtual-Z gate or a software phase offset needs.
Modulated and IQRotation materialize both channels as Arbitrary waveforms sampled at one per nanosecond, and IQZero does the same for its silent Q channel while handing back its I channel unchanged. Materializing collapses the parametric structure of what these shapes wrap to concrete samples, and since Arbitrary.envelope() ignores resolution, they only behave correctly at resolution=1. Modulated and IQRotation quietly return 1-ns samples for any other value, and IQZero mixes a resolution-aware I channel with a fixed-length Q channel, so IQZero(Square(0.5, 100)).peak_amplitude(resolution=2) raises ValueError: operands could not be broadcast together with shapes (50,) (100,). Where a coarser rendering is needed, prefer carrying the phase or frequency through the underlying envelope's own parameters.
Variables and expressions in parameters¶
A parameter annotated float | Expression or int | Expression accepts a Variable or an Expression in place of a number, and that covers every numeric parameter of every built-in with one exception: FlatTop.buffer is annotated plain int and takes a number only. This is how a pulse parameter gets swept inside a loop:
import qprogram as qp
program = qp.QProgram()
amp = program.variable("amp")
with program.sweep(amp, qp.Range(0.0, 1.0, 0.01)):
program.play("drive_q0", qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8))
The waveform stores the symbolic parameter and nothing else happens at construction. The platform's compiler then decides whether to update an amplitude register on the fly or re-upload the waveform on each iteration; you write the same thing either way.
The structural parameters are the ones that must be concrete when the object is built: Arbitrary.samples, the list in Chained.waveforms, IQPair.I and IQPair.Q, IQZero.envelope, Modulated.envelope, and IQRotation.base. Each of the last four is type-checked in __init__, so a mistake there is a TypeError at the call site rather than a failure during rendering.
A waveform can be evaluated locally once its variables are bound, which is what plotting a swept shape needs:
import qprogram as qp
amp = qp.Variable("amp")
amp.set_value(0.7)
samples = qp.waveforms.Gaussian(amp, 40, 8).envelope()
amp.reset()
Structural equality¶
Waveforms compare the way every other AST node does, through the shared rule Core ideas sets out: an exact type match, then attribute-by-attribute comparison of vars(). Two consequences are particular to waveforms.
import numpy as np
import qprogram as qp
qp.waveforms.Gaussian(0.5, 40, 8) == qp.waveforms.Gaussian(0.5, 40, 8)
qp.waveforms.Gaussian(qp.Variable("amp"), 40, 8) == qp.waveforms.Gaussian(qp.Variable("amp"), 40, 8)
qp.waveforms.Arbitrary(np.array([1.0, 2.0])) == qp.waveforms.Arbitrary([1.0, 2.0])
All three are True. The second holds because a Variable compares by its string id and the two objects share the id "amp", so a waveform whose amplitude is swept still compares equal across a rebuild. The third holds because Arbitrary converts its argument with numpy.asarray before storing it, which puts a list and the equivalent array in the same place.
Waveforms are usable as dictionary keys, on the condition that they are treated as values and never mutated after being hashed. Arbitrary is the one shape where equality and hashing disagree: hashing includes the array's dtype and equality does not, so Arbitrary(np.array([1, 2, 3])) and Arbitrary(np.array([1.0, 2.0, 3.0])) compare equal yet land in different buckets of a dict or set.
Structural equality is what survives a .qp round trip. QProgram itself does not define __eq__, so compare bodies:
import qprogram as qp
program = qp.QProgram()
program.play("drive_q0", qp.waveforms.IQDrag(0.5, 40, 8, 0.1))
reloaded = qp.loads(qp.dumps(program))
assert reloaded.body == program.body
On the wire¶
The writer emits a waveform as a constructor call: the class name verbatim, then every attribute in vars(wf) that does not start with an underscore, as a keyword argument. Defaults are written out rather than omitted, and sample arrays are written in full, because the parser has no way to recover dropped samples.
#!QProgram 0.2
body:
play "drive_q0" IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1)
play "flux_q0" Arbitrary(samples=[0.0, 0.5, 1.0])
play "flux_q1" FlatTop(amplitude=0.5, duration=200, smooth_duration=20, buffer=10)
play "flux_q2" Chained(waveforms=[Square(amplitude=1.0, duration=50), Gaussian(amplitude=0.5, duration=40, sigma=8)])
Each shape also carries a capability token, which is what lets a Profile advertise the subset of shapes its compiler can lower. The token is waveform. followed by the snake_case class name, with SuddenNetZero the one abbreviation: it is waveform.snz, not waveform.sudden_net_zero. On top of the per-class token, a play contributes the channel kind (waveform.single or waveform.iq), and a string alias contributes waveform.alias and no per-class token at all.
Custom waveforms¶
Subclass Waveform or IQWaveform and implement the abstract methods. To make the new shape serializable, register it with the parser:
import numpy as np
import qprogram as qp
@qp.register_waveform
class HalfSine(qp.waveforms.Waveform):
def __init__(self, amplitude: float, duration: int) -> None:
self.amplitude = amplitude
self.duration = duration
def envelope(self, resolution: int = 1) -> np.ndarray:
n = self.duration // resolution
t = np.arange(n) / n * np.pi
return self.amplitude * np.sin(t)
def get_duration(self) -> int:
return self.duration
Once registered, HalfSine(amplitude=0.5, duration=100) appears in .qp files using the constructor syntax, and the parser reconstructs the object on load. Because the writer works from vars(wf), anything stored on self under the same name as a constructor parameter round-trips without further work, and anything else stored on self is emitted too and will fail to reload; prefix computed attributes with an underscore. Registering a different class under a name already taken raises ValueError rather than silently changing how existing files parse. Pair the registration with qp.register_waveform_token(HalfSine, "waveform.half_sine") to give profiles something to advertise; without a token the shape contributes only its channel kind, and any profile accepting that kind accepts it. See Adding waveforms for the developer-side details.
Picking the right shape¶
Two shapes often both fit a job. The third column is the property that decides between them.
| When you need | Reach for | Because |
|---|---|---|
| A flat pulse with hard edges | Square |
every sample sits at amplitude, and nothing is tapered |
| A gate whose area you calibrate | Gaussian |
area is close to amplitude * sigma * sqrt(2π), so sigma and amplitude trade off |
| Leakage suppression on a weakly anharmonic transmon | IQDrag |
Q carries the Gaussian's derivative, weighted by beta |
| A specific I and Q pairing | IQPair(I=..., Q=...) |
the two channels stay exactly what you passed |
| A single-channel envelope on an IQ bus | IQZero |
I is the envelope untouched, Q is silence |
| The same, at an intermediate frequency | Modulated |
one envelope becomes a cos/sin pair at frequency |
| A phase offset on an already calibrated pulse | IQRotation |
it mixes the existing channels instead of resampling the shape |
| A flat pulse whose edges are set in nanoseconds | FlatTop |
smooth_duration is an absolute edge length, and buffer adds dead time around it |
| A flat pulse whose edges scale with its length | Tukey |
alpha is a fraction of duration, so the taper tracks it as the pulse is swept |
| Zero net integrated flux for a two-qubit gate | SuddenNetZero |
the negative segment cancels the positive one, and b absorbs the residual |
| An adiabatic-passage envelope | Sech |
sech plus a quadratic phase ramp has a closed-form transfer solution |
| A continuous drive tone | Sine / Cosine |
the oscillation is the envelope, and neither shape tapers at the endpoints |
| A monotonic sweep of a bias point | Ramp |
both endpoints are hit exactly |
| A shape you computed elsewhere | Arbitrary |
samples are stored and emitted verbatim, at one per nanosecond |
| Two shapes played back to back | Chained, or a + b |
durations add and each child keeps its own parameters |
| Anything else | Subclass Waveform / IQWaveform |
envelope() and get_duration() supply every derived measure |
For programs that go through calibration, you usually pass string aliases ("pi_pulse", "readout") and let the platform substitute the concrete waveform from its calibration store.
Related pages¶
Operations has the play and measure signatures. Buses covers the schemas whose channel field drives the single-versus-IQ check. Saving and loading covers how aliases survive the .qp round trip.