Draw a waveform with the palette a result is drawn with - #36
Merged
Conversation
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.
fedonman
force-pushed
the
waveform-plot-theme
branch
from
September 2, 2026 10:38
87c47c0 to
9e757b9
Compare
AI AnalysisOupsie! Looks like something went wrong on our end. |
fedonman
force-pushed
the
waveform-plot-theme
branch
5 times, most recently
from
September 2, 2026 11:50
40faad8 to
4987fd5
Compare
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
force-pushed
the
waveform-plot-theme
branch
from
September 2, 2026 12:02
4987fd5 to
14e2d5e
Compare
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Waveform.plotandIQWaveform.plotnow draw through the palette and the renderer registryresult.plotdraws through, so a pi pulse and the Rabi sweep it produced stop looking like two libraries. All three take the samestyle,rendererandtarget,targetreplacingaxandaxes, and all three default their style to a plainStyle().Style.sizestood in the way of that one default, since a pulse is a wide short figure and a measurement is not, so it now defaults toNone, meaning the size that suits what is being drawn, and the three are named:qp.plotting.DEFAULT_SIZE,ENVELOPE_SIZEandIQ_ENVELOPE_SIZE, which are the sizes the plotting methods always had.Figurecarries 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 fortarget=(I, Q)and any other renderer has to be given it._repr_html_reads its figure off the axesplotreturned 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.