Skip to content

Draw a waveform with the palette a result is drawn with - #36

Merged
fedonman merged 10 commits into
mainfrom
waveform-plot-theme
Sep 3, 2026
Merged

Draw a waveform with the palette a result is drawn with#36
fedonman merged 10 commits into
mainfrom
waveform-plot-theme

Conversation

@fedonman

@fedonman fedonman commented Sep 2, 2026

Copy link
Copy Markdown
Member

Waveform.plot and IQWaveform.plot now draw through the palette and the renderer registry result.plot draws through, so a pi pulse and the Rabi sweep it produced stop looking like two libraries. All three take the same style, renderer and target, target replacing ax and axes, and all three default their style to a plain Style(). Style.size stood in the way of that one default, since a pulse is a wide short figure and a measurement is not, so it now defaults to None, meaning the size that suits what is being drawn, and the three are named: qp.plotting.DEFAULT_SIZE, ENVELOPE_SIZE and IQ_ENVELOPE_SIZE, which are the sizes the plotting methods always had. Figure carries the colour slot its first mark takes for the same reason, which is what draws the two panels of an IQ envelope in the theme's first two colours now that no renderer argument can say so. Those panels are the one thing a waveform still decides itself: two axes sharing a scale is a matplotlib layout, so an IQ shape asks for target=(I, Q) and any other renderer has to be given it. _repr_html_ reads its figure off the axes plot returned rather than off pyplot's current figure, and hands back a <picture> holding the envelope drawn for both surfaces, so a waveform in a dark notebook is no longer a white rectangle. That last one is the question the draft left open and this takes the second of its options; the first, light only, is a small change from here if you would rather not double the render.

Closes #33.

Based on #35.

The executor took a variable's id when it built an axis and dropped everything
else, so every result coordinate arrived with an empty attrs dict. A program
that declared label="Drive amplitude", units="V" had both strings sitting on
the Variable and no way to reach them from the result.

Each axis now carries the attributes alongside its coords, and _finalize writes
them onto the matching coordinate of every field array. The keys are long_name
and units, which is what xarray's own plotting accessor reads, so
result.get(m0).sel(IQ="I").plot() labels its x axis rather than falling back to
the variable id. A key the variable did not declare is left out instead of
written as None, which xarray would render.

The array's own attrs and name are untouched: a measurement has no unit the
executor knows, and the y axis of a readout plot is physics.

Three doc passages said otherwise and move with it: variables.md listed the
executor and the result objects among the things that ignore units, rabi.md
explained that the axis label had to be typed out by hand, and the array
contract in measurements.md said nothing about coordinate attributes.
A QProgramResult handed back a bare DataArray and the docs told you to write
matplotlib. xarray's own .plot() does not fill the gap: a measurement array
carries a trailing IQ dimension, so a 1-D sweep looks two-dimensional and comes
back as a heatmap with IQ on the x axis.

result.plot() looks the array up the way get() does and works the figure out
from its shape. Every dimension but IQ is a plot dimension, time included, so a
raw trace draws against time; one of them gives a line per quadrature, two give
a heatmap, and kind="scatter" puts I against Q, which no dimension count
implies. channels= says what to make of the quadratures, x= and y= which
coordinate goes on which axis, and value_label= names the measured quantity,
which is the one label nothing in the result knows. An argument that cannot
choose anything for the kind in hand raises rather than being ignored.

A dimension built by a parallel composition is the case worth the error. It
carries one coordinate per composed variable and coords["a|b"] answers with a
plain integer range rather than failing, so guessing would produce a wrong axis
that looks entirely plausible. x= is required there.

The drawing sits behind a renderer resolved by name, registered the way
register_sweep_source registers a sweep source. build_figure returns a Figure of
Line, Points and Mesh marks holding numpy arrays and two axis labels, and knows
nothing about colour or canvas; Style and Theme are frozen dataclasses, so a
palette of your own is a constructor call and a variant is one replace(). Only
matplotlib_renderer imports a plotting library, and it is imported the first
time something is drawn, so import qprogram still pulls in numpy and xarray and
nothing else.

The guide gains a page for it, and three example pages drop the matplotlib they
had been hand-rolling. Qubit spectroscopy keeps its own, because its figure
reads in gigahertz and rescaling a coordinate is arithmetic on the array;
single-shot readout keeps its own, because two colours and a threshold line are
a layout, not something the result implies. A result gets no _repr_html_ for the
same reason: a waveform is one shape with one picture, a result holds every
measurement of a run and they need not share a shape.
Twelve pytest.raises blocks built their array inside the block, so the assertion
covered the setup as well as the call under test. The setup is hoisted above the
with, the way the rest of the suite already writes one.

Two Any annotations narrow. The matplotlib renderer draws on an Axes and nothing
else, so its target says Axes | None. QProgramResult.plot forwards its target to
whichever renderer was asked for, and object accepts every surface one of them
could take while claiming less than Any does.

The return type of plot stays Any: what comes back is the chosen renderer's, and
the matplotlib one hands back the Axes the docs tell you to keep working on.
A result carries hertz because the instrument takes hertz, and the figure of it
wants gigahertz. There was no way to ask for that: the qubit spectroscopy page
hand-rolled four lines of matplotlib for it and the guide had a paragraph
explaining why it had to.

qp.plotting.Quantity carries the three things that have to travel together when
a quantity is restated: what to call it, what unit to read it in, and the
arithmetic that gets there. coords= takes one per swept coordinate, keyed by the
name the axis resolved to, and value= takes one for the measured quantity, which
is the y axis of a line, the colour bar of a heatmap and both axes of a scatter.
value_label= is gone; a label alone is Quantity("Readout response").

One rule, in both directions: a change of unit and a change of numbers travel
together. Rescaling values that carry a unit has to say what the unit is now,
and a unit that contradicts the one already there has to come with the
arithmetic that earns it. Both fire only where there is an inherited unit to
falsify, so correcting a unit the program never recorded still works, and
Quantity(units="Hz", transform=lambda v: v - v[0]) says a shift keeps its unit.
What no check here can catch is arithmetic that does not match the unit it
claims; that needs a registry, and Variable.units legitimately holds "arb".

A transform is handed a copy, so the ordinary spelling of a baseline (v -= v[0])
cannot rewrite the measurement the figure is of. It is checked for raising, for
changing the shape, for returning something other than real numbers, and for
turning a finite value non-finite, each naming the argument that carried it. A
NaN the measurement already held is not blamed on it.

A coords key that reaches no axis raises rather than doing nothing, and says
which of the three mistakes it was: a typo, a coordinate that lost the axis to a
sibling on a composed dimension, or a dimension name where the coordinate along
it is drawn.

Four defects the review turned up go with it. A scatter never received the label
argument at all, so it was silently dropped; it now takes units and a transform
for the pair and refuses a label, since I and Q already name themselves. An
explicit channels="iq" was accepted on an array with no quadratures and labelled
the axis "Signal". A coordinate named after one dimension but living on another,
which xarray allows and the executor builds when one variable is swept at two
nesting levels, was drawn under the wrong dimension's label. And "phase" took
its unit from the channel while keeping the array's own name, so an annotated
array read "Readout voltage (rad)".

The guide gains the rules, what is not checked, and why this moves the data
rather than the tick labels. Qubit spectroscopy drops its matplotlib and reads
in gigahertz off the program, and T1 gains the microsecond axis its figure has
always had.
Fifteen pytest.raises blocks built their Quantity inside the block. A Quantity
rejects its own arguments, so the assertion covered the constructor as well as
the call under test; hoisting it above the with is also what makes those tests
say the error comes from build_figure.

The string naming the measured quantity's argument in an error becomes a
constant. A coordinate's restatement is named by the key that carried it; the
measured quantity has no key, only the argument, and it was spelled out nine
times.
@pullpo-for-slack

Copy link
Copy Markdown

🔗Pullpo.io Slack PR-Channel

@fedonman
fedonman force-pushed the waveform-plot-theme branch from 87c47c0 to 9e757b9 Compare September 2, 2026 10:38
@pullpo-for-slack

pullpo-for-slack Bot commented Sep 2, 2026

Copy link
Copy Markdown

AI Analysis

Oupsie! Looks like something went wrong on our end.
We'll investigate it and solve it for future pull requests!

@fedonman
fedonman force-pushed the waveform-plot-theme branch 5 times, most recently from 40faad8 to 4987fd5 Compare September 2, 2026 11:50
Waveform.plot and IQWaveform.plot were the only plotting in the package, and they
set no colour at all: no palette, no grid, no theme, and two figure sizes written
as literals. A pi pulse and the Rabi sweep it produced are one experiment, and
they came out looking like two libraries.

Both now describe their envelope as a plotting.Figure and hand it to a renderer
resolved by name, which is what result.plot does. They take the same three
arguments it takes and mean the same things by them: style, renderer, and target,
which replaces ax and axes. So there is one way to ask for a dark figure, one
registry deciding who draws, and one kind of handle coming back, whichever of the
three you called.

Style.size stood in the way of one shared default, since a pulse is a wide short
figure and a measurement is not. It now defaults to None, meaning the size that
suits what is being drawn, and the three are named: DEFAULT_SIZE for a figure a
renderer makes, ENVELOPE_SIZE and IQ_ENVELOPE_SIZE for the two waveform methods,
which are the sizes they always had. Style.sized is how a caller that knows fills
one in, so a renderer is handed a concrete size rather than having to invent one.
A size on the style still wins, and a target the caller brought keeps the figure
it is on.

The colour slot moves onto the figure for the same reason. An IQ pair draws its
two panels in the theme's first two colours, which is what the documentation
figures already do for a stacked pair, and with the drawing behind the registry
there is no longer a renderer argument to say so with. Figure.series is the slot
its first mark takes, zero unless the figure is one panel of several that should
not repeat a colour, and a renderer drawing in one colour ignores it.

The panels themselves are the one thing a waveform still decides for itself. Two
axes sharing a scale is a matplotlib layout rather than anything a Figure
describes, and it is the only pair this knows how to build, so an IQ shape asks
for target=(I, Q) and any renderer but the built-in one has to be given it. The
alternative was importing matplotlib to build panels a foreign renderer never
asked for, which would also fail on a machine with no matplotlib and a renderer
that does not need one.

_repr_html_ no longer picks its figure out of pyplot's global state. plot returns
the axes, the axes knows its figure, and an ordering contract that held only by
convention is gone. It was also the thing that would break first if a waveform
were ever drawn by something that is not pyplot.

What a notebook gets is the question the draft left open, and this takes the
second of its two options. The envelope is drawn once per surface and comes back
as a <picture> whose prefers-color-scheme source is the dark one, so a waveform
in a dark notebook is no longer a white rectangle. That reads the browser's
setting rather than the notebook's own theme, which is the editor's 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. A host that strips the
source is left with the light figure, which is what a cell had before.

The four smoke tests keep their bar for appearance and raise it for what is
decidable: which size each default lands on and that a style naming one wins,
that the renderer argument reaches the drawing and that an IQ shape refuses a
foreign one with no panels, that two themes reach the axes that come back, that
the markup survives an unrelated figure being pyplot's current one, and that
nothing is left open afterwards. mpl.use("Agg") and the figure teardown move to
conftest, where the suite's two drawing modules had a copy each.
@fedonman
fedonman force-pushed the waveform-plot-theme branch from 4987fd5 to 14e2d5e Compare September 2, 2026 12:02
@fedonman fedonman linked an issue Sep 2, 2026 that may be closed by this pull request

@flavie-lebars flavie-lebars left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

Base automatically changed from result-plot to main September 3, 2026 10:34
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

@fedonman
fedonman merged commit 4f46b2f into main Sep 3, 2026
9 checks passed
@fedonman
fedonman deleted the waveform-plot-theme branch September 3, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Give Waveform.plot() the same themes as the result plots

2 participants