Skip to content
1 change: 1 addition & 0 deletions changelog/36.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`Waveform.plot` and `IQWaveform.plot` draw through the palette and the renderer registry `QProgramResult.plot` draws through, so a pulse and the sweep it produced no longer look like they came from two libraries. All three now take the same `style`, `renderer` and `target`: the envelope is described as a `qp.plotting.Figure` and handed to a renderer resolved by name, the style defaults to the same `qp.plotting.Style()`, and `target` replaces `ax` and `axes`. `Style.size` defaults to `None`, meaning the size that suits what is being drawn: `qp.plotting.DEFAULT_SIZE` for a measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, which are the figure sizes the two plotting methods always had, and `Style.sized` is how a caller fills one in. A `Figure` also carries `series`, the palette slot its first mark takes, which is what draws the two panels of an IQ envelope in the theme's first two colours. Those panels are the one thing a renderer does not decide, since two axes sharing a scale is a matplotlib layout, so any other renderer has to be given the `(I, Q)` surfaces to draw on. `_repr_html_` returns a `<picture>` holding the envelope drawn once for a light surface and once for a dark one, chosen by `prefers-color-scheme`, so a waveform in a dark notebook is no longer a white rectangle; it also takes the figure off the axes `plot` returned rather than off pyplot's current figure, which was an ordering contract that held only by convention.
8 changes: 5 additions & 3 deletions docs/developer/adding-waveforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@ subclass inherits all of it and writes none of it. `peak_amplitude()` is
`max(|envelope|)`, `rms_amplitude()` the root mean square of the samples,
`area()` the trapezoidal integral in nanosecond-amplitude units
(`np.trapezoid(env, dx=resolution)`), and `spectrum()` a one-sided `np.fft.rfft`
paired with frequencies in Hz. `plot()` and the Jupyter `_repr_html_` need
matplotlib, which ships in the `viz` extra and is imported inside the call so
the package stays importable without it.
paired with frequencies in Hz. `plot()` describes the envelope as a
`qp.plotting.Figure` and hands it to a renderer, and the Jupyter `_repr_html_`
draws it once per surface; both reach matplotlib by default, which ships in the
`viz` extra and is imported the first time something draws with it, so the
package stays importable without it.

Nothing in core calls `envelope()`. Validation and serialization work on the
constructor arguments alone, so samples are rendered only when someone asks for
Expand Down
31 changes: 25 additions & 6 deletions docs/guide/plotting.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ ax.axvline(0.5, linestyle="--")
ax.set_ylabel("Readout response")
```

In a notebook that axes is also the cell's value, so a `result.plot(m0)` on a
line of its own shows `<Axes: ...>` beside the figure. Bind it the way the
snippet above does, or end the call with a semicolon.

Behind that call are two halves that never meet. `qp.plotting.build_figure`
reads the array and returns a `Figure`: marks holding numpy arrays, two axis
labels, and nothing about colour or canvas. A renderer takes that figure and a
Expand Down Expand Up @@ -201,16 +205,10 @@ one already there has to come with the arithmetic that earns it:
# On a coordinate that declares units="Hz":
Quantity(transform=lambda v: v / 1e9) # raises: the axis would read (Hz) over gigahertz
Quantity(units="GHz") # raises: relabels the unit, changes no number
Quantity(units="") # raises: calls hertz dimensionless, changes no number
Quantity(units="GHz", transform=lambda v: v / 1e9) # both halves, and the figure is drawn
Quantity(units="Hz", transform=lambda v: v - v[0]) # a shift keeps its unit, and says so
Quantity(units="", transform=lambda v: v / v[-1]) # a bare ratio, and the arithmetic that made one
```

Emptying a unit is a change like any other rather than a way around the rule:
`units=""` says the numbers carry no unit at all, which over values that
arrived in hertz needs the arithmetic that made them a ratio.

Both fire only where there is a claim to falsify, so a coordinate that declared
no unit, or a demodulated magnitude that has none to declare, takes either half
alone. That is also how you correct a unit the program never recorded:
Expand Down Expand Up @@ -262,6 +260,20 @@ result.plot(m0, style=qp.plotting.Style(theme=house))
`markers` is worth turning on for a coarse sweep, where the points are the
measurement and the line between them is interpolation.

`size` is the one field with no default of its own. `None` means the size that
suits what is being drawn, which is `qp.plotting.DEFAULT_SIZE` for a
measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, and it is
read only when the figure is made here: axes you pass as `target=` keep the size
they came with.

`Waveform.plot` takes the same `style`, `renderer` and `target`, which is most
of why the palette and the registry are objects of their own: a pi pulse and the
Rabi sweep it produced are one experiment, and a pair that speaks two visual
languages is a papercut. Its style defaults to `Style()` the way this one does,
and the only differences are the size a figure of a pulse comes out at and the
`(I, Q)` pair of panels an IQ shape wants for a `target`.
[Waveforms](waveforms.md) has the rest.

## Another renderer

A renderer is any callable taking a figure, a `Style`, and a surface to draw
Expand All @@ -283,6 +295,7 @@ def to_text(figure, style, target=None):

register_renderer("text", to_text)
result.plot(m0, renderer="text")
qp.waveforms.Square(0.5, 100).plot(renderer="text")
```

`build_figure` is the half worth reading first when writing one. It returns a
Expand All @@ -291,6 +304,12 @@ dataclass of numpy arrays, and a renderer dispatches on their types. Nothing in
that half imports a plotting library, so a renderer for any backend reads the
same description.

A figure hands over everything a renderer needs to draw it and nothing about
how: the marks, the two labels, a title, either `Twin` scale, and `series`, the
palette slot its first mark takes. That last one is only ever set when a figure
is one panel of several that should not repeat a colour, which is what the `Q`
panel of an IQ envelope is; a renderer drawing in one colour ignores it.

## What it does not draw

`plot` returns composable axes rather than trying to be the whole figure. A
Expand Down
79 changes: 69 additions & 10 deletions docs/guide/waveforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ own, answers them without extra code:
| `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 `fftshift`ed two-sided `numpy.fft.fft`. |
| `plot(resolution=1, ...)` | a matplotlib `Axes`, or an `(I_axes, Q_axes)` pair for an IQ shape. |
| `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
Expand All @@ -71,15 +71,74 @@ UnassignedVariableError: Cannot evaluate expression Variable('amp'): unassigned
Every measure above is computed from `envelope()`, so they all raise the same
error under the same conditions.

`plot()` takes the axes to draw on: `Waveform.plot(resolution=1, ax=None)`
returns one `Axes`, and
`IQWaveform.plot(resolution=1, axes=None)` returns two stacked axes sharing an
x axis, labeled `I` and `Q`. Passing `None` creates a fresh figure. matplotlib
is imported inside the call, from the `qprogram[viz]` extra, which keeps the
rest of the package importable without it; without matplotlib installed the
call raises `ModuleNotFoundError`. Both bases also define `_repr_html_`, which
returns the same plot as an inline SVG, so a bare waveform renders in a Jupyter
cell without an explicit `plot()`.
`plot()` takes the same three arguments `result.plot` takes, and means the same
things by them:

<!-- check: skip -->

```python
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](plotting.md) 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:

```python
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:

```python
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

Expand Down
10 changes: 9 additions & 1 deletion docs/reference/api-qprogram.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@ compared, hashed, and serialized in.
`QProgramResult.plot` above is the front door. It runs `build_figure` to
describe the figure and a renderer to draw it, and the two halves are separate
so that a backend other than matplotlib is possible: everything down to
`Renderer` reads numpy and xarray only. See
`Renderer` reads numpy and xarray only. `Waveform.plot` and `IQWaveform.plot`
take the same `style`, `renderer` and `target` and describe an envelope as the
same `Figure`, which is what keeps a pulse and a result on one palette. See
[Plotting results](../guide/plotting.md) for the walkthrough. These names live
in `qprogram.plotting`, which the top level does not re-export.

Expand Down Expand Up @@ -496,6 +498,12 @@ in `qprogram.plotting`, which the top level does not re-export.

::: qprogram.plotting.DARK

::: qprogram.plotting.DEFAULT_SIZE

::: qprogram.plotting.ENVELOPE_SIZE

::: qprogram.plotting.IQ_ENVELOPE_SIZE

::: qprogram.plotting.Renderer
options:
show_root_full_path: false
Expand Down
16 changes: 15 additions & 1 deletion src/qprogram/plotting/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
result.plot(m0, channels="magnitude") # hypot(I, Q)
result.plot(m0, x="freq", style=Style(theme=DARK))

[`Waveform.plot`][qprogram.waveforms.Waveform.plot] runs the same two halves over an envelope, so a
pulse and the sweep it produced sit on one palette rather than looking like two libraries.

Only the drawing half needs matplotlib, which ships in the ``viz`` extra and is imported the first
time a figure is rendered.
"""
Expand All @@ -42,13 +45,24 @@
register_renderer,
resolve_renderer,
)
from qprogram.plotting.theme import DARK, LIGHT, Style, Theme
from qprogram.plotting.theme import (
DARK,
DEFAULT_SIZE,
ENVELOPE_SIZE,
IQ_ENVELOPE_SIZE,
LIGHT,
Style,
Theme,
)

__all__ = [
"CHANNELS",
"DARK",
"DEFAULT_RENDERER",
"DEFAULT_SIZE",
"ENVELOPE_SIZE",
"IQ_DIM",
"IQ_ENVELOPE_SIZE",
"KINDS",
"LIGHT",
"Figure",
Expand Down
13 changes: 7 additions & 6 deletions src/qprogram/plotting/matplotlib_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
# limitations under the License.
"""The matplotlib renderer — the one implementation of [`Renderer`][qprogram.plotting.Renderer] that ships.

Nothing else in the package imports it. It is loaded the first time something resolves the
``"matplotlib"`` renderer, which is what keeps ``import qprogram`` free of a plotting library;
matplotlib itself comes with the ``viz`` extra, so a missing install surfaces here as a plain
`ModuleNotFoundError`.
Nothing imports it at module scope. It is loaded the first time something resolves the
``"matplotlib"`` renderer or plots a waveform, which is what keeps ``import qprogram`` free of a
plotting library; matplotlib itself comes with the ``viz`` extra, so a missing install surfaces here
as a plain `ModuleNotFoundError`.

The frame it draws is deliberately quiet: no top or right spine, ticks with no marks, grid lines
behind the data, and a legend with no box. What should carry the eye is the data. The exception is a
Expand All @@ -33,6 +33,7 @@
from matplotlib.colors import LinearSegmentedColormap

from qprogram.plotting.model import Line, Points
from qprogram.plotting.theme import DEFAULT_SIZE

if TYPE_CHECKING:
from typing import Literal
Expand Down Expand Up @@ -77,11 +78,11 @@ def render(figure: Figure, style: Style, target: Axes | None = None) -> Axes:
# between the axes and the colour bar — so a figure carrying one is laid out constrained.
# A figure the caller brought keeps whatever layout the caller gave it.
twinned = figure.x_twin is not None or figure.y_twin is not None
fig, ax = plt.subplots(figsize=style.size, layout="constrained" if twinned else None)
fig, ax = plt.subplots(figsize=style.size or DEFAULT_SIZE, layout="constrained" if twinned else None)
fig.set_facecolor(style.theme.surface)
_frame(ax, style)

series = 0
series = figure.series
for mark in figure.marks:
if isinstance(mark, Line):
_line(ax, mark, style, series)
Expand Down
6 changes: 6 additions & 0 deletions src/qprogram/plotting/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ class Figure:
renderer with nowhere to put one may ignore it: it repeats what the x axis already
shows, in another variable.
y_twin (Twin | None): The same for the y axis.
series (int): Which categorical slot the first mark takes, the rest counting up from it.
Zero unless this figure is one panel of several that should not repeat a colour, which
is what keeps the Q panel of an IQ envelope off the I panel's hue. It is an index and
not a colour: what the slot holds is the renderer's business, and one drawing in a
single colour ignores it.
"""

marks: tuple[Mark, ...]
Expand All @@ -136,3 +141,4 @@ class Figure:
title: str | None = None
x_twin: Twin | None = None
y_twin: Twin | None = None
series: int = 0
Loading
Loading