Skip to content
Merged
173 changes: 173 additions & 0 deletions .demos/plotting.ipynb

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions changelog/35.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`QProgramResult.plot` draws a measurement. The array is looked up the way `get` looks it up, and its shape chooses the figure: 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 swept variable's `label` and `units` reach the axis on their own. A dimension a parallel composition built carries one coordinate per composed variable, and both readings are drawn rather than one being chosen: the first runs along the axis and the second along a twin scale opposite it, in the order the dimension name gives them, so `"freq|time"` reads frequency across the bottom and time across the top. `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 rescaling values without saying what unit they are now in raises rather than printing a label that contradicts its own numbers. The drawing sits behind a renderer registered by name, so matplotlib is one implementation rather than the only one: `qp.plotting.build_figure` describes a figure using numpy and xarray alone, and `qp.plotting.Style` and `Theme` are frozen dataclasses a light or dark palette of your own replaces.
Binary file modified docs/assets/plots/cz-chevron-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/cz-chevron-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/qubit-spectroscopy-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/qubit-spectroscopy-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/rabi-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/rabi-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/t1-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/plots/t1-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 12 additions & 7 deletions docs/developer/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ qprogram/
├── optimization.py # optimize(): plan-improving program rewrites
├── executor.py # ReferencePlatform + simulate(): the reference interpreter
├── lsp.py # check_text(), the check|explain|serve CLI, the language server
├── plotting/ # the figure model, the themes, and the renderer registry
├── operations/ # one module per leaf op, plus the Operation base
├── blocks/ # Block, Sweep, Average, Parallel, Conditional
├── sweeps/ # SweepSource contract, built-in sources, combinators
Expand Down Expand Up @@ -102,13 +103,17 @@ plain string everywhere downstream.
The rest of the AST layer is supporting structure. `fragments.py` holds
`Fragment`, `Parameter`, and the `expand_program` lowering that inlines every
call site. `result.py` holds `MeasurementHandle`, `MeasurementResult`, and
`QProgramResult`. `waveform_library.py` resolves a waveform alias per bus and
owns the `.wfl` text format, which is deliberately not part of a `.qp` file:
calibration state travels alongside a program, not inside it. `errors.py`
defines the whole exception hierarchy under `QProgramError`, including the
platform-side classes that core QProgram never raises but every backend shares.
`_reserved.py` holds `RESERVED_KEYWORDS`, and `_structural.py` the two equality
helpers described below.
`QProgramResult`. `plotting/` is what `QProgramResult.plot` runs: `build.py`
turns a result array into the `Figure` description in `model.py`, and a
renderer registered in `renderers.py` draws it. Only `matplotlib_renderer.py`
imports a plotting library, and it is imported on first use, which is what
keeps `matplotlib` optional. `waveform_library.py` resolves a waveform alias
per bus and owns the `.wfl` text format, which is deliberately not part of a
`.qp` file: calibration state travels alongside a program, not inside it.
`errors.py` defines the whole exception hierarchy under `QProgramError`,
including the platform-side classes that core QProgram never raises but every
backend shares. `_reserved.py` holds `RESERVED_KEYWORDS`, and `_structural.py`
the two equality helpers described below.

Analysis sits above the AST. `protocol.py` defines what a platform declares:
`PlatformCapabilities` (per-bus profiles plus one platform-wide profile),
Expand Down
23 changes: 11 additions & 12 deletions docs/examples/cz-chevron.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,24 +188,22 @@ shot count it recorded there. A grid point holds `NaN` when that count is
zero, which happens only for a measurement inside a conditional arm the
program never selected at that point.

To plot the chevron, pick the IQ component you want; the array is already on
the grid, so no reshaping is needed:
Two swept dimensions besides `IQ` give a heatmap, and the array is already on
the grid, so no reshaping is needed. A heatmap colours one surface, so name the
quadrature you want; leaving `channels` out takes the magnitude instead:

```python
import matplotlib.pyplot as plt

plt.pcolormesh(data0.coords["dur"], data0.coords["amp"], data0.sel(IQ="I"))
plt.xlabel("Flux duration (ns)")
plt.ylabel("Flux amplitude (V)")
result.plot(m0, channels="i", value=qp.plotting.Quantity("Population transferred"))
```

![Heatmap of transferred population against flux duration and amplitude, with interference fringes converging to a chevron tip at 0.5 V.](../assets/plots/cz-chevron-light.png#only-light)
![Heatmap of transferred population against flux duration and amplitude, with interference fringes converging to a chevron tip at 0.5 V.](../assets/plots/cz-chevron-dark.png#only-dark)

`pcolormesh` takes the x axis first, so the inner sweep goes first and the
outer one second, the opposite of the dimension order in `data0.dims`.
matplotlib is not a runtime dependency; it comes with the `viz` extra,
installed with `pip install "qprogram[viz]"`.
The inner sweep runs along the x axis and the outer one up the y axis, matching
the loop nesting rather than the dimension order in `data0.dims`; `x=` and `y=`
say otherwise. matplotlib is not a runtime dependency; it comes with the `viz`
extra, installed with `pip install "qprogram[viz]"`.
[Plotting results](../guide/plotting.md) covers the rest.

## Adapting it

Expand All @@ -223,7 +221,8 @@ The two sources must then hold the same number of points, and both do here at
`ValidationError: parallel loops must have the same number of iterations to
advance in lockstep; got Sweep('amp'): 11, Sweep('dur'): 12`. The results come
back on one `"amp|dur"` dimension of 101 points carrying `amp` and `dur` as
coordinates along it. See [Control flow](../guide/control-flow.md).
coordinates along it, which `plot` draws as one axis with the other above it.
See [Control flow](../guide/control-flow.md).

For the SNZ flavor of CZ, swap the waveform and leave the rest of the program
alone:
Expand Down
35 changes: 24 additions & 11 deletions docs/examples/qubit-spectroscopy.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,26 +159,39 @@ since the phase of the transmitted signal depends on cable length and the
feature does not:

```python
import matplotlib.pyplot as plt
result.plot(
m0,
channels="magnitude",
coords={"freq": qp.plotting.Quantity(units="GHz", transform=lambda v: v / 1e9)},
value=qp.plotting.Quantity("Readout magnitude"),
)
```

magnitude = np.hypot(data.sel(IQ="I"), data.sel(IQ="Q"))
plt.plot(data.coords["freq"] / 1e9, magnitude)
plt.xlabel("Drive frequency (GHz)")
plt.ylabel("Readout magnitude")
![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz.](../assets/plots/qubit-spectroscopy-light.png#only-light)
![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz.](../assets/plots/qubit-spectroscopy-dark.png#only-dark)

`channels="magnitude"` is `np.hypot(I, Q)`, and the `qp.plotting.Quantity` on
`coords` restates the axis in gigahertz: the arithmetic and the unit it
produces travel as one object, so the axis cannot end up reading `(Hz)` over
numbers running 4.6 to 5.4. matplotlib is not a runtime dependency; it comes
with the `viz` extra, installed with `pip install "qprogram[viz]"`.

The figure is restated; the result is not. Reading the peak back is arithmetic
on the array, and the array is still in hertz:

```python
magnitude = np.hypot(data.sel(IQ="I"), data.sel(IQ="Q"))
f01 = float(data.coords["freq"][int(np.argmax(magnitude.values))]) # 5.0e9
```

![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz marked as f01.](../assets/plots/qubit-spectroscopy-light.png#only-light)
![Readout magnitude against drive frequency, flat except for a sharp peak at 5.000 GHz marked as f01.](../assets/plots/qubit-spectroscopy-dark.png#only-dark)

`np.hypot` over two `sel` results returns a `DataArray` with dims `("freq",)`,
so the coordinate survives the arithmetic and the peak can be read back as a
so the coordinate survives the arithmetic and the peak comes back as a
frequency. `np.argmax` wants the underlying array rather than the `DataArray`,
which is what `.values` is for; handing it the labelled array raises
`ValueError: dimensions ('freq',) must have the same length as the number of
data dimensions, ndim=0`. matplotlib is not a runtime dependency; it comes with
the `viz` extra, installed with `pip install "qprogram[viz]"`.
data dimensions, ndim=0`. The same split is worth remembering for anything you
draw on the axes `plot` returns: they are in the figure's units, so marking the
peak is `ax.axvline(f01 / 1e9)`.

## Adapting it

Expand Down
32 changes: 17 additions & 15 deletions docs/examples/rabi.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,31 +218,32 @@ It comes with the `viz` extra:
pip install "qprogram[viz]"
```

The `IQ` dimension is a coordinate, so the two quadratures come out by label:
`result.plot` works the figure out from the array's shape. One swept dimension
besides `IQ` gives a line per quadrature:

```python
import matplotlib.pyplot as plt

data = result.get(m0)
plt.plot(data.coords["gain"], data.sel(IQ="I"), label="I")
plt.plot(data.coords["gain"], data.sel(IQ="Q"), label="Q")
plt.xlabel("Drive amplitude (V)")
plt.legend()
result.plot(m0, value=qp.plotting.Quantity("Readout response"))
```

![Readout response against drive amplitude. I rises to a maximum of 1 at 0.5 V and falls back to 0 by 1.0 V, while Q stays flat at 0.](../assets/plots/rabi-light.png#only-light)
![Readout response against drive amplitude. I rises to a maximum of 1 at 0.5 V and falls back to 0 by 1.0 V, while Q stays flat at 0.](../assets/plots/rabi-dark.png#only-dark)

The axis label is written out here, but it does not have to be. The `label` and
`units` given to `program.variable` reach the coordinate as its `long_name` and
`units` attributes, so `data.coords["gain"].attrs` holds both and anything that
reads them labels the axis itself:
Nothing about the x axis is typed out. The `label` and `units` given to
`program.variable` reach the coordinate as its `long_name` and `units`
attributes, and the axis reads them:

```python
data.coords["gain"].attrs # {"long_name": "Drive amplitude", "units": "V"}
data.sel(IQ="I").plot() # x axis reads "Drive amplitude [V]"
```

`value=` is there because the other axis has no such source: what a demodulated
point means is the readout chain's business, not the program's. A
`qp.plotting.Quantity` is also how a coordinate gets restated for the figure,
in the units you want to read it in. The call returns the matplotlib `Axes`, so
anything else the figure does not decide is a method away on it.
[Plotting results](../guide/plotting.md) has the rest: heatmaps and scatters,
the `channels` argument, themes, and registering a renderer of your own.

## Adapting it

For a chip that is not a fixed-frequency transmon, change the schema. The
Expand All @@ -255,8 +256,9 @@ subclassing `BusSchema` gives typed accessors. See
To sweep frequency as well, add `program.set_frequency(q[0].drive, freq)` and
a second sweep. Nesting the two gives the full grid and a two-dimensional
result; composing them with `|` advances them in lockstep and gives one
`"gain|freq"` dimension carrying both coordinates. Both loops must then have
the same length. See [Control flow](../guide/control-flow.md).
`"gain|freq"` dimension carrying both coordinates, which `plot` draws as one
axis and a twin axis above it. Both loops must then have the same length. See
[Control flow](../guide/control-flow.md).

To read the classified state instead of the IQ point, request
`fields=(qp.MeasurementField.STATE,)` and read
Expand Down
6 changes: 6 additions & 0 deletions docs/examples/single-shot-readout.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ plt.legend()
![Scatter of single shots in the IQ plane: two well-separated gaussian blobs for the ground and excited preparations, split by a threshold at I = 2.](../assets/plots/single-shot-readout-light.png#only-light)
![Scatter of single shots in the IQ plane: two well-separated gaussian blobs for the ground and excited preparations, split by a threshold at I = 2.](../assets/plots/single-shot-readout-dark.png#only-dark)

`result.plot(shots, kind="scatter")` draws the same plane in one call, but as a
single cloud: two colours by prepared state and a line at the threshold are a
layout, and nothing on the result says those three things belong in one figure.
[Plotting results](../guide/plotting.md) draws the line between what `plot`
infers and what stays here.

Four thousand shots run in well under a tenth of a second, so this is the
cheapest program in the section despite having the most records. The
combination to be careful with is not the shot count but the shot count times
Expand Down
19 changes: 18 additions & 1 deletion docs/examples/t1-and-ramsey.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,28 @@ against `env["delay"]` is all it takes to give the curve a shape. Without a
`p_excited` argument every shot classifies as 0.

The curve is the exponential the model was given, sampled a thousand shots per
point, and the scatter around it is the Bernoulli noise of that count:
point, and the scatter around it is the Bernoulli noise of that count. `delay`
was declared in nanoseconds and runs to 40000, which is not how anyone reads a
T1, so the figure restates it:

```python
result.plot(
m0,
field=qp.MeasurementField.STATE,
coords={"delay": qp.plotting.Quantity(units="μs", transform=lambda v: v / 1000)},
value=qp.plotting.Quantity("Excited-state population"),
style=qp.plotting.Style(markers=True),
)
```

![Excited-state population against delay, decaying exponentially from 1 toward 0 over 40 microseconds.](../assets/plots/t1-light.png#only-light)
![Excited-state population against delay, decaying exponentially from 1 toward 0 over 40 microseconds.](../assets/plots/t1-dark.png#only-dark)

The `label` the variable was given survives the restatement and only the unit
moves, so the axis reads `Delay (μs)`. `markers=True` earns its place on a
41-point sweep, where the points are the measurement and the line between them
is interpolation. [Plotting results](../guide/plotting.md) covers the rest.

The `STATE` array has no trailing `"IQ"` dimension, because a classified
outcome is one number per shot rather than a pair. `result.get(m0)` on the same
handle still returns the IQ field with dims `("delay", "IQ")` and shape
Expand Down
17 changes: 9 additions & 8 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,20 @@ pip install "qprogram[viz]" # matplotlib >= 3.10.9
pip install "qprogram[lsp]" # pygls >= 2, < 3
```

The `viz` extra is what `Waveform.plot()` and `IQWaveform.plot()` need, and
`lsp` is what `python -m qprogram.lsp serve` needs. Both packages are imported
inside the call that uses them, so a missing extra raises
`ModuleNotFoundError` at that call rather than breaking `import qprogram`; the
language server catches that error and re-raises it naming the extra to
install, while `plot()` lets Python's own message through. The other two
language-server front-ends, `python -m qprogram.lsp check` and
The `viz` extra is what `QProgramResult.plot()`, `Waveform.plot()`, and
`IQWaveform.plot()` need, and `lsp` is what `python -m qprogram.lsp serve`
needs. Both packages are imported inside the call that uses them, so a missing
extra raises `ModuleNotFoundError` at that call rather than breaking
`import qprogram`; the language server catches that error and re-raises it
naming the extra to install, while `plot()` lets Python's own message through.
The other two language-server front-ends, `python -m qprogram.lsp check` and
`python -m qprogram.lsp explain`, need no extra at all: they run the parser
and validator the base install already carries, which is why an editor
integration can spawn them directly.

The base install covers the AST, expressions, sweep sources, waveforms, bus
schemas, serialization, validation, and the reference platform.
schemas, serialization, validation, the reference platform, and the half of
plotting that describes a figure without drawing it.
Vendor-specific operations come from separate packages that follow the protocol
described in [Building a vendor extension](developer/vendor-extensions.md).

Expand Down
4 changes: 3 additions & 1 deletion docs/guide/control-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,9 @@ in, since the inherited walk over the body alone would miss them.

In the result `DataArray` a parallel composition is one dimension, named by
joining the variable ids with `|` (`"freq|gain"`), and each variable contributes
its own coordinate array on that shared dimension.
its own coordinate array on that shared dimension. `plot` reads the first two of
them on an axis and a twin axis opposite it, in the order the name gives — see
[Plotting results](plotting.md#two-variables-on-one-axis).

`Parallel` has no context-manager method of its own. Constructing one directly,
as an analyzer or a code generator might, is `qp.blocks.Parallel(loops=[...])`
Expand Down
10 changes: 9 additions & 1 deletion docs/guide/execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ with p.average(1000), p.sweep(g, qp.Range(0.0, 1.0, 0.01)):

result = qp.simulate(p, model=model)
da = result.get("m0") # dims ("g", "IQ"), coords from the sweep
da.sel(IQ="I").plot() # a noisy Rabi oscillation (needs matplotlib, the `viz` extra)
result.plot("m0") # a noisy Rabi oscillation (needs matplotlib, the `viz` extra)
```

`plot` takes the same arguments `get` does and draws what it finds, choosing
the figure from the array's shape. [Plotting results](plotting.md) covers it.

`simulate` raises rather than returning a partial result. A program that
validation rejects raises `UnsupportedOperationError`, an operation whose
expression references a variable no enclosing loop binds raises
Expand Down Expand Up @@ -121,6 +124,11 @@ da.coords["a"].values # [0.0, 0.5, 1.0]
da.coords["b"].values # [10.0, 15.0, 20.0]
```

Both coordinates describe the same three samples, which is why a figure of them
reads one along the axis and the other on a twin scale opposite it rather than
picking between them. See
[Plotting results](plotting.md#two-variables-on-one-axis).

The trailing dimensions depend on which field you ask for. Writing `*sweeps` for
the loop dimensions above:

Expand Down
1 change: 1 addition & 0 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ where they are used.
| [Measurements and results](measurements.md) | The `measure` signature and its `fields` argument, how names are allocated and how they survive a `.qp` round trip, and `QProgramResult` access by handle, by name, and by index, with the dimensions a result carries. |
| [Capabilities, diagnostics, and profiles](capabilities.md) | `PlatformCapabilities`, the routing that decides which slot checks a node, the ten diagnostic codes and what produces each, the `ExecutionPlan` and `explain()`, numeric limits, predicates, and `Profile` bundles. |
| [Running programs](execution.md) | `qp.simulate` and `ReferencePlatform`: the result shapes a run produces, measurement models and the mock default, what the reference executor does not model, and what implementing `PlatformProtocol` involves. |
| [Plotting results](plotting.md) | `QProgramResult.plot`: the figure a result's shape asks for, the `channels` argument that decides what becomes of the `IQ` dimension, where an axis label comes from, the `Quantity` that restates a coordinate in the units you want to read it in, the `Style` and `Theme` dataclasses, and registering a renderer of your own. |
| [Saving and loading](serialization.md) | `dumps`, `loads`, `save`, and `load`: what the round trip preserves and what it drops, the format version and `require` lines, vendor activation at parse time, the normalizations the writer applies, and the `WaveformLibrary` that quoted aliases resolve through, with its own `.wfl` file. |

Two worked programs, each given in full from the builder calls to the result
Expand Down
5 changes: 5 additions & 0 deletions docs/guide/measurements.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ primary array lives.
result.measurements[0].data # the "iq" field if requested, else the first in canonical order
```

`result.plot` takes the same measurement, `bus`, and `field` arguments and
draws the array rather than returning it, working the figure out from the
dimensions below. [Plotting results](plotting.md) covers what it makes of each
shape.

## Result dimensions

The dimensions of every returned array are the enclosing `sweep` blocks,
Expand Down
Loading
Loading