Skip to content

Draw a result with result.plot(), behind a pluggable renderer - #35

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

Draw a result with result.plot(), behind a pluggable renderer#35
fedonman merged 10 commits into
mainfrom
result-plot

Conversation

@fedonman

@fedonman fedonman commented Sep 2, 2026

Copy link
Copy Markdown
Member

QProgramResult.plot draws a measurement. It looks the array up the way get does and works the figure out from its shape: one dimension besides IQ gives a line per quadrature, two give a heatmap, and kind="scatter" puts I against Q. channels= says what to make of the quadratures, x= and y= which coordinate goes on which axis, and a parallel-composed dimension asks for x= rather than plotting the sweep index behind your back. coords= and value= restate a quantity for the figure: a qp.plotting.Quantity carries 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, so qp.plotting.build_figure describes a figure with numpy and xarray alone and matplotlib is one implementation of Renderer rather than the only one; Style and Theme are 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.

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.
@pullpo-for-slack

Copy link
Copy Markdown

🔗Pullpo.io Slack PR-Channel

@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!

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 fedonman linked an issue Sep 2, 2026 that may be closed by this pull request
Base automatically changed from result-coord-labels to main September 2, 2026 16:12

@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.

Super cool implementation! I have left very minor comments.

Comment thread src/qprogram/plotting/quantity.py Outdated
)
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:

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.

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

@fedonman fedonman Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks! Done.

than real numbers, or introduces a non-finite value.
"""
if quantity is None or quantity.transform is None:
return values

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.

The array is only copied on the transform path even though the docstring says otherwise.

@fedonman fedonman Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks! Done.

Comment thread src/qprogram/plotting/renderers.py Outdated
ModuleNotFoundError: If the default renderer is asked for without ``matplotlib``
installed — install ``qprogram[viz]``.
"""
name = name or DEFAULT_RENDERER

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.

resolve_renderer("") resolves to the default, but it should probably raise an Error, just like giving a wrong string would.

@fedonman fedonman Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks! Done.

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.
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

@fedonman
fedonman merged commit 1a46553 into main Sep 3, 2026
9 checks passed
@fedonman
fedonman deleted the result-plot branch September 3, 2026 10:34
fedonman added a commit that referenced this pull request Sep 3, 2026
`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.
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]: Plot a result with result.plot(), behind a pluggable renderer

2 participants