Draw a result with result.plot(), behind a pluggable renderer - #35
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.
AI AnalysisOupsie! Looks like something went wrong on our end. |
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.
flavie-lebars
left a comment
There was a problem hiding this comment.
Super cool implementation! I have left very minor comments.
| ) | ||
| raise ValidationError(msg) | ||
| return _joined(quantity.label if quantity.label is not None else label, units) | ||
| if quantity.units and quantity.transform is None and units and quantity.units != units: |
There was a problem hiding this comment.
the truthy check if quantity.units exclude quantity.units == "" (unitless), so no error is raised if we do text(Quantity(units=""), label="Drive frequency", units="Hz", where="coords['freq']").
It should probably be quantity.units is not None
| than real numbers, or introduces a non-finite value. | ||
| """ | ||
| if quantity is None or quantity.transform is None: | ||
| return values |
There was a problem hiding this comment.
The array is only copied on the transform path even though the docstring says otherwise.
| ModuleNotFoundError: If the default renderer is asked for without ``matplotlib`` | ||
| installed — install ``qprogram[viz]``. | ||
| """ | ||
| name = name or DEFAULT_RENDERER |
There was a problem hiding this comment.
resolve_renderer("") resolves to the default, but it should probably raise an Error, just like giving a wrong string would.
Three findings, all of them a check that let through the case it was written to catch. units="" now obeys the rule the rest of a restatement obeys. The contradiction check guarded on a truthy new unit, so Quantity(units="") over a coordinate declaring "Hz" drew "Drive frequency" over values still running 4.6e9 and said nothing. An emptied unit is the claim that the numbers are dimensionless, which is a change like any other and comes with the arithmetic that made them a ratio; Quantity(units="", transform=lambda v: v / v[-1]) is how the normalised case was always meant to read, and it still draws. The error says the axis would carry no unit at all rather than that it would read (), which is what the old message would have printed with the unit emptied. An empty renderer= raises instead of resolving to matplotlib. `name or DEFAULT_RENDERER` read "" as "whatever is installed", but a name that arrived empty got lost on the way and drawing anyway hides that; only None asks for the default now. The message a wrong name gets lists matplotlib whether or not it has registered itself, since it registers on the first call that asks for it and the reader of "registered: none" cannot see that it is one call away. restated() no longer claims to copy on the path that copies nothing. The transform is handed a copy, and that is what protects the stored result; where there is no transform the array is handed back as it stands, and the docstring said the input was copied first without qualification.
|
`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.



QProgramResult.plotdraws a measurement. It looks the array up the waygetdoes and works the figure out from its shape: one dimension besidesIQgives a line per quadrature, two give a heatmap, andkind="scatter"puts I against Q.channels=says what to make of the quadratures,x=andy=which coordinate goes on which axis, and a parallel-composed dimension asks forx=rather than plotting the sweep index behind your back.coords=andvalue=restate a quantity for the figure: aqp.plotting.Quantitycarries the arithmetic and the words it produces in one object, so{"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}draws the axis in gigahertz and labels it so, and changing the numbers without the unit, or the unit without the numbers, raises instead of printing a label that contradicts itself. The drawing sits behind a renderer registered by name, soqp.plotting.build_figuredescribes a figure with numpy and xarray alone and matplotlib is one implementation ofRendererrather than the only one;StyleandThemeare frozen dataclasses with a light and a dark palette. The guide gains a page, and four example pages drop their hand-rolled matplotlib.Closes #32.
Based on #34.