diff --git a/.demos/plotting.ipynb b/.demos/plotting.ipynb new file mode 100644 index 0000000..1a1c5e7 --- /dev/null +++ b/.demos/plotting.ipynb @@ -0,0 +1,173 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "28650207", + "metadata": {}, + "outputs": [], + "source": [ + "import qprogram as qp\n", + "\n", + "schema = qp.BusSchema.transmon()\n", + "q = schema.q\n", + "\n", + "program = qp.QProgram(\n", + " label=\"qubit_spectroscopy\",\n", + " description=\"Two-tone spectroscopy of q0\",\n", + " schema=schema,\n", + ")\n", + "freq = program.variable(\"freq\", label=\"Drive frequency\", units=\"Hz\")\n", + "time = program.variable(id=\"time\", label=\"Time\", units=\"ns\")\n", + "\n", + "with program.average(shots=1000):\n", + " with program.sweep(freq, qp.Linspace(4.6e9, 5.4e9, num=201)) | program.sweep(time, qp.Range(0, 200)):\n", + " program.set_frequency(q[0].drive, freq)\n", + " program.play(q[0].drive, \"saturation\")\n", + " program.sync()\n", + " m0 = program.measure(q[0].readout, \"readout\", \"weights\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d376d9cd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "('freq|time', 'IQ')" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "\n", + "library = {\n", + " \"saturation\": qp.waveforms.IQPair(qp.waveforms.Square(0.02, 20000), qp.waveforms.Square(0.0, 20000)),\n", + " \"readout\": qp.waveforms.IQPair(qp.waveforms.Square(1.0, 2000), qp.waveforms.Square(0.0, 2000)),\n", + " \"weights\": qp.waveforms.IQPair(qp.waveforms.Square(1.0, 2000), qp.waveforms.Square(1.0, 2000)),\n", + "}\n", + "\n", + "\n", + "def lorentzian(bus, env):\n", + " \"\"\"A saturated transition: unit height at 5 GHz, 8 MHz half width.\"\"\"\n", + " f0, hwhm = 5.0e9, 8e6\n", + " return 1.0 / (1.0 + ((env[\"freq\"] - f0) / hwhm) ** 2) + 0j\n", + "\n", + "\n", + "result = qp.simulate(\n", + " program.with_waveforms(library),\n", + " model=qp.MockMeasurementModel(response=lorentzian, noise=0.01),\n", + ")\n", + "\n", + "data = result.get(m0)\n", + "data.dims # (\"freq\", \"IQ\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a7a565f", + "metadata": {}, + "outputs": [], + "source": [ + "dummy_result = qp.simulate(program.with_waveforms(library))\n", + "dummy_result.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ca899518", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result.plot(m0, channels=\"magnitude\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "293e004d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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7LXtrLPPRWpqqvrzunVrsWb1auh0OkyafDT69esX+sCpW9izZzdWrvwb9XX16Ne/P446apLXtae+vg4//fQTCgvykZaWjuOnTVffJ33Z31k4sh0ku3fvxl133o7KigrExcXhrTffwLvvvh3usKib+v23XxETE4uBAwep/2m1TZ+NF8x/C++++zbi4+NRUlKCO++4Dfv37QtjxNRVRUfHYODAQcjslYVffvm52YI0paWluPPO27B79y4kJSbhm6+/wvOzn/V5P0W2rN69MXDgIFRUVGDVqpXN9q/46y8A8LqWeSY7ixZ9h+dnPwur1Qqn04H777sb69at7bT4qev6/PNPMff11yBJEmJiY/DxRx/i8ccehSRJAJQ5+u+//178veIvJCenYNmypXhw5n1wOBw+7e9MHNkOko8/+hATJkzE9TfcCAAYOGgQZj36MP75z9OQkMAprch/OTnjMXDQoGbbCwoK8L///RdPPvmMur++vh6ffPIR7rr73s4Ok7q49PR0pKeno7y8HO++s7DZ/m++/grJySm46+57IQgCjpo0GTNuuBZbtmzGkCFD291PkW38+AkAgKLiImzKzW2xzbDhI3DssVOabbfb7fj4ow9x9TXXqfu1Wh3ef+9djB49JlQhUzcxadLROOec89Sfc3LG4+abZmDfvr3o27cffvvtF1RVVuKZZ2ZDr9fjpJNPwXXXXoU/fv8N06af0O7+zsSR7SCQZRmbNuUip/GiAwDDh4+A0WjC5s2bwhgZdWc//vg/LJj/Jv77/SLU1tao2zdtykVMTIxXIj5+/ATktvJGR9SWjbkbkJMzXl1kJjk5GX369EXuxo0+7Sdqz1/Ll+Gtt97A1199idLSUnX7zp07UV9fj/Hjx6vbxk+YgL1796CmpqalQ1EESUvzLo10j2jrdHoAwMaNGzF69Bjo9crPBoMBo0aNRm7uRp/2dyYm20FQV1cHm82GuLg4dZsgCIiNi0V5eXkYI6Pu6qqrr8XgwUOQlJyCv1Ysx40zblDrIsvLyxAbG+fVPi4uDjU11WH5eoy6t4rycsTGNX89ua9d7e0nast5552PsWPHISM9A9t3bMdNN16P7du2AQAqystgNBphMpnV9u730fLysrDES12Ty+XCu+++gzFjjkBmZiaA1q5N8e1cu+LDcu1ish0Eoqh0o8vl8trucrrUfUT+mDr1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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result.plot(\n", + " m0,\n", + " channels=\"magnitude\",\n", + " coords={\"freq\": qp.plotting.Quantity(units=\"GHz\", transform=lambda v: v / 1e9)},\n", + " value=qp.plotting.Quantity(\"Readout magnitude\"),\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "qprogram (3.14.x)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/changelog/35.added.md b/changelog/35.added.md new file mode 100644 index 0000000..56a656a --- /dev/null +++ b/changelog/35.added.md @@ -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. diff --git a/docs/assets/plots/cz-chevron-dark.png b/docs/assets/plots/cz-chevron-dark.png index ebb4c04..71e2f56 100644 Binary files a/docs/assets/plots/cz-chevron-dark.png and b/docs/assets/plots/cz-chevron-dark.png differ diff --git a/docs/assets/plots/cz-chevron-light.png b/docs/assets/plots/cz-chevron-light.png index fb7770d..17c9cfe 100644 Binary files a/docs/assets/plots/cz-chevron-light.png and b/docs/assets/plots/cz-chevron-light.png differ diff --git a/docs/assets/plots/qubit-spectroscopy-dark.png b/docs/assets/plots/qubit-spectroscopy-dark.png index 427ef78..58b482c 100644 Binary files a/docs/assets/plots/qubit-spectroscopy-dark.png and b/docs/assets/plots/qubit-spectroscopy-dark.png differ diff --git a/docs/assets/plots/qubit-spectroscopy-light.png b/docs/assets/plots/qubit-spectroscopy-light.png index 0ce91fa..f55e8c4 100644 Binary files a/docs/assets/plots/qubit-spectroscopy-light.png and b/docs/assets/plots/qubit-spectroscopy-light.png differ diff --git a/docs/assets/plots/rabi-dark.png b/docs/assets/plots/rabi-dark.png index 4ef317a..e12a152 100644 Binary files a/docs/assets/plots/rabi-dark.png and b/docs/assets/plots/rabi-dark.png differ diff --git a/docs/assets/plots/rabi-light.png b/docs/assets/plots/rabi-light.png index c62690a..9b61ac8 100644 Binary files a/docs/assets/plots/rabi-light.png and b/docs/assets/plots/rabi-light.png differ diff --git a/docs/assets/plots/t1-dark.png b/docs/assets/plots/t1-dark.png index 7ac2f7b..4737e6d 100644 Binary files a/docs/assets/plots/t1-dark.png and b/docs/assets/plots/t1-dark.png differ diff --git a/docs/assets/plots/t1-light.png b/docs/assets/plots/t1-light.png index f0296a3..a4da23b 100644 Binary files a/docs/assets/plots/t1-light.png and b/docs/assets/plots/t1-light.png differ diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index e33a3b4..d5d79cb 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -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 @@ -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), diff --git a/docs/examples/cz-chevron.md b/docs/examples/cz-chevron.md index 3b2c082..6e98cb3 100644 --- a/docs/examples/cz-chevron.md +++ b/docs/examples/cz-chevron.md @@ -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 @@ -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: diff --git a/docs/examples/qubit-spectroscopy.md b/docs/examples/qubit-spectroscopy.md index 82882d3..73a99af 100644 --- a/docs/examples/qubit-spectroscopy.md +++ b/docs/examples/qubit-spectroscopy.md @@ -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 diff --git a/docs/examples/rabi.md b/docs/examples/rabi.md index 71d7b71..a94715b 100644 --- a/docs/examples/rabi.md +++ b/docs/examples/rabi.md @@ -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 @@ -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 diff --git a/docs/examples/single-shot-readout.md b/docs/examples/single-shot-readout.md index 19db9f2..a1ff848 100644 --- a/docs/examples/single-shot-readout.md +++ b/docs/examples/single-shot-readout.md @@ -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 diff --git a/docs/examples/t1-and-ramsey.md b/docs/examples/t1-and-ramsey.md index 4eb5733..cdb31f6 100644 --- a/docs/examples/t1-and-ramsey.md +++ b/docs/examples/t1-and-ramsey.md @@ -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 diff --git a/docs/getting-started.md b/docs/getting-started.md index 357e7ca..f6ae2fd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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). diff --git a/docs/guide/control-flow.md b/docs/guide/control-flow.md index be9d992..85319ec 100644 --- a/docs/guide/control-flow.md +++ b/docs/guide/control-flow.md @@ -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=[...])` diff --git a/docs/guide/execution.md b/docs/guide/execution.md index d29dda3..593bc23 100644 --- a/docs/guide/execution.md +++ b/docs/guide/execution.md @@ -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 @@ -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: diff --git a/docs/guide/index.md b/docs/guide/index.md index 9a9b1fa..d29e0d5 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -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 diff --git a/docs/guide/measurements.md b/docs/guide/measurements.md index 38c9f3e..5c32999 100644 --- a/docs/guide/measurements.md +++ b/docs/guide/measurements.md @@ -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, diff --git a/docs/guide/plotting.md b/docs/guide/plotting.md new file mode 100644 index 0000000..d644c4e --- /dev/null +++ b/docs/guide/plotting.md @@ -0,0 +1,305 @@ +# Plotting results + +`QProgramResult.plot` draws one measurement. It looks the array up exactly the +way `get` does, works out what kind of figure its shape asks for, and hands the +drawing to a renderer: + +```python +result = qp.simulate(program) + +result.plot(m0) # a line per quadrature +result.plot(m0, channels="magnitude") # hypot(I, Q) +result.plot(m0, field="state") # the classified outcome +``` + +The default renderer is matplotlib, which comes with the `viz` extra and is +imported the first time something is drawn, so `import qprogram` never pulls in +a plotting library. It returns the `Axes` it drew on, which is the point: a +figure is a starting position, not a finished picture, and everything the call +does not decide is one method away on the object that comes back. + +```python +ax = result.plot(m0) +ax.axvline(0.5, linestyle="--") +ax.set_ylabel("Readout response") +``` + +Behind that call are two halves that never meet. `qp.plotting.build_figure` +reads the array and returns a `Figure`: marks holding numpy arrays, two axis +labels, and nothing about colour or canvas. A renderer takes that figure and a +`Style` and draws it. The seam is what lets a second backend exist, and what +lets a test check the shape of a figure without a display attached. + +## What the shape decides + +Every dimension except `IQ` is a plot dimension. `IQ` is the one that never +becomes an axis: it holds the two quadratures of a single measured point, so it +becomes the series of a line figure or the two axes of a scatter. `time` is an +ordinary plot dimension, which is why a raw trace draws against it. + +| Plot dimensions | Figure | Example | +|-----------------|-----------------|---------------------------------------------| +| one | lines | a Rabi sweep, `("gain", "IQ")` | +| two | a heatmap | a chevron, `("amp", "dur", "IQ")` | +| none | `ValidationError` | an unswept measurement, `("IQ",)` | +| three or more | `ValidationError` | select one down with `data.sel()` first | + +`kind=` overrides the inference, and `kind="scatter"` is the one shape that is +never inferred: plotting I against Q is a choice no dimension count implies. It +puts I on one axis and Q on the other and flattens every other dimension into +the cloud. + +```python +result.plot(shots, kind="scatter") +``` + +Its axes are settled by what they are, so `x`, `y`, and `channels` all raise +there rather than being quietly ignored. `y` raises on a line figure for the +same reason: only a heatmap has a second dimension to put on an axis. + +## The two quadratures + +`channels=` says what to make of the `IQ` dimension. + +| `channels` | What is drawn | +|---------------|-----------------------------------------------------| +| `"iq"` | one line per quadrature, labeled `I` and `Q` | +| `"i"`, `"q"` | that quadrature alone | +| `"magnitude"` | `hypot(I, Q)`, the reading a rotation cannot change | +| `"phase"` | `arctan2(Q, I)`, in radians | + +A line figure takes both quadratures by default, since that is the pair the +measurement produced. A heatmap colours one surface and has to reduce them, so +it takes the magnitude instead; `channels="iq"` on a heatmap raises rather than +picking a quadrature for you. An array with no `IQ` dimension, a `state` field +for instance, is already one number per point and rejects `channels` outright. + +## Axis labels and which axis is which + +An axis labels itself from the coordinate. A variable declared with a `label` +and `units` carries both onto its coordinate, and the axis reads +`Drive amplitude (V)` with nothing typed out: + +```python +gain = program.variable("gain", label="Drive amplitude", units="V") +``` + +Without a label the axis falls back to the variable id, and without units it is +the label alone. +[Variables and expressions](variables.md#label-units-and-description) has what +the two strings are and where else they travel. + +The other axis is the measured quantity, and there the result has less to go +on: a demodulated point is whatever the readout chain makes of it, and no unit +follows from the program. It is labeled from the channel by default, `Signal` +for a pair of quadratures and `Magnitude` for their hypotenuse, and `value=` +says what it really is. The same words label the colour bar of a heatmap. + +```python +result.plot(m0, value=qp.plotting.Quantity("Readout response")) +``` + +For a heatmap the innermost sweep runs along the x axis and the outermost up +the y axis, matching the loop nesting: the variable that changes fastest goes +left to right. `x=` and `y=` override that, and naming one settles the other: + +```python +result.plot(m0) # x is "dur", the inner sweep +result.plot(m0, x="amp") # x is "amp", so "dur" moves to y +``` + +## Two variables on one axis + +A dimension built by a parallel composition carries one coordinate per composed +variable and none of its own, so there are two readings of every sample and no +reason to throw one away. Both are drawn: the first goes on the axis and the +second on a twin scale opposite it, which is matplotlib's `secondary_xaxis`, +the position-tracking form of `twiny`. The order is the order the dimension +name gives, which is the order the loops were written in, so `"freq|time"` +draws frequency along the bottom and time along the top. + +```python +with program.sweep(freq, qp.Range(4e9, 5e9, 25e6)) | program.sweep(time, qp.Range(0, 400, 10)): + m0 = program.measure(q[0].readout, "readout", "weights") + +result.plot(m0) # freq along the bottom, time along the top +``` + +The twin ticks at samples rather than at round numbers. The two loops advanced +in lockstep, so tick *i* and sample *i* are the same measurement, and putting a +tick anywhere else would mean interpolating between measured points to label a +position nothing was measured at. `Style(twin_ticks=...)` is how many to aim +for; a sweep shorter than that gets one per sample. + +`x=` and `y=` name an axis to draw on its own, which is how an axis with +nothing above it is asked for: + +```python +result.plot(m0, x="freq") # frequency along the bottom, nothing on top +result.plot(m0, x="time") # time along the bottom instead +result.plot(m0, x="freq|time") # the sweep index, if that is what you meant +``` + +A heatmap twins each axis separately, so a chevron whose inner loop is a +composition reads its second variable across the top and a composition on the +outer loop reads up the right-hand side. A composition of three or more +variables draws the first two and leaves the rest: two scales on one axis is +already the most a reader can follow, and the coordinates are all still on the +array for a caller who wants a different one. + +## Restating a quantity + +A result carries hertz because the instrument takes hertz, and the figure of it +wants gigahertz. That is two changes at once, arithmetic on the numbers and a +new unit on the axis, and `Quantity` carries the pair so that neither can +travel without the other: + +```python +from qprogram.plotting import Quantity + +result.plot( + m0, + channels="magnitude", + coords={"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}, + value=Quantity("Readout magnitude"), +) +``` + +`coords=` is keyed by the name the axis resolved to, which is the same string +`x=` takes: the coordinate on the axis, or the dimension when no coordinate is. +A twin scale is keyed by its own coordinate the same way. +A key that reaches no axis raises rather than doing nothing, since a figure +that ignored it would print the axis it was asked to change. `value=` is the +measured quantity wherever it lands: the y axis of a line, the colour bar of a +heatmap, both axes of a scatter. + +| Field | What it does | +|---|---| +| `label` | Replaces the coordinate's `long_name`, or the name the channel implies. `None` keeps it. | +| `units` | Replaces the coordinate's `units`. `None` keeps it, `""` says the numbers now carry none. | +| `transform` | Arithmetic on the values. Gets a copy of the whole array that would have been drawn, and returns one real number per value. | + +Read positionally the three are the sentence the axis makes: + +```python +result.plot(m0, coords={"freq": Quantity("Detuning", "MHz", lambda f: (f - 5e9) / 1e6)}) +``` + +A `Quantity` describes presentation only. `result.get(m0)` is still in hertz +after the figure of it has been drawn in gigahertz, which is what you want when +the next line fits a peak, and what to remember when the line after that draws +on the axes: everything you hand the returned `Axes` is in the figure's units, +so a frequency read back off the array needs the same `/ 1e9` the figure got. + +### 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: + +```python +# On a coordinate that declares units="Hz": +Quantity(transform=lambda v: v / 1e9) # raises: the axis would read (Hz) over gigahertz +Quantity(units="GHz") # raises: relabels the unit, changes no number +Quantity(units="") # raises: calls hertz dimensionless, changes no number +Quantity(units="GHz", transform=lambda v: v / 1e9) # both halves, and the figure is drawn +Quantity(units="Hz", transform=lambda v: v - v[0]) # a shift keeps its unit, and says so +Quantity(units="", transform=lambda v: v / v[-1]) # a bare ratio, and the arithmetic that made one +``` + +Emptying a unit is a change like any other rather than a way around the rule: +`units=""` says the numbers carry no unit at all, which over values that +arrived in hertz needs the arithmetic that made them a ratio. + +Both fire only where there is a claim to falsify, so a coordinate that declared +no unit, or a demodulated magnitude that has none to declare, takes either half +alone. That is also how you correct a unit the program never recorded: +`Quantity(units="V")` on an unlabeled coordinate is a statement, not a +contradiction. + +A transform is checked for the things that produce a broken figure rather than +a wrong one: it must not raise, must return the shape it was given, must return +real numbers, and must not turn a finite value into an infinity or a NaN. A NaN +the measurement itself carries, from a grid point a conditional arm never +reached, passes through untouched. What cannot be checked is whether the +arithmetic matches the unit — `Quantity(units="GHz", transform=lambda v: v / 1e6)` +is a lie no check here can catch, because `Variable.units` is free-form text +that legitimately holds `arb`, `counts` and `shots`. + +### Why this moves the data, not the tick labels + +matplotlib would let a formatter rewrite the tick text and leave the numbers +alone, and that is what `EngFormatter` and `FuncFormatter` do. This does not, +for three reasons. The figure model is numpy and xarray only, so a formatter +would be a rendering contract smuggled into the description. A transform like +`v - v[0]` or `v / v.max()` reads the whole array, which no per-tick formatter +can see. And a ticks-only rescale leaves `ax.get_xlim()`, a fit, and any +`axvline` in the old unit while the axis reads the new one, which is the +mismatch this page spends its rules preventing. The numbers on the axis are the +numbers drawn. + +## Themes + +A `Style` is a palette plus the handful of settings that decide how heavy the +marks are. Two themes ship, `qp.plotting.LIGHT` and `qp.plotting.DARK`, and +both are frozen dataclasses, so a variant is one `dataclasses.replace` away and +a palette of your own is a constructor call. + +```python +from dataclasses import replace + +import qprogram as qp + +result.plot(m0, style=qp.plotting.Style(theme=qp.plotting.DARK)) +result.plot(m0, style=qp.plotting.Style(markers=True, legend=False)) + +house = replace(qp.plotting.LIGHT, series=("#3b6ea5", "#c1554a")) +result.plot(m0, style=qp.plotting.Style(theme=house)) +``` + +`Style` carries `size`, `linewidth`, `markers`, `markersize`, `point_size`, +`point_alpha`, `grid`, `legend`, `colorbar`, and `twin_ticks` alongside `theme`. +`markers` is worth turning on for a coarse sweep, where the points are the +measurement and the line between them is interpolation. + +## Another renderer + +A renderer is any callable taking a figure, a `Style`, and a surface to draw +on. Registering one works the way `register_sweep_source` works: one name, one +implementation, and a different object under a name already taken raises. + +```python +import qprogram as qp +from qprogram.plotting import Line, register_renderer + + +def to_text(figure, style, target=None): + """Print a figure instead of drawing it.""" + print(f"{figure.x_label} against {figure.y_label}") + for mark in figure.marks: + if isinstance(mark, Line): + print(f" {mark.label or 'series'}: {len(mark.x)} points") + + +register_renderer("text", to_text) +result.plot(m0, renderer="text") +``` + +`build_figure` is the half worth reading first when writing one. It returns a +`Figure` holding `Line`, `Points`, and `Mesh` marks, each a small frozen +dataclass of numpy arrays, and a renderer dispatches on their types. Nothing in +that half imports a plotting library, so a renderer for any backend reads the +same description. + +## What it does not draw + +`plot` returns composable axes rather than trying to be the whole figure. A +layout of several panels, a fit drawn over the data, an annotation pointing at +a peak: none of those follow from anything the result knows, and all of them +are ordinary calls on the axes that come back. The example pages that build one +keep their own plotting code for exactly that reason. + +A result does not draw itself in a Jupyter cell the way a waveform does. A +waveform is one shape and has one picture; a result holds every measurement of +a run, and they need not share a field, a shape, or an axis. `repr` stays the +list of what is in there, and `plot` draws the one you name. diff --git a/docs/reference/api-qprogram.md b/docs/reference/api-qprogram.md index 7d740ae..f89f500 100644 --- a/docs/reference/api-qprogram.md +++ b/docs/reference/api-qprogram.md @@ -11,9 +11,10 @@ dotted path, so another page can link to a single member: The supported surface is `qprogram.__all__`, the names that resolve directly on the package after `import qprogram as qp`. Three other kinds of name appear -here under a longer dotted path. The waveform, operation, and block classes -live in submodules the top level does not re-export, so they are written -`qp.waveforms.Gaussian`, `qp.operations.Play`, and `qp.blocks.Sweep`. A few +here under a longer dotted path. The waveform, operation, block, and plotting +classes live in submodules the top level does not re-export, so they are +written `qp.waveforms.Gaussian`, `qp.operations.Play`, `qp.blocks.Sweep`, and +`qp.plotting.Style`. A few names the top level does re-export are grouped with the submodule that defines them instead, because they read better next to related material: `Call` and `MeasurementField` sit with the rest of `qprogram.operations`, `UNASSIGNED` and @@ -448,6 +449,65 @@ compared, hashed, and serialized in. options: show_root_full_path: false +## Plotting + +`QProgramResult.plot` above is the front door. It runs `build_figure` to +describe the figure and a renderer to draw it, and the two halves are separate +so that a backend other than matplotlib is possible: everything down to +`Renderer` reads numpy and xarray only. See +[Plotting results](../guide/plotting.md) for the walkthrough. These names live +in `qprogram.plotting`, which the top level does not re-export. + +::: qprogram.plotting.build_figure + +::: qprogram.plotting.Quantity + options: + show_root_full_path: false + +::: qprogram.plotting.Figure + options: + show_root_full_path: false + +::: qprogram.plotting.Line + options: + show_root_full_path: false + +::: qprogram.plotting.Points + options: + show_root_full_path: false + +::: qprogram.plotting.Mesh + options: + show_root_full_path: false + +::: qprogram.plotting.Twin + options: + show_root_full_path: false + +::: qprogram.plotting.Style + options: + show_root_full_path: false + +::: qprogram.plotting.Theme + options: + show_root_full_path: false + +::: qprogram.plotting.LIGHT + +::: qprogram.plotting.DARK + +::: qprogram.plotting.Renderer + options: + show_root_full_path: false + +::: qprogram.plotting.register_renderer + +::: qprogram.plotting.resolve_renderer + +::: qprogram.plotting.available_renderers + +::: qprogram.plotting.matplotlib_renderer.render + ## Vendor protocol A vendor extension groups its operations as methods on a `VendorNamespace` diff --git a/src/qprogram/plotting/__init__.py b/src/qprogram/plotting/__init__.py new file mode 100644 index 0000000..a62b015 --- /dev/null +++ b/src/qprogram/plotting/__init__.py @@ -0,0 +1,68 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Plotting for results, in two halves that never meet. + +[`build_figure`][qprogram.plotting.build_figure] reads a measurement array and returns a +[`Figure`][qprogram.plotting.Figure]: marks holding numpy arrays, two axis labels, nothing about colour or canvas. +A [`Renderer`][qprogram.plotting.Renderer] takes that figure and a [`Style`][qprogram.plotting.Style] and draws it. +The seam is what lets a second backend exist, and what lets a test check the shape of a figure with +no display attached. + +[`QProgramResult.plot`][qprogram.QProgramResult.plot] is the front door and runs both halves:: + + result = qp.simulate(program) + result.plot(m0) # a line per quadrature, kind inferred + result.plot(m0, channels="magnitude") # hypot(I, Q) + result.plot(m0, x="freq", style=Style(theme=DARK)) + +Only the drawing half needs matplotlib, which ships in the ``viz`` extra and is imported the first +time a figure is rendered. +""" + +from __future__ import annotations + +from qprogram.plotting.build import CHANNELS, IQ_DIM, KINDS, build_figure +from qprogram.plotting.model import Figure, Line, Mark, Mesh, Points, Twin +from qprogram.plotting.quantity import Quantity +from qprogram.plotting.renderers import ( + DEFAULT_RENDERER, + Renderer, + available_renderers, + register_renderer, + resolve_renderer, +) +from qprogram.plotting.theme import DARK, LIGHT, Style, Theme + +__all__ = [ + "CHANNELS", + "DARK", + "DEFAULT_RENDERER", + "IQ_DIM", + "KINDS", + "LIGHT", + "Figure", + "Line", + "Mark", + "Mesh", + "Points", + "Quantity", + "Renderer", + "Style", + "Theme", + "Twin", + "available_renderers", + "build_figure", + "register_renderer", + "resolve_renderer", +] diff --git a/src/qprogram/plotting/build.py b/src/qprogram/plotting/build.py new file mode 100644 index 0000000..d3d6b72 --- /dev/null +++ b/src/qprogram/plotting/build.py @@ -0,0 +1,795 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Turning a result array into a [`Figure`][qprogram.plotting.Figure]. + +This is the whole of the reasoning about *what* to draw, and it uses numpy and xarray only. It reads +the dimensions the executor gave the array, decides which of them is an axis and which is a series, +pulls the axis labels off the coordinate attributes a swept variable left there, and hands back a +figure a renderer can draw without knowing any of it. + +The ``"IQ"`` dimension is the one that is never an axis. It carries the two quadratures of a single +measured point, so it becomes the series of a line figure, the two axes of a scatter, or a single +derived surface for a heatmap — see the ``channels`` argument. Every other dimension, ``"time"`` +included, is a plot dimension, which is what makes a raw trace plot against time on its own. + +A dimension a parallel composition built carries one coordinate per composed variable rather than +one for itself. The first two become an axis and its [`Twin`][qprogram.plotting.Twin], in the order +the dimension name gives them, because the loops advanced in lockstep and both readings describe the +same samples. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import numpy as np + +from qprogram.errors import ValidationError +from qprogram.plotting.model import Figure, Line, Mesh, Points, Twin +from qprogram.plotting.quantity import Quantity, checked, restated, text + +if TYPE_CHECKING: + from collections.abc import Mapping + + import xarray as xr + + from qprogram.plotting.model import Mark + +IQ_DIM = "IQ" +"""The dimension carrying the in-phase and quadrature halves of one measured point.""" + +# How the measured quantity's argument is named in an error. A coordinate's restatement is named by +# the key that carried it, ``coords['freq']``; the measured quantity has no key, only this argument. +_VALUE = "value=" + +KINDS = ("line", "heatmap", "scatter") +"""The figure shapes [`build_figure`][qprogram.plotting.build_figure] knows how to build.""" + +CHANNELS = ("iq", "i", "q", "magnitude", "phase") +"""The ways the ``"IQ"`` dimension can be turned into something plottable.""" + +# Channel name -> the (label, units) of the quantity it produces, for an array that names neither. +# The unit is held apart from the label rather than written into it, so that restating it is a +# change one rule can see: ``Quantity(units="deg", transform=numpy.degrees)`` composes "Phase (deg)" +# rather than "Phase (rad) (deg)", and forgetting the units= there fails like anywhere else. Only +# ``phase`` names a unit of its own — the others carry whatever the measured values carry. +_CHANNEL_LABELS = { + "iq": ("Signal", None), + "i": ("I", None), + "q": ("Q", None), + "magnitude": ("Magnitude", None), + "phase": ("Phase", "rad"), +} + + +@dataclass(frozen=True, eq=False) +class _Resolved: + """One axis after resolution, with its label and unit still held apart. + + Attributes: + name (str): What this axis resolved to — the coordinate drawn on it, or the dimension when + no coordinate is. This is the name a ``coords`` key has to match. + values (numpy.ndarray): The positions along the axis. + label (str): The name inherited from the coordinate, before any restatement. + units (str | None): The unit inherited with it, or ``None`` for none. + """ + + name: str + values: np.ndarray + label: str + units: str | None + + +@dataclass(frozen=True, eq=False) +class _Axis: + """One finished axis: the numbers to draw on it, the words for it, and the twin beside it. + + Attributes: + positions (numpy.ndarray): The positions along the axis, restated. + label (str): The text for the axis, restated. + twin (Twin | None): The second scale a composed dimension left over, or ``None``. + drawn (tuple[tuple[str, str], ...]): The ``(name, role)`` of everything this axis consumed a + ``coords`` key for — one entry, or two when it carries a twin. `_unused` lists these back + to a caller whose key reached nothing. + """ + + positions: np.ndarray + label: str + twin: Twin | None + drawn: tuple[tuple[str, str], ...] + + +def build_figure( # ruff: ignore[too-many-arguments] # every argument is one decision about the figure + data: xr.DataArray, + *, + kind: str | None = None, + x: str | None = None, + y: str | None = None, + channels: str | None = None, + coords: Mapping[str, Quantity] | None = None, + value: Quantity | None = None, + title: str | None = None, +) -> Figure: + """Describe the figure that ``data`` should be drawn as. + + Args: + data (xarray.DataArray): A measurement field array, as + [`QProgramResult.get`][qprogram.QProgramResult.get] returns it. Any array with at most one + ``"IQ"`` dimension works. + kind (str | None): One of `KINDS`. ``None`` infers it from the shape: one plot + dimension gives ``"line"``, two give ``"heatmap"``. ``"scatter"`` is never inferred — + plotting I against Q is a choice no shape implies. + x (str | None): Dimension or coordinate to put on the x axis, drawn alone — naming an axis + is what settles it, so no [`Twin`][qprogram.plotting.Twin] follows. ``None`` takes the + dimension's own coordinate, or for a dimension a parallel composition built, the first + two coordinates in the order the dimension name gives them: the first on the axis and + the second as its twin. + y (str | None): The same for the y axis of a ``"heatmap"``. The two arguments name different + dimensions; naming one leaves the other for the axis not named. Only a heatmap has a + second axis to choose, so a line or a scatter rejects it rather than ignoring it. + channels (str | None): One of `CHANNELS`, deciding what the ``"IQ"`` dimension + becomes. ``"iq"`` draws a line per quadrature, ``"i"`` and ``"q"`` one of them, + ``"magnitude"`` gives ``hypot(I, Q)`` and ``"phase"`` gives ``arctan2(Q, I)``. ``None`` + takes ``"iq"`` for a line and ``"magnitude"`` for a heatmap, which needs a single + surface and has no rotation to prefer I with. Rejected for an array with no ``"IQ"`` + dimension, such as a ``state`` field. + coords (collections.abc.Mapping[str, Quantity] | None): Restatements for the swept + coordinates, keyed by the name each axis or [`Twin`][qprogram.plotting.Twin] resolved to + — the same string ``x=`` or ``y=`` takes, or the coordinate's own name when neither was + given. Each + [`Quantity`][qprogram.plotting.Quantity] carries the arithmetic and the words it produces + together, so ``{"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}`` puts + gigahertz on the axis and says so. A key naming no axis this figure draws raises: a + figure that ignored it would print the axis it was asked to change. + value (Quantity | None): Restatement for the measured quantity — the y axis of a line, the + colour bar of a heatmap, both axes of a scatter. Its transform runs over each drawn + series separately, once per quadrature for a line and over the ``[row, column]`` grid + for a mesh, so ``lambda v: v - v[0]`` baselines each series against its own first point + and ``lambda v: v - v[:, :1]`` is the per-row spelling. + title (str | None): Title for the figure. No title by default. + + Returns: + The [`Figure`][qprogram.plotting.Figure] to hand a renderer. + + Raises: + ValidationError: If ``kind`` or ``channels`` is not a known name; if the array's shape does + not suit the requested kind; if ``x`` or ``y`` names something that is not a coordinate + on a plot dimension; if ``coords`` or ``value`` holds anything but a + [`Quantity`][qprogram.plotting.Quantity]; if a ``coords`` key names no axis this figure + draws; or if a restatement changes the numbers without the unit, or the unit without the + numbers. + """ + if kind is not None and kind not in KINDS: + msg = f"kind must be one of {', '.join(KINDS)}, got {kind!r}" + raise ValidationError(msg) + if channels is not None and channels not in CHANNELS: + msg = f"channels must be one of {', '.join(CHANNELS)}, got {channels!r}" + raise ValidationError(msg) + rescales = _rescales(coords) + value = checked(value, _VALUE) + + dims = tuple(str(dim) for dim in data.dims if dim != IQ_DIM) + kind = kind or _infer_kind(data, dims) + if kind == "scatter": + return _scatter(data, x, y, channels, rescales, value, title) + if kind == "line": + return _lines(data, dims, x, y, channels, rescales, value, title) + return _heatmap(data, dims, x, y, channels, rescales, value, title) + + +def _rescales(coords: Mapping[str, Quantity] | None) -> dict[str, Quantity]: + """Copy the ``coords`` mapping, checking every value is a [`Quantity`][qprogram.plotting.Quantity]. + + The copy is what the builders pop from as they consume keys, so whatever is left at the end is + by definition a key that named nothing the figure drew. + + Args: + coords (collections.abc.Mapping[str, Quantity] | None): The caller's mapping, or ``None``. + + Returns: + A mutable copy keyed by string. + + Raises: + ValidationError: If ``coords`` is not a mapping, or holds anything but quantities. + """ + if coords is None: + return {} + try: + items = list(coords.items()) + except AttributeError as exc: + msg = ( + f"coords= must be a mapping of coordinate name to Quantity, got " + f"{type(coords).__name__}, e.g. {{'freq': Quantity(units='GHz', transform=f)}}." + ) + raise ValidationError(msg) from exc + return {str(name): cast("Quantity", checked(q, f"coords[{name!r}]")) for name, q in items} + + +def _infer_kind(data: xr.DataArray, dims: tuple[str, ...]) -> str: + """Choose a figure shape from the dimensions left once ``"IQ"`` is set aside. + + Args: + data (xarray.DataArray): The array being plotted, used only to phrase the error. + dims (tuple[str, ...]): The plot dimensions, in array order. + + Returns: + ``"line"`` for one dimension, ``"heatmap"`` for two. + + Raises: + ValidationError: For no dimensions at all, or more than two. + """ + if len(dims) == 1: + return "line" + if len(dims) == 2: + return "heatmap" + if not dims: + msg = ( + f"Nothing to plot: {_shape(data)} is a single measured point with no dimension to plot " + f"it against. Sweep a variable, or plot the raw trace, which carries a 'time' dimension." + ) + raise ValidationError(msg) + msg = ( + f"Cannot infer a figure for {_shape(data)}: {len(dims)} dimensions besides 'IQ' " + f"({', '.join(dims)}) is more than a line or a heatmap can show. Select one down first, " + f"with data.sel({dims[0]}=...), or pass kind='scatter' to plot I against Q." + ) + raise ValidationError(msg) + + +def _shape(data: xr.DataArray) -> str: + """Describe an array's dimensions the way an error message wants to read them. + + Args: + data (xarray.DataArray): The array to describe. + + Returns: + A parenthesised dimension list, e.g. ``"an array with dims ('gain', 'IQ')"``. + """ + return f"an array with dims {tuple(str(dim) for dim in data.dims)}" + + +def _lines( # ruff: ignore[too-many-arguments] + data: xr.DataArray, + dims: tuple[str, ...], + x: str | None, + y: str | None, + channels: str | None, + rescales: dict[str, Quantity], + value: Quantity | None, + title: str | None, +) -> Figure: + """Build a line per channel against a single plot dimension. + + Args: + data (xarray.DataArray): The array to plot. + dims (tuple[str, ...]): Its plot dimensions. + x (str | None): Dimension or coordinate for the x axis. + y (str | None): Must be ``None``: a line figure's y axis is the measured value. + channels (str | None): Requested channel treatment. + rescales (dict[str, Quantity]): Restatements keyed by coordinate, consumed as they are used. + value (Quantity | None): Restatement for the measured quantity. + title (str | None): Figure title. + + Returns: + A figure of [`Line`][qprogram.plotting.Line] marks. + + Raises: + ValidationError: If the array does not have exactly one plot dimension, or if ``y`` was + given. + """ + if y is not None: + msg = f"y={y!r} chooses a second dimension, which only a heatmap has; a line's y axis is the measured value." + raise ValidationError(msg) + if len(dims) != 1: + msg = ( + f"kind='line' needs exactly one dimension besides 'IQ'; {_shape(data)} has " + f"{len(dims)} ({', '.join(dims) or 'none'}). Select the others down with data.sel()." + ) + raise ValidationError(msg) + dim = dims[0] + across = _axis(data, dim, x, "x", rescales) + _unused(rescales, across.drawn, data, "line") + channels = channels or _default_channels(data, "line") + label, units = _measured(data, channels) + marks: tuple[Mark, ...] = tuple( + Line( + x=across.positions, + y=restated(value, np.asarray(values.transpose(dim).values), _VALUE), + label=series, + ) + for series, values in _channels(data, channels, "line") + ) + return Figure( + marks=marks, + x_label=across.label, + y_label=text(value, label, units, _VALUE), + title=title, + x_twin=across.twin, + ) + + +def _heatmap( # ruff: ignore[too-many-arguments] + data: xr.DataArray, + dims: tuple[str, ...], + x: str | None, + y: str | None, + channels: str | None, + rescales: dict[str, Quantity], + value: Quantity | None, + title: str | None, +) -> Figure: + """Build a single coloured surface over two plot dimensions. + + Args: + data (xarray.DataArray): The array to plot. + dims (tuple[str, ...]): Its plot dimensions. + x (str | None): Dimension or coordinate for the x axis. + y (str | None): Dimension or coordinate for the y axis. + channels (str | None): Requested channel treatment; must name a single surface. + rescales (dict[str, Quantity]): Restatements keyed by coordinate, consumed as they are used. + value (Quantity | None): Restatement for the coloured values and the colour bar. + title (str | None): Figure title. + + Returns: + A figure holding one [`Mesh`][qprogram.plotting.Mesh]. + + Raises: + ValidationError: If the array does not have exactly two plot dimensions, or if ``x`` and + ``y`` name the same one. + """ + if len(dims) != 2: + msg = ( + f"kind='heatmap' needs exactly two dimensions besides 'IQ'; {_shape(data)} has " + f"{len(dims)} ({', '.join(dims) or 'none'})." + ) + raise ValidationError(msg) + x_dim, y_dim = _mesh_dims(data, dims, x, y) + across = _axis(data, x_dim, x, "x", rescales) + up = _axis(data, y_dim, y, "y", rescales) + _unused(rescales, across.drawn + up.drawn, data, "heatmap") + channels = channels or _default_channels(data, "heatmap") + ((_, values),) = _channels(data, channels, "heatmap") + label, units = _measured(data, channels) + mesh = Mesh( + x=across.positions, + y=up.positions, + values=restated(value, np.asarray(values.transpose(y_dim, x_dim).values), _VALUE), + label=text(value, label, units, _VALUE), + ) + return Figure( + marks=(mesh,), + x_label=across.label, + y_label=up.label, + title=title, + x_twin=across.twin, + y_twin=up.twin, + ) + + +def _consume(rescales: dict[str, Quantity], resolved: _Resolved) -> tuple[np.ndarray, str]: + """Take the restatement for one resolved axis out of the mapping and apply it. + + Popping as the figure is built is what makes a silent no-op inexpressible: a key is either + consumed by an axis or it is left over for `_unused` to raise on. + + Args: + rescales (dict[str, Quantity]): The remaining restatements. Mutated. + resolved (_Resolved): The axis to draw. + + Returns: + The positions along the axis and the text to label it with. + + Raises: + ValidationError: If the restatement changes the numbers without the unit, or the unit + without the numbers, or its transform misbehaves. + """ + where = f"coords[{resolved.name!r}]" + quantity = rescales.pop(resolved.name, None) + return ( + restated(quantity, resolved.values, where), + text(quantity, resolved.label, resolved.units, where), + ) + + +def _unused( + rescales: dict[str, Quantity], + drawn: tuple[tuple[str, str], ...], + data: xr.DataArray, + kind: str, +) -> None: + """Raise for every ``coords`` key no axis consumed, all of them in one message. + + Three mistakes end up here and they have three different fixes: a typo, a real coordinate that + lost the axis to a sibling on a composed dimension, and a dimension name where the coordinate + along it is what gets drawn. The message tells them apart and then lists what this figure + actually draws, so the caller does not have to work out the difference. + + Args: + rescales (dict[str, Quantity]): Whatever is left after every axis has taken its own. + drawn (tuple[tuple[str, str], ...]): The ``(name, role)`` of each axis this figure draws. + data (xarray.DataArray): The array being plotted, for the listing of what is on it. + kind (str): The figure kind, to name it in the message. + + Raises: + ValidationError: If anything is left. + """ + if not rescales: + return + reasons = "; ".join(_unused_reason(key, data) for key in sorted(rescales)) + listing = ", ".join(f"{name!r} ({role})" for name, role in drawn) + msg = ( + f"{reasons}. This {kind} draws: {listing}. The measured quantity is not keyed here — name " + f"it with value=Quantity(...). Coordinates on this result: " + f"{', '.join(str(name) for name in data.coords) or 'none'}." + ) + raise ValidationError(msg) + + +def _unused_reason(key: str, data: xr.DataArray) -> str: + """Say which of the three mistakes one leftover key is. + + Args: + key (str): The unconsumed ``coords`` key. + data (xarray.DataArray): The array being plotted. + + Returns: + A clause naming what the key turned out to be. + """ + prefix = f"coords[{key!r}]" + if key in data.dims: + return f"{prefix} names a dimension, and this figure draws a coordinate along it, not the sweep index" + if key in data.coords: + return f"{prefix} names a coordinate this figure does not draw" + return f"{prefix} names nothing on this result" + + +def _mesh_dims( + data: xr.DataArray, + dims: tuple[str, ...], + x: str | None, + y: str | None, +) -> tuple[str, str]: + """Decide which plot dimension goes on which axis of a heatmap. + + The default puts the innermost sweep on the x axis and the outermost on the y axis, so the + picture matches the loop nesting: the variable that changes fastest runs left to right. + + Args: + data (xarray.DataArray): The array being plotted. + dims (tuple[str, ...]): Its two plot dimensions, in array order. + x (str | None): Dimension or coordinate requested for the x axis. + y (str | None): Dimension or coordinate requested for the y axis. + + Returns: + The ``(x_dim, y_dim)`` pair. + + Raises: + ValidationError: If both arguments resolve to the same dimension. + """ + outer, inner = dims + x_dim = _dim_of(data, dims, x, "x") if x is not None else None + y_dim = _dim_of(data, dims, y, "y") if y is not None else None + if x_dim is not None and y_dim is not None and x_dim == y_dim: + msg = f"x={x!r} and y={y!r} both name dimension {x_dim!r}; a heatmap needs one on each axis" + raise ValidationError(msg) + if x_dim is None: + x_dim = outer if y_dim == inner else inner + if y_dim is None: + y_dim = inner if x_dim == outer else outer + return x_dim, y_dim + + +def _dim_of(data: xr.DataArray, dims: tuple[str, ...], name: str, argument: str) -> str: + """Return the plot dimension ``name`` sits on. + + Args: + data (xarray.DataArray): The array being plotted. + dims (tuple[str, ...]): Its plot dimensions. + name (str): A dimension name or the name of a coordinate on one. + argument (str): ``"x"`` or ``"y"``, so the error names the argument the caller passed. + + Returns: + The dimension name. + + Raises: + ValidationError: If ``name`` is neither a plot dimension nor a coordinate on one. + """ + if name in dims: + return name + coord = data.coords.get(name) + if coord is not None and len(coord.dims) == 1 and str(coord.dims[0]) in dims: + return str(coord.dims[0]) + msg = ( + f"{argument}={name!r} is not a dimension or a coordinate of this result. " + f"Dimensions: {', '.join(dims)}. Coordinates: {', '.join(str(c) for c in data.coords) or 'none'}." + ) + raise ValidationError(msg) + + +def _axis( + data: xr.DataArray, + dim: str, + requested: str | None, + argument: str, + rescales: dict[str, Quantity], +) -> _Axis: + """Resolve one axis of a figure, restate it, and pick up any twin beside it. + + Args: + data (xarray.DataArray): The array being plotted. + dim (str): The plot dimension this axis shows. + requested (str | None): The dimension or coordinate the caller named for this axis, if any. + argument (str): ``"x"`` or ``"y"``, for the error messages and the roles in them. + rescales (dict[str, Quantity]): Restatements keyed by coordinate. Mutated: this axis and its + twin each pop their own. + + Returns: + The finished axis. + + Raises: + ValidationError: If ``requested`` is not a coordinate on ``dim``, or if a restatement this + axis consumed changes the numbers without the unit, or the unit without the numbers. + """ + primary, partner = _coordinates(data, dim, requested, argument) + positions, label = _consume(rescales, primary) + role = f"the {argument} axis" + if partner is None: + return _Axis(positions=positions, label=label, twin=None, drawn=((primary.name, role),)) + values, twin_label = _consume(rescales, partner) + return _Axis( + positions=positions, + label=label, + twin=Twin(positions=positions, values=values, label=twin_label), + drawn=((primary.name, role), (partner.name, f"the twin of {role}")), + ) + + +def _coordinates( + data: xr.DataArray, + dim: str, + requested: str | None, + argument: str, +) -> tuple[_Resolved, _Resolved | None]: + """Choose the coordinate an axis draws, and the one that doubles it. + + A dimension a parallel composition built carries one coordinate per composed variable and none + of its own. Its loops advanced in lockstep, though, so every one of those coordinates describes + the same samples, and the second reading of them is a twin axis rather than a choice to be made: + the first two coordinates go on the axis and opposite it. Naming ``x=`` or ``y=`` draws that one + alone, which is how an axis with nothing above it is asked for. + + A composition of three or more leaves the rest undrawn. Two scales on one axis is already the + most a reader can follow, and a third would be a legend rather than an axis; the coordinates are + all still on the array for a caller who wants one of them instead. + + Args: + data (xarray.DataArray): The array being plotted. + dim (str): The plot dimension this axis shows. + requested (str | None): The dimension or coordinate the caller named for this axis, if any. + argument (str): ``"x"`` or ``"y"``, for the error message. + + Returns: + The coordinate on the axis, and the one to twin it with or ``None``. + + Raises: + ValidationError: If ``requested`` is not a coordinate on ``dim``. + """ + if requested is not None: + _dim_of(data, (dim,), requested, argument) + return _coordinate(data, dim, requested), None + if dim in data.coords: + return _coordinate(data, dim, dim), None + candidates = _candidates(data, dim) + if not candidates: + return _Resolved(name=dim, values=np.arange(data.sizes[dim]), label=dim, units=None), None + primary = _coordinate(data, dim, candidates[0]) + if len(candidates) == 1: + return primary, None + return primary, _coordinate(data, dim, candidates[1]) + + +def _candidates(data: xr.DataArray, dim: str) -> list[str]: + """List the coordinates lying along ``dim``, in the order the dimension name gives them. + + A parallel composition names its dimension by joining the ids of the variables it composes with + ``"|"``, so the name is the declaration order of the loops — which is the order the axis and its + twin are wanted in, and not what a mapping of coordinates hands back. A name that spells none of + them out, which is any array not built by the executor, leaves the array's own order alone. + + Args: + data (xarray.DataArray): The array being plotted. + dim (str): The dimension to look along. + + Returns: + The coordinate names, the ones the dimension name spells out first. + """ + along = [str(name) for name, coord in data.coords.items() if coord.dims == (dim,)] + ranked = [part for part in dim.split("|") if part in along] + return ranked + [name for name in along if name not in ranked] + + +def _coordinate(data: xr.DataArray, dim: str, name: str) -> _Resolved: + """Return one coordinate's values, with its name and unit still held apart. + + Args: + data (xarray.DataArray): The array being plotted. + dim (str): The dimension the coordinate lies along. + name (str): The coordinate's name, or ``dim`` itself for a dimension with no coordinate. + + Returns: + What this axis draws, still unrestated. + """ + if name not in data.coords or data.coords[name].dims != (dim,): + # A dimension carrying no coordinate of its own, or a name that belongs to another + # dimension: either way this axis has no values but its own index. xarray allows a + # coordinate named after one dimension to live on a second, and the executor builds exactly + # that when one variable is swept at two nesting levels, so taking the name at face value + # here would draw the other dimension's values under this one's label. + return _Resolved(name=name, values=np.arange(data.sizes[dim]), label=name, units=None) + coord = data.coords[name] + label = coord.attrs.get("long_name") + units = coord.attrs.get("units") + return _Resolved( + name=name, + values=np.asarray(coord.values), + label=str(label) if label else name, + units=str(units) if units else None, + ) + + +def _default_channels(data: xr.DataArray, kind: str) -> str | None: + """Choose what to do with the quadratures when the caller did not say. + + A line figure draws both, which is the pair a measurement produced. A heatmap colours one + surface and has to reduce them: it takes the magnitude, the one function of I and Q that does + not depend on a readout rotation the executor never applied. + + Args: + data (xarray.DataArray): The array being plotted. + kind (str): The figure kind being built. + + Returns: + The channel name, or ``None`` for an array with no ``"IQ"`` dimension to reduce. + """ + if IQ_DIM not in data.dims: + return None + return "magnitude" if kind == "heatmap" else "iq" + + +def _channels(data: xr.DataArray, channels: str | None, kind: str) -> list[tuple[str | None, xr.DataArray]]: + """Turn the ``"IQ"`` dimension into the series a figure draws. + + Args: + data (xarray.DataArray): The array to read the quadratures from. + channels (str | None): One of `CHANNELS`, or ``None`` for an array with no quadratures. + kind (str): The figure kind, so the error can say why a pair will not do. + + Returns: + One ``(label, values)`` pair per series, each ``values`` array without the ``"IQ"`` + dimension. + + Raises: + ValidationError: If the array has no ``"IQ"`` dimension to reduce, or if a heatmap was asked + for both quadratures at once. + """ + if IQ_DIM not in data.dims: + if channels is not None: + msg = ( + f"channels={channels!r} needs an 'IQ' dimension; {_shape(data)} has none. " + f"A 'state' field is already one number per point." + ) + raise ValidationError(msg) + return [(None, data)] + if channels == "iq" and kind == "heatmap": + msg = "A heatmap colours one surface, and channels='iq' is two. Pass channels='magnitude', 'phase', 'i' or 'q'." + raise ValidationError(msg) + in_phase = data.sel({IQ_DIM: "I"}) + quadrature = data.sel({IQ_DIM: "Q"}) + if channels == "iq": + return [("I", in_phase), ("Q", quadrature)] + if channels == "i": + return [("I", in_phase)] + if channels == "q": + return [("Q", quadrature)] + # numpy's ufuncs dispatch through xarray and hand back a DataArray with the coordinates intact; + # the cast is only to say so, since their annotations stop at ndarray. + if channels == "magnitude": + return [("Magnitude", cast("xr.DataArray", np.hypot(in_phase, quadrature)))] + return [("Phase", cast("xr.DataArray", np.arctan2(quadrature, in_phase)))] + + +def _measured(data: xr.DataArray, channels: str | None) -> tuple[str, str | None]: + """Return the inherited label and unit of the measured quantity. + + The array's own ``long_name`` is read first, then its name, then the name the channel implies. + The unit is the channel's when the channel defines one — only ``"phase"`` does, since an + arctangent is radians whatever went into it — and otherwise the array's own. + + Args: + data (xarray.DataArray): The array being plotted. + channels (str | None): The channel treatment in force. + + Returns: + The ``(label, units)`` a [`Quantity`][qprogram.plotting.Quantity] restates. + """ + label, units = _CHANNEL_LABELS.get(channels, ("Value", None)) if channels is not None else ("Value", None) + if units is not None: + # A channel declares a unit only where it made a new quantity out of the pair, and there its + # own name belongs with its own unit: the array names the values that went in, not the + # arctangent that came out. + return label, units + attribute = data.attrs.get("units") + inherited = data.attrs.get("long_name") or data.name + return (str(inherited) if inherited else label), (str(attribute) if attribute else None) + + +def _scatter( # ruff: ignore[too-many-arguments] + data: xr.DataArray, + x: str | None, + y: str | None, + channels: str | None, + rescales: dict[str, Quantity], + value: Quantity | None, + title: str | None, +) -> Figure: + """Build I against Q, every other dimension flattened into the cloud. + + Args: + data (xarray.DataArray): The array to plot. + x (str | None): Must be ``None``: the x axis of a scatter is the in-phase quadrature. + y (str | None): Must be ``None``: the y axis is the other one. + channels (str | None): Must be ``None``: the axes of a scatter *are* the quadratures. + rescales (dict[str, Quantity]): Must be empty: a scatter draws no coordinate. + value (Quantity | None): Restatement for the pair. Its ``units`` and ``transform`` reach + both axes; a ``label`` is refused, since I and Q already name themselves. + title (str | None): Figure title. + + Returns: + A figure holding one [`Points`][qprogram.plotting.Points] mark. + + Raises: + ValidationError: If the array has no ``"IQ"`` dimension, if any of the arguments that choose + an axis was given, or if ``value`` carries a label. + """ + if IQ_DIM not in data.dims: + msg = f"kind='scatter' plots I against Q, and {_shape(data)} has no 'IQ' dimension." + raise ValidationError(msg) + named = [name for name, given in (("x", x), ("y", y), ("channels", channels)) if given is not None] + if rescales: + named.append("coords") + if named: + pointer = " A scatter draws no coordinate; restate the quadratures with value=." if rescales else "" + msg = ( + f"kind='scatter' already puts I on one axis and Q on the other; " + f"{', '.join(named)} has nothing left to choose.{pointer}" + ) + raise ValidationError(msg) + if value is not None and value.label is not None: + msg = ( + "value=Quantity(label=...) names one quantity, and a scatter's two axes are I and Q, " + "which already name themselves — one label on both would hide which is which. Pass " + "units= and transform= to restate the pair, and title= to name the figure." + ) + raise ValidationError(msg) + units = data.attrs.get("units") + units = str(units) if units else None + in_phase = restated(value, np.asarray(data.sel({IQ_DIM: "I"}).values).reshape(-1), _VALUE) + quadrature = restated(value, np.asarray(data.sel({IQ_DIM: "Q"}).values).reshape(-1), _VALUE) + return Figure( + marks=(Points(x=in_phase, y=quadrature),), + x_label=text(value, "I", units, _VALUE), + y_label=text(value, "Q", units, _VALUE), + title=title, + ) diff --git a/src/qprogram/plotting/matplotlib_renderer.py b/src/qprogram/plotting/matplotlib_renderer.py new file mode 100644 index 0000000..4b28186 --- /dev/null +++ b/src/qprogram/plotting/matplotlib_renderer.py @@ -0,0 +1,289 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The matplotlib renderer — the one implementation of [`Renderer`][qprogram.plotting.Renderer] that ships. + +Nothing else in the package imports it. It is loaded the first time something resolves the +``"matplotlib"`` renderer, which is what keeps ``import qprogram`` free of a plotting library; +matplotlib itself comes with the ``viz`` extra, so a missing install surfaces here as a plain +`ModuleNotFoundError`. + +The frame it draws is deliberately quiet: no top or right spine, ticks with no marks, grid lines +behind the data, and a legend with no box. What should carry the eye is the data. The exception is a +[`Twin`][qprogram.plotting.Twin], which puts a spine back on the side it reads from, because a +second scale that does not announce itself is worse than no second scale. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import LinearSegmentedColormap + +from qprogram.plotting.model import Line, Points + +if TYPE_CHECKING: + from typing import Literal + + from matplotlib.axes import Axes + from matplotlib.figure import Figure as MatplotlibFigure + from numpy.typing import ArrayLike + + from qprogram.plotting.model import Figure, Mesh, Twin + from qprogram.plotting.theme import Style + +# Data sits above the grid and below the annotations that point at it. +_DATA_LAYER = 3 + +# Gap between the axes and the colour bar, in fractions of the axes width. The twinned figure needs +# the wider one: its y twin reads up the right-hand side, where the narrow gap would put the colour +# bar through the tick labels. +_COLORBAR_PAD = 0.02 +_TWINNED_COLORBAR_PAD = 0.13 + +# Significant figures on a twin's tick labels. The ticks sit at samples rather than at round +# numbers, so most of them are values no formatter would have chosen and four digits is where they +# stop growing the axis without saying more. +_TWIN_TICK_DIGITS = 4 + + +def render(figure: Figure, style: Style, target: Axes | None = None) -> Axes: + """Draw ``figure`` on a matplotlib `Axes`. + + Args: + figure (Figure): What to draw. + style (Style): The palette and the weights to draw it with. + target (matplotlib.axes.Axes | None): An `Axes` to draw on. A fresh figure is created when + ``None``, sized by `Style.size` and filled with the theme's surface colour. + + Returns: + The `Axes` the marks were drawn on. + """ + ax = target + if ax is None: + # A twin puts a scale where the default margins have nothing reserved — above the title, or + # between the axes and the colour bar — so a figure carrying one is laid out constrained. + # A figure the caller brought keeps whatever layout the caller gave it. + twinned = figure.x_twin is not None or figure.y_twin is not None + fig, ax = plt.subplots(figsize=style.size, layout="constrained" if twinned else None) + fig.set_facecolor(style.theme.surface) + _frame(ax, style) + + series = 0 + for mark in figure.marks: + if isinstance(mark, Line): + _line(ax, mark, style, series) + series += 1 + elif isinstance(mark, Points): + _points(ax, mark, style, series) + series += 1 + else: + # ``Mark`` is a closed union of the three, so what is left is a Mesh. + _mesh(ax, mark, style, _TWINNED_COLORBAR_PAD if figure.y_twin else _COLORBAR_PAD) + + ax.set_xlabel(figure.x_label) + ax.set_ylabel(figure.y_label) + if figure.title: + ax.set_title(figure.title, color=style.theme.text, fontsize=10, loc="left", pad=6) + _legend(ax, figure, style) + # Last, because a twin is furniture over a finished axis: it reads the scale and the limits the + # marks and the labels above have already settled. + _twin(ax, figure.x_twin, style, "x") + _twin(ax, figure.y_twin, style, "y") + return ax + + +def _frame(ax: Axes, style: Style) -> None: + """Push the axes furniture back so the data reads first. + + Args: + ax (Axes): The axes to restyle. + style (Style): The palette and whether a grid is wanted. + """ + theme = style.theme + ax.set_facecolor(theme.surface) + # Line properties alongside ``visible=False`` are what matplotlib warns about, so the off case + # passes nothing but the switch. + if style.grid: + ax.grid(visible=True, color=theme.grid, linewidth=0.8, zorder=0) + else: + ax.grid(visible=False) + ax.set_axisbelow(True) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + for side in ("left", "bottom"): + ax.spines[side].set_color(theme.grid) + ax.tick_params(colors=theme.muted, labelsize=9, length=0) + ax.xaxis.label.set_color(theme.muted) + ax.yaxis.label.set_color(theme.muted) + ax.xaxis.label.set_fontsize(10) + ax.yaxis.label.set_fontsize(10) + + +def _line(ax: Axes, mark: Line, style: Style, series: int) -> None: + """Draw one polyline. + + Args: + ax (Axes): The axes to draw on. + mark (Line): The samples and the legend entry. + style (Style): The palette and the stroke weight. + series (int): Which categorical colour slot this mark takes. + """ + ax.plot( + mark.x, + mark.y, + color=style.color(series), + linewidth=style.linewidth, + marker="o" if style.markers else None, + markersize=style.markersize, + label=mark.label, + zorder=_DATA_LAYER, + ) + + +def _points(ax: Axes, mark: Points, style: Style, series: int) -> None: + """Draw one cloud of samples. + + Args: + ax (Axes): The axes to draw on. + mark (Points): The samples and the legend entry. + style (Style): The palette, the point size, and the opacity that keeps a dense cloud + readable. + series (int): Which categorical colour slot this mark takes. + """ + ax.scatter( + mark.x, + mark.y, + s=style.point_size, + alpha=style.point_alpha, + linewidths=0, + color=style.color(series), + label=mark.label, + zorder=_DATA_LAYER, + ) + + +def _mesh(ax: Axes, mark: Mesh, style: Style, pad: float) -> None: + """Draw one coloured surface, with its colour bar. + + Args: + ax (Axes): The axes to draw on. + mark (Mesh): The grid and its colour-bar label. + style (Style): The palette the ramp is built from, and whether a colour bar is wanted. + pad (float): Gap between the axes and the colour bar, as a fraction of the axes width. + """ + theme = style.theme + cmap = LinearSegmentedColormap.from_list("qprogram-sequential", theme.ramp) + mesh = ax.pcolormesh(mark.x, mark.y, mark.values, cmap=cmap, shading="nearest", rasterized=True) + if not style.colorbar: + return + # An Axes always belongs to a figure; the annotation admits None for an axes under teardown. + bar = cast("MatplotlibFigure", ax.get_figure()).colorbar(mesh, ax=ax, pad=pad) + if mark.label: + bar.set_label(mark.label, color=theme.muted, fontsize=10) + bar.ax.tick_params(colors=theme.muted, labelsize=9, length=0) + bar.outline.set_edgecolor(theme.grid) + bar.outline.set_linewidth(0.8) + + +def _identity(values: ArrayLike) -> ArrayLike: + """Return ``values`` unchanged. + + A secondary axis is defined by the pair of functions mapping between its scale and the parent's. + A twin shares the parent's samples, so the pair is this function twice and the ticks are placed + at positions the builder already worked out. + + Args: + values (numpy.typing.ArrayLike): Positions on either scale. + + Returns: + The same positions. + """ + return values + + +def _twin(ax: Axes, twin: Twin | None, style: Style, orientation: Literal["x", "y"]) -> None: + """Draw the second scale a parallel composition left on one axis. + + matplotlib's `Axes.secondary_xaxis` rather than `Axes.twiny`: a secondary axis is an artist of + the axes it doubles, so it follows the position a colour bar shrank and the limits a later + ``set_xlim`` changes, where a twinned Axes is a second Axes that has to be kept in step by hand. + The scale is the identity and the ticks are fixed at the samples, since the two variables + advanced in lockstep and every tick is a measurement rather than a round number. + + Args: + ax (Axes): The axes to add the scale to. + twin (Twin | None): The scale to draw, or ``None`` to draw none. + style (Style): The palette, and how many ticks a twin gets. + orientation (Literal["x", "y"]): ``"x"`` for a scale along the top, ``"y"`` for one up the + right side. + """ + if twin is None: + return + theme = style.theme + if orientation == "x": + secondary = ax.secondary_xaxis("top", functions=(_identity, _identity)) + side, axis = "top", secondary.xaxis + else: + secondary = ax.secondary_yaxis("right", functions=(_identity, _identity)) + side, axis = "right", secondary.yaxis + positions, labels = _twin_ticks(twin, style.twin_ticks) + secondary.set_ticks(positions, labels=labels) + axis.set_label_text(twin.label) + axis.label.set_color(theme.muted) + axis.label.set_fontsize(10) + secondary.tick_params(colors=theme.muted, labelsize=9, length=0) + # The secondary axes is a sliver the height of a hairline, so its patch would paint a line of + # surface colour over the spine it sits on. + secondary.patch.set_visible(False) + secondary.spines[side].set_color(theme.grid) + + +def _twin_ticks(twin: Twin, wanted: int) -> tuple[np.ndarray, list[str]]: + """Choose where to tick a twin scale and what to write at each tick. + + The ticks land on samples, evenly spaced through the sweep and always including its ends. A + parallel sweep need not be linear in either variable, and putting the ticks anywhere else would + mean interpolating between measured points to label a position nothing was measured at. + + Args: + twin (Twin): The scale being drawn. + wanted (int): How many ticks to aim for. A sweep with fewer samples than that gets one per + sample. + + Returns: + The tick positions, on the parent axis's scale, and the text for each. + """ + total = len(twin.positions) + indices = np.unique(np.linspace(0, total - 1, min(max(wanted, 2), total)).round().astype(int)) + return twin.positions[indices], [f"{value:.{_TWIN_TICK_DIGITS}g}" for value in twin.values[indices]] + + +def _legend(ax: Axes, figure: Figure, style: Style) -> None: + """Add a legend when there is more than one named mark to tell apart. + + A single named series needs no legend: the axis label already says what the curve is. + + Args: + ax (Axes): The axes to draw on. + figure (Figure): Read for how many of its marks carry a label. + style (Style): The palette, and whether a legend is wanted at all. + """ + named = [mark for mark in figure.marks if mark.label] + if not style.legend or len(named) < 2: + return + legend = ax.legend(frameon=False, fontsize=9) + for text in legend.get_texts(): + text.set_color(style.theme.text) diff --git a/src/qprogram/plotting/model.py b/src/qprogram/plotting/model.py new file mode 100644 index 0000000..883245a --- /dev/null +++ b/src/qprogram/plotting/model.py @@ -0,0 +1,138 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""What a figure *is*, with nothing about how it is drawn. + +A [`Figure`][qprogram.plotting.Figure] is a sequence of marks and the two axis labels they share. A mark is one of +three shapes — a [`Line`][qprogram.plotting.Line], a [`Points`][qprogram.plotting.Points] cloud, or a +[`Mesh`][qprogram.plotting.Mesh] — each holding plain numpy arrays. An axis may also carry a +[`Twin`][qprogram.plotting.Twin], a second scale reading the same samples in another variable. There is no colour +here, no figure size, and no reference to a plotting library: those belong to +[`Style`][qprogram.plotting.Style] and to the renderer. + +Keeping the description separate is what makes a second renderer possible at all. It is also what +lets a caller inspect what would be drawn without drawing it, which is how the tests in +``tests/test_plotting.py`` check the shape of a figure without a display. + +The dataclasses are frozen and compare by identity: a field holding a numpy array has no equality +that answers `True` or `False`, so an ``==`` between two figures would raise rather than +report a difference. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeAlias + +if TYPE_CHECKING: + import numpy as np + + +@dataclass(frozen=True, eq=False) +class Line: + """A polyline through ``(x, y)``, drawn in the order the samples are given. + + Attributes: + x (numpy.ndarray): Positions along the x axis. Same length as ``y``. + y (numpy.ndarray): The values plotted against them. + label (str | None): Legend entry. ``None`` for a figure whose single line needs no name. + """ + + x: np.ndarray + y: np.ndarray + label: str | None = None + + +@dataclass(frozen=True, eq=False) +class Points: + """An unordered cloud of ``(x, y)`` samples — a scatter. + + Attributes: + x (numpy.ndarray): Positions along the x axis. Same length as ``y``. + y (numpy.ndarray): The values plotted against them. + label (str | None): Legend entry, or ``None``. + """ + + x: np.ndarray + y: np.ndarray + label: str | None = None + + +@dataclass(frozen=True, eq=False) +class Mesh: + """A rectangular grid of values, coloured by magnitude — a heatmap. + + Attributes: + x (numpy.ndarray): Column positions, of length ``values.shape[1]``. + y (numpy.ndarray): Row positions, of length ``values.shape[0]``. + values (numpy.ndarray): The grid itself, indexed ``[row, column]`` — the layout + `xarray.DataArray.values` gives for dimensions ordered ``(y, x)``. + label (str | None): Colour-bar label, or ``None``. + """ + + x: np.ndarray + y: np.ndarray + values: np.ndarray + label: str | None = None + + +Mark: TypeAlias = Line | Points | Mesh +"""Any one of the three shapes a figure is built from.""" + + +@dataclass(frozen=True, eq=False) +class Twin: + """A second scale along one axis, reading the same samples as another variable. + + This is what a parallel composition leaves behind. Its loops advance in lockstep, so the + variables it composes share one dimension and one set of samples, and sample *i* of the axis and + tick *i* of the twin are the same measurement. That is what makes the second scale a fact about + the data rather than a coincidence of two ranges, and it is why nothing here needs interpolating. + + Attributes: + positions (numpy.ndarray): Where the samples sit along the axis this twin doubles, in that + axis's own drawn numbers — so a renderer places a tick without knowing which axis it is + or what it was restated by. + values (numpy.ndarray): What the twinned variable read at those same samples, in the same + order. Same length as ``positions``. + label (str): Text for the twin axis, already carrying its units. + """ + + positions: np.ndarray + values: np.ndarray + label: str + + +@dataclass(frozen=True, eq=False) +class Figure: + """A set of marks sharing one pair of axes. + + Attributes: + marks (tuple[Mark, ...]): What to draw, in drawing order. A renderer dispatches on the type + of each one, so a figure may mix a [`Mesh`][qprogram.plotting.Mesh] with the + [`Line`][qprogram.plotting.Line] s drawn over it. + x_label (str): Text for the x axis, already carrying its units. + y_label (str): Text for the y axis, already carrying its units. + title (str | None): Title above the axes, or ``None`` for none. + x_twin (Twin | None): A second scale to draw opposite the x axis, or ``None`` for none. A + renderer with nowhere to put one may ignore it: it repeats what the x axis already + shows, in another variable. + y_twin (Twin | None): The same for the y axis. + """ + + marks: tuple[Mark, ...] + x_label: str + y_label: str + title: str | None = None + x_twin: Twin | None = None + y_twin: Twin | None = None diff --git a/src/qprogram/plotting/quantity.py b/src/qprogram/plotting/quantity.py new file mode 100644 index 0000000..f6c2387 --- /dev/null +++ b/src/qprogram/plotting/quantity.py @@ -0,0 +1,292 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Restating a quantity for a figure: the numbers and the words that name them, held together. + +A result carries hertz because the instrument takes hertz, and the figure of it wants gigahertz. +That is two changes at once — arithmetic on the numbers and a new unit on the axis — and doing +either without the other is how an axis comes to read ``(Hz)`` over values running 4.6 to 5.4. +[`Quantity`][qprogram.plotting.Quantity] carries the pair, which is what lets the builder refuse the +half-done version. + +The module imports numpy and the error hierarchy and nothing else, so it stays on the numeric side +of the seam that [`build_figure`][qprogram.plotting.build_figure] sits on. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +from qprogram.errors import ValidationError + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class Quantity: + """One quantity restated: what to call it, what unit to read it in, and how to get there. + + A ``Quantity`` describes presentation only. It never touches the array, so + ``data.coords["freq"]`` is still in hertz after the figure of it has been drawn in gigahertz. + The two sides part company there: the drawn numbers moved, so anything handed to the axes + afterwards is in the figure's units, and a frequency read back off the array needs the same + arithmetic the figure got. + + One rule is enforced, in both directions: **a change of unit and a change of numbers travel + together.** A ``transform`` over values that carry a unit must say what the unit is now, and a + ``units`` that contradicts the one already there must come with the arithmetic that earns it. + Either half alone would produce an axis whose numbers and label disagree, which is the + plausible-looking wrong figure this type exists to prevent. What cannot be checked is whether + the arithmetic matches the unit: ``Quantity(units="GHz", transform=lambda v: v / 1e6)`` is a lie + no check here can catch, because ``Variable.units`` is free-form text and legitimately holds + ``"arb"``, ``"counts"`` and ``"shots"``. + + Attributes: + label (str | None): Name for the quantity, replacing the coordinate's ``long_name`` or the + name the channel implies. ``None`` keeps the inherited one. + units (str | None): Unit to read the numbers in, replacing the coordinate's ``units`` + attribute. ``None`` keeps the inherited one, and ``""`` says the numbers now carry no + unit at all — a ratio, a normalised population — which over values that arrived with a + unit is a change like any other and comes with the ``transform`` that made them one. + transform (Callable[[numpy.ndarray], numpy.ndarray] | None): Arithmetic on the values, + called with the whole array that would otherwise have been drawn and returning one real + number per value. It gets a copy, so an in-place transform cannot reach the stored + result, and it is called once per drawn series, so write it pure. + + Raises: + ValidationError: If all three are ``None``, so the object restates nothing; if ``label`` or + ``units`` is not a string; or if ``transform`` is not callable. + """ + + label: str | None = None + units: str | None = None + transform: Callable[[np.ndarray], np.ndarray] | None = None + + def __post_init__(self) -> None: + """Reject a ``Quantity`` that could not restate anything, before any data exists. + + Raises: + ValidationError: As documented on the class. + """ + if self.label is None and self.units is None and self.transform is None: + msg = ( + "Quantity() restates nothing. Give it a label=, units=, a transform=, or some of " + "the three; leave the argument out entirely to keep what the array already says." + ) + raise ValidationError(msg) + for name, value in (("label", self.label), ("units", self.units)): + if value is not None and not isinstance(value, str): + msg = f"Quantity {name} must be a string, got {type(value).__name__}" + raise ValidationError(msg) + if self.transform is not None and not callable(self.transform): + msg = ( + f"Quantity transform must be callable, got {type(self.transform).__name__}. It is " + f"the arithmetic that restates the values, so write it as a function of the array." + ) + raise ValidationError(msg) + + +def checked(value: object, where: str) -> Quantity | None: + """Return ``value`` if it is a [`Quantity`][qprogram.plotting.Quantity] or ``None``, and raise otherwise. + + The gate runs before anything reads a field off the argument, so a wrong type gives the message + below rather than an ``AttributeError`` from somewhere further in. + + Args: + value (object): Whatever the caller passed. + where (str): How to name the argument in the error, e.g. ``"value="`` or ``"coords['freq']"``. + + Returns: + The quantity, or ``None``. + + Raises: + ValidationError: If ``value`` is anything else. + """ + if value is None or isinstance(value, Quantity): + return value + if isinstance(value, str): + hint = ( + f"A bare string names the quantity without saying what unit to read it in; write it as Quantity({value!r})." + ) + elif callable(value): + hint = ( + "A bare function rescales the numbers without saying what they are now, which is how " + "an axis comes to read '(Hz)' over gigahertz; wrap it as " + "Quantity(units='GHz', transform=f)." + ) + else: + hint = "Wrap it as Quantity(label=..., units=..., transform=...)." + msg = f"{where} must be a Quantity, got {type(value).__name__}. {hint}" + raise ValidationError(msg) + + +def restated(quantity: Quantity | None, values: np.ndarray, where: str) -> np.ndarray: + """Run one transform over an array and check what came back. + + A transform is handed a copy: the arrays reaching here share memory with the stored result, and + the ordinary numpy spelling of a baseline (``v -= v[0]``) would otherwise rewrite the + measurement the figure is of. Where there is no transform there is nothing to guard against and + ``values`` is handed back as it stands, so the copy is the transform's, not the return value's. + + What is checked is the transform's shape and dtype, and whether it turned a finite value into a + non-finite one. A value the measurement itself carries as NaN — the executor writes one into a + grid point a conditional arm never reached — passes through untouched. + + Args: + quantity (Quantity | None): The restatement, or ``None`` for none. + values (numpy.ndarray): The numbers to restate. + where (str): How to name the argument that carried it, in any error. + + Returns: + The restated numbers, or ``values`` itself when there is no transform. + + Raises: + ValidationError: If the transform raises, returns a different shape, returns something other + than real numbers, or introduces a non-finite value. + """ + if quantity is None or quantity.transform is None: + return values + try: + restated_values = np.asarray(quantity.transform(np.array(values, copy=True))) + except Exception as exc: + msg = ( + f"{where} raised {type(exc).__name__}: {exc}. It is called once with a numpy array of " + f"shape {values.shape} and must return one number per value, so write it as arithmetic " + f"over the whole array rather than over a single point." + ) + raise ValidationError(msg) from exc + if restated_values.shape != values.shape: + msg = ( + f"{where} returned shape {restated_values.shape} for an input of shape {values.shape}. " + f"A transform restates each value in place; select points with data.sel() before " + f"plotting rather than inside it." + ) + raise ValidationError(msg) + if not _is_real(restated_values): + msg = ( + f"{where} returned dtype {restated_values.dtype}, and an axis is drawn on real numbers. " + f"Return the restated values themselves — numpy.abs(v) or v.real for a complex " + f"result — and use label= for the words." + ) + raise ValidationError(msg) + _check_finite(values, restated_values, where) + return restated_values + + +def text(quantity: Quantity | None, label: str, units: str | None, where: str) -> str: + """Compose the text for one quantity, with the restatement applied. + + This holds the rule the type exists for: a change of unit and a change of numbers travel + together. It fires only where there is a claim to falsify — a non-empty inherited unit — so a + coordinate that declared none, or a demodulated magnitude that has none to declare, takes a bare + transform or a bare ``units`` without complaint. Over values that did come with a unit, ``""`` + is a restatement like any other and needs the arithmetic that earns it: an axis whose numbers + are still hertz reads as dimensionless once the unit is dropped from under them. + + Args: + quantity (Quantity | None): The restatement, or ``None`` for none. + label (str): The inherited name. + units (str | None): The inherited unit, or ``None`` for none. + where (str): How to name the argument that carried it, in any error. + + Returns: + ``"Label (unit)"``, or the label alone when there is no unit. + + Raises: + ValidationError: If a transform rescales values whose inherited unit was not restated with + it, or if a unit contradicts the inherited one with no arithmetic to earn the change. + """ + if quantity is None: + return _joined(label, units) + if quantity.units is None: + if quantity.transform is not None and units: + msg = ( + f"{where} rescales the values, so the inherited unit {units!r} no longer describes " + f"them and the axis would read ({units}) over numbers that are not in {units}. Pass " + f"units= for what they are now, units='' if they are now a bare ratio, or " + f"units={units!r} if the transform leaves the unit alone, as subtracting a baseline " + f"does." + ) + raise ValidationError(msg) + return _joined(quantity.label if quantity.label is not None else label, units) + if quantity.transform is None and units and quantity.units != units: + reads = f"read ({quantity.units})" if quantity.units else "carry no unit at all" + msg = ( + f"{where} restates the unit from {units!r} to {quantity.units!r} without changing the " + f"numbers, so the axis would {reads} over values still in {units}. Pass the transform= " + f"that converts them, or correct the unit on the variable the coordinate came from." + ) + raise ValidationError(msg) + return _joined(quantity.label if quantity.label is not None else label, quantity.units) + + +def _joined(label: str, units: str | None) -> str: + """Put a label and a unit together the way an axis reads them. + + Args: + label (str): The quantity's name. + units (str | None): Its unit, or ``None`` or ``""`` for none. + + Returns: + ``"Label (unit)"``, or the label alone. + """ + return f"{label} ({units})" if units else label + + +def _is_real(values: np.ndarray) -> bool: + """Report whether an array holds real numbers, which is what an axis is drawn on. + + Args: + values (numpy.ndarray): The array to inspect. + + Returns: + ``True`` for a real numeric dtype, ``False`` for complex, text, or anything else. + """ + return np.issubdtype(values.dtype, np.number) and not np.issubdtype(values.dtype, np.complexfloating) + + +def _check_finite(values: np.ndarray, restated_values: np.ndarray, where: str) -> None: + """Raise when the transform turned a finite value non-finite. + + The comparison is elementwise and against the input, so a NaN the measurement already carried is + not blamed on the transform, and one pre-existing NaN does not disable the check for the rest of + the array. It is skipped when the input is not real numbers, since `numpy.isfinite` has + nothing to say about a text coordinate. + + Args: + values (numpy.ndarray): What the transform was given. + restated_values (numpy.ndarray): What it returned. + where (str): How to name the argument that carried it, in the error. + + Raises: + ValidationError: If any value that was finite no longer is. + """ + if not _is_real(values): + return + introduced = np.isfinite(values) & ~np.isfinite(restated_values) + if not introduced.any(): + return + first = int(np.flatnonzero(introduced.reshape(-1))[0]) + before = values.reshape(-1)[first] + after = restated_values.reshape(-1)[first] + msg = ( + f"{where} turned {int(introduced.sum())} of {int(np.isfinite(values).sum())} finite values " + f"into inf or nan, the first at index {first} of the flattened array ({before} became " + f"{after}). Check for a division by zero or a logarithm of a non-positive value, and clip " + f"or shift the input first if the transform has a domain." + ) + raise ValidationError(msg) diff --git a/src/qprogram/plotting/renderers.py b/src/qprogram/plotting/renderers.py new file mode 100644 index 0000000..7ee348b --- /dev/null +++ b/src/qprogram/plotting/renderers.py @@ -0,0 +1,128 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The registry that decides who draws a [`Figure`][qprogram.plotting.Figure]. + +A renderer is any callable taking a figure, a [`Style`][qprogram.plotting.Style], and an optional surface to draw on. +What it returns is its own business — the matplotlib one hands back the `Axes` it drew, +which is what makes a figure composable with the rest of a notebook. + +Registration mirrors [`register_sweep_source`][qprogram.register_sweep_source]: one name, one +implementation, and re-registering the same object is a no-op while a different one under a taken +name raises. ``"matplotlib"`` is the default and is registered on first use, so importing +``qprogram`` never imports a plotting library. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from qprogram.plotting.model import Figure + from qprogram.plotting.theme import Style + +DEFAULT_RENDERER = "matplotlib" +"""The renderer used when a call names none.""" + + +class Renderer(Protocol): + """What [`register_renderer`][qprogram.plotting.register_renderer] accepts: a callable that draws a figure. + + A plain function satisfies it, and so does an instance of a class with ``__call__``, which is + the way to carry per-renderer configuration. + """ + + def __call__(self, figure: Figure, style: Style, target: Any = None) -> Any: # ruff: ignore[any-type] + """Draw ``figure``. + + Args: + figure (Figure): What to draw. Marks are given in drawing order. + style (Style): The palette and the weights to draw it with. + target (Any): An existing surface to draw on — a matplotlib `Axes` for the + built-in renderer — or ``None`` to make a new one. + + Returns: + Whatever handle the backend gives back for further work. + """ + ... + + +_renderers: dict[str, Renderer] = {} + + +def register_renderer(name: str, renderer: Renderer) -> Renderer: + """Register ``renderer`` under ``name``. + + Args: + name (str): The name callers pass as ``renderer=``. + renderer (Renderer): The callable that draws a figure. + + Returns: + ``renderer``, so a class or function can be registered where it is defined. + + Raises: + ValueError: If ``name`` is already registered to a different object. Replacing a renderer + silently would change every plot in the process. + """ + existing = _renderers.get(name) + if existing is not None and existing is not renderer: + msg = f"renderer {name!r} is already registered to {existing!r}; pick another name" + raise ValueError(msg) + _renderers[name] = renderer + return renderer + + +def resolve_renderer(name: str | None = None) -> Renderer: + """Return the renderer registered under ``name``. + + Only ``None`` asks for the default. Every other value has to name something, ``""`` included: + an empty ``renderer=`` is a name that got lost on the way rather than a request for whatever is + installed, and silently drawing with matplotlib would hide that. + + Args: + name (str | None): A registered name, or ``None`` for `DEFAULT_RENDERER`. + + Returns: + The renderer to draw with. + + Raises: + KeyError: If ``name`` names no registered renderer. + ModuleNotFoundError: If the default renderer is asked for without ``matplotlib`` + installed — install ``qprogram[viz]``. + """ + if name is None: + name = DEFAULT_RENDERER + if name == DEFAULT_RENDERER and name not in _renderers: + # Imported on first use, so that `import qprogram` never pulls in matplotlib. + from qprogram.plotting import matplotlib_renderer # ruff: ignore[import-outside-top-level] + + register_renderer(DEFAULT_RENDERER, matplotlib_renderer.render) + if name not in _renderers: + # The default is listed whether or not it has registered itself yet, since it registers on + # the first call that asks for it and a reader of the message cannot see that it has not. + available = ", ".join(sorted(set(_renderers) | {DEFAULT_RENDERER})) + msg = f"No renderer named {name!r}; registered: {available}" + raise KeyError(msg) + return _renderers[name] + + +def available_renderers() -> tuple[str, ...]: + """List the renderers registered so far, in name order. + + The default is absent until something has drawn with it, because it registers itself on first + use rather than at import. + + Returns: + The registered names. + """ + return tuple(sorted(_renderers)) diff --git a/src/qprogram/plotting/theme.py b/src/qprogram/plotting/theme.py new file mode 100644 index 0000000..da5f6f6 --- /dev/null +++ b/src/qprogram/plotting/theme.py @@ -0,0 +1,118 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Colours and drawing settings, held apart from both the figure and the renderer. + +A [`Theme`][qprogram.plotting.Theme] is the palette and nothing else; a [`Style`][qprogram.plotting.Style] is a theme +plus the handful of settings that decide how heavy the marks are. Neither imports a plotting library, +so a renderer for any backend reads the same two objects. + +Two themes ship: [`LIGHT`][qprogram.plotting.LIGHT] and [`DARK`][qprogram.plotting.DARK]. Both are frozen dataclasses, +so a variant is one `dataclasses.replace` away and a whole palette of your own is a constructor call. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Theme: + """A palette: the surface a figure sits on, its text and frame, and the colours the data takes. + + Attributes: + surface (str): Background of the figure and the axes. + text (str): Titles and legend entries — the type meant to be read. + muted (str): Axis labels, tick labels, and annotations, one step back from ``text``. + grid (str): Grid lines and the axis spines, the furthest back of the three. + series (tuple[str, ...]): Categorical slots, used in order, one per series. Cycled when a + figure carries more series than the theme has slots. + ramp (tuple[str, ...]): Sequential ramp for a [`Mesh`][qprogram.plotting.Mesh], darkest-to-lightest or the + reverse, whichever runs away from ``surface``. Interpolated by the renderer. + """ + + surface: str + text: str + muted: str + grid: str + series: tuple[str, ...] + ramp: tuple[str, ...] + + +LIGHT = Theme( + surface="#ffffff", + text="#0b0b0b", + muted="#52514e", + grid="#e6e6e3", + series=("#2a78d6", "#eb6834", "#1baf7a", "#eda100"), + ramp=("#cde2fb", "#9ec5f4", "#5598e7", "#2a78d6", "#256abf", "#184f95", "#0d366b"), +) +"""The palette for a light surface: four categorical hues and a ramp that darkens away from white.""" + +DARK = Theme( + surface="#1e2129", + text="#e2e4e9", + muted="#a2a7b3", + grid="#33373f", + series=("#3987e5", "#d95926", "#199e70", "#c98500"), + ramp=("#232733", "#1d3a63", "#1c5497", "#2a78d6", "#5598e7", "#9ec5f4", "#cde2fb"), +) +"""The palette for a dark surface. The same four hues, stepped for slate rather than flipped, and a +ramp that lightens away from it — so in both themes the strongest mark is the one furthest off the +page.""" + + +@dataclass(frozen=True) +class Style: + """A theme plus the settings that decide how the marks are drawn. + + Attributes: + theme (Theme): The palette. Defaults to [`LIGHT`][qprogram.plotting.LIGHT]. + size (tuple[float, float]): Figure size in inches, ``(width, height)``. + linewidth (float): Stroke width of a [`Line`][qprogram.plotting.Line]. + markers (bool): Draw a marker at every sample of a line. Worth turning on for a coarse + sweep, where the points are the measurement and the line between them is interpolation. + markersize (float): Size of those markers. + point_size (float): Size of a [`Points`][qprogram.plotting.Points] mark. + point_alpha (float): Opacity of a [`Points`][qprogram.plotting.Points] mark, which is what keeps a few thousand + single shots readable where they pile up. + grid (bool): Draw grid lines behind the data. + legend (bool): Draw a legend when the figure has more than one labelled mark. + colorbar (bool): Draw a colour bar beside a [`Mesh`][qprogram.plotting.Mesh]. + twin_ticks (int): How many ticks a [`Twin`][qprogram.plotting.Twin] scale gets. They land on + samples rather than on round numbers, so this is the count the renderer aims for and a + sweep shorter than it gets one tick per sample; two is the floor. + """ + + theme: Theme = LIGHT + size: tuple[float, float] = (7.2, 4.0) + linewidth: float = 1.8 + markers: bool = False + markersize: float = 3.5 + point_size: float = 5.0 + point_alpha: float = 0.45 + grid: bool = True + legend: bool = True + colorbar: bool = True + twin_ticks: int = 5 + + def color(self, index: int) -> str: + """Return the categorical colour for series ``index``, cycling when the theme runs out. + + Args: + index (int): Zero-based position of the series in the figure. + + Returns: + One of the theme's `series` colours. + """ + return self.theme.series[index % len(self.theme.series)] diff --git a/src/qprogram/result.py b/src/qprogram/result.py index d50814e..69d426b 100644 --- a/src/qprogram/result.py +++ b/src/qprogram/result.py @@ -21,17 +21,54 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any from qprogram.errors import ValidationError from qprogram.operations.operation import MeasurementField +from qprogram.plotting import Quantity, Style, build_figure, resolve_renderer +from qprogram.plotting.quantity import checked if TYPE_CHECKING: + from collections.abc import Mapping + import xarray as xr from qprogram.variable import _HandleFieldAccess, _UnassignedType +# The label for a field whose meaning the executor defines. ``iq`` and ``raw`` have none: what a +# demodulated point means is the readout chain's business, so their label comes from the channel. +_FIELD_LABELS = {MeasurementField.STATE.value: "State"} + + +def _field_value(value: object, field: str, kind: str | None) -> Quantity | None: + """Gate the caller's ``value=``, then fill in the label the field implies. + + The gate runs first and unconditionally: reading a field off an unchecked argument is how + ``value="Excited population"`` would become a silently discarded argument rather than an error + naming the constructor to wrap it in. A scatter takes no label at all, so the field's is not + filled in there either. + + Args: + value (object): Whatever the caller passed for ``value=``. + field (str): The measurement field being drawn. + kind (str | None): The figure kind the caller asked for, if any. + + Returns: + The [`Quantity`][qprogram.plotting.Quantity] to hand + [`build_figure`][qprogram.plotting.build_figure], or ``None``. + + Raises: + ValidationError: If ``value`` is neither a [`Quantity`][qprogram.plotting.Quantity] nor ``None``. + """ + quantity = checked(value, "value=") + label = _FIELD_LABELS.get(field) + if label is None or kind == "scatter": + return quantity + if quantity is None: + return Quantity(label=label) + return quantity if quantity.label is not None else replace(quantity, label=label) + class MeasurementHandle: """A reference to a measurement performed by a [`QProgram`][qprogram.QProgram]. @@ -159,7 +196,7 @@ class QProgramResult: Results are stored in construction order and addressable by handle, by name string, or by integer position via `get`, which returns the `IQ` field unless a - different one is named. + different one is named. `plot` takes the same arguments and draws what it finds. """ def __init__(self) -> None: @@ -265,6 +302,97 @@ def get( raise KeyError(msg) return record.fields[name] + def plot( # ruff: ignore[too-many-arguments] # every argument is one decision about the figure + self, + measurement: MeasurementHandle | str | int = 0, + bus: str | None = None, + field: MeasurementField | str = MeasurementField.IQ, + *, + kind: str | None = None, + x: str | None = None, + y: str | None = None, + channels: str | None = None, + coords: Mapping[str, Quantity] | None = None, + value: Quantity | None = None, + title: str | None = None, + style: Style | None = None, + renderer: str | None = None, + target: object = None, + ) -> Any: # ruff: ignore[any-type] # whatever handle the renderer gives back + """Draw one measurement field. + + The array is looked up exactly as `get` looks it up, described as a + [`Figure`][qprogram.plotting.Figure] by [`build_figure`][qprogram.plotting.build_figure], and handed to a + renderer. The default renderer is matplotlib, from the ``viz`` extra, and it returns the + `Axes` it drew on, so anything the figure does not decide — a limit, an + annotation, a second series from elsewhere — is a call away on the object that comes back. + + The shape of the array chooses the figure. One dimension besides ``"IQ"`` gives a line per + quadrature, two give a heatmap of the magnitude, and ``kind="scatter"`` plots I against Q, + which no shape implies on its own. A swept variable's ``label`` and ``units`` reach the axis + from the coordinate the executor wrote them onto, and a dimension a parallel composition + built brings two of them, the second read on a twin axis opposite the first. + + Args: + measurement (MeasurementHandle | str | int): Which measurement to draw, by handle, by + name, or by position, exactly as in `get`. + bus (str | None): Bus name filter, applied before that lookup. + field (MeasurementField | str): Which measurement field to draw. Defaults to + `IQ`. + kind (str | None): ``"line"``, ``"heatmap"`` or ``"scatter"``. Inferred from the shape + when omitted. + x (str | None): Dimension or coordinate for the x axis, drawn on its own. A dimension a + parallel composition built needs no such argument: its loops advanced in lockstep, + so its first two coordinates go on the axis and opposite it as a + [`Twin`][qprogram.plotting.Twin] scale, in the order the dimension name gives them. + Naming one here is how a bare axis is asked for instead. + y (str | None): The same for the y axis of a heatmap. + channels (str | None): What to make of the ``"IQ"`` dimension — ``"iq"``, ``"i"``, + ``"q"``, ``"magnitude"`` or ``"phase"``. Defaults to both quadratures for a line and + to the magnitude for a heatmap, which colours one surface. + coords (collections.abc.Mapping[str, Quantity] | None): Restatements for the swept + coordinates, keyed by the name each axis or twin resolved to — the same string + ``x=`` takes. + A [`Quantity`][qprogram.plotting.Quantity] carries the arithmetic and the words it + produces together, so ``{"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}`` + draws the axis in gigahertz and labels it so. The array itself is untouched. + value (Quantity | None): The same for the measured quantity — the y axis of a line, the + colour bar of a heatmap, both axes of a scatter. Its label defaults to what the + field and the channel imply, since what a demodulated point means is the readout + chain's business and not the executor's. + title (str | None): Title for the figure. None by default. + style (Style | None): Palette and drawing weights. Defaults to + [`Style`][qprogram.plotting.Style]``()``, which is the light theme. + renderer (str | None): A name passed to + [`resolve_renderer`][qprogram.plotting.resolve_renderer]. Defaults to ``"matplotlib"``. + target (object): An existing surface for the renderer to draw on — a matplotlib + `Axes` for the default one. A new figure is made when omitted. + + Returns: + Whatever the renderer returns: the `Axes` for the matplotlib one. + + Raises: + KeyError: When the measurement or the field has no match, or ``renderer`` names none. + IndexError: When ``measurement`` is a position outside the range in scope. + ValidationError: When an argument does not suit the array's shape, or a restatement + changes the numbers without the unit — see + [`build_figure`][qprogram.plotting.build_figure]. + ModuleNotFoundError: When the matplotlib renderer is used without matplotlib + installed — install ``qprogram[viz]``. + """ + data = self.get(measurement, bus=bus, field=field) + figure = build_figure( + data, + kind=kind, + x=x, + y=y, + channels=channels, + coords=coords, + value=_field_value(value, str(field), kind), + title=title, + ) + return resolve_renderer(renderer)(figure, style or Style(), target) + @staticmethod def _lookup_by_name( candidates: list[MeasurementResult], diff --git a/tests/test_plotting.py b/tests/test_plotting.py new file mode 100644 index 0000000..96b4862 --- /dev/null +++ b/tests/test_plotting.py @@ -0,0 +1,1255 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for qprogram.plotting: the figure model, the builder, and the renderers. + +The builder half needs no display and no matplotlib: it turns an array into a description, and the +assertions read that description. The renderer half draws for real, on the Agg backend. +""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +import pytest +import xarray as xr + +import qprogram as qp +from qprogram import ValidationError +from qprogram.plotting import ( + DARK, + LIGHT, + Figure, + Line, + Mesh, + Points, + Quantity, + Style, + Theme, + available_renderers, + build_figure, + matplotlib_renderer, + register_renderer, + resolve_renderer, +) + +mpl.use("Agg") + + +@pytest.fixture(autouse=True) +def _close_figures(): + """Close every figure a test opened, so the suite never trips matplotlib's open-figure warning.""" + yield + plt.close("all") + + +# --------------------------------------------------------------------------- +# Arrays shaped like the executor's output +# --------------------------------------------------------------------------- + + +def _sweep_iq(n: int = 5, *, attrs: dict[str, str] | None = None) -> xr.DataArray: + """One sweep dimension named ``gain`` plus ``IQ``, the shape of a 1-D Rabi result.""" + gain = xr.DataArray(np.linspace(0.0, 1.0, n), dims="gain", attrs=attrs or {}) + values = np.stack([np.arange(n, dtype=float), np.arange(n, dtype=float) * 2], axis=-1) + return xr.DataArray(values, dims=("gain", "IQ"), coords={"gain": gain, "IQ": ["I", "Q"]}) + + +def _grid_iq() -> xr.DataArray: + """Two sweep dimensions plus ``IQ``, the shape of a chevron result.""" + values = np.arange(2 * 3 * 2, dtype=float).reshape(2, 3, 2) + return xr.DataArray( + values, + dims=("amp", "dur", "IQ"), + coords={ + "amp": xr.DataArray([0.0, 1.0], dims="amp", attrs={"long_name": "Flux amplitude", "units": "V"}), + "dur": xr.DataArray([10, 20, 30], dims="dur", attrs={"units": "ns"}), + "IQ": ["I", "Q"], + }, + ) + + +def _state() -> xr.DataArray: + """A ``state`` field over one sweep: no ``IQ`` dimension at all.""" + return xr.DataArray( + np.array([0.0, 0.5, 1.0]), + dims="tau", + coords={"tau": xr.DataArray([1.0, 2.0, 3.0], dims="tau", attrs={"long_name": "Spacing"})}, + ) + + +def _composed() -> xr.DataArray: + """A parallel composition: one dimension, one coordinate per composed variable, no index.""" + return xr.DataArray( + np.arange(4 * 2, dtype=float).reshape(4, 2), + dims=("a|b", "IQ"), + coords={ + "a": xr.DataArray([0.0, 1.0, 2.0, 3.0], dims="a|b", attrs={"long_name": "Gain", "units": "V"}), + "b": xr.DataArray([10.0, 20.0, 30.0, 40.0], dims="a|b", attrs={"units": "ns"}), + "IQ": ["I", "Q"], + }, + ) + + +# --------------------------------------------------------------------------- +# Kind inference +# --------------------------------------------------------------------------- + + +def test_one_dimension_besides_iq_gives_lines(): + figure = build_figure(_sweep_iq()) + assert [type(mark) for mark in figure.marks] == [Line, Line] + assert [mark.label for mark in figure.marks] == ["I", "Q"] + + +def test_two_dimensions_besides_iq_give_a_heatmap(): + figure = build_figure(_grid_iq()) + assert [type(mark) for mark in figure.marks] == [Mesh] + + +def test_time_counts_as_a_plot_dimension(): + # A raw trace with no sweep is (time, IQ): one plot dimension, so it draws against time. + raw = xr.DataArray( + np.zeros((8, 2)), + dims=("time", "IQ"), + coords={"time": np.arange(8), "IQ": ["I", "Q"]}, + ) + figure = build_figure(raw) + assert [type(mark) for mark in figure.marks] == [Line, Line] + assert figure.x_label == "time" + + +def test_a_single_point_has_nothing_to_plot(): + scalar = xr.DataArray(np.zeros(2), dims="IQ", coords={"IQ": ["I", "Q"]}) + with pytest.raises(ValidationError, match="single measured point"): + build_figure(scalar) + + +def test_three_dimensions_cannot_be_inferred(): + cube = xr.DataArray(np.zeros((2, 2, 2, 2)), dims=("a", "b", "c", "IQ")) + with pytest.raises(ValidationError, match="more than a line or a heatmap"): + build_figure(cube) + + +def test_unknown_kind_is_rejected(): + data = _sweep_iq() + with pytest.raises(ValidationError, match="kind must be one of"): + build_figure(data, kind="bar") + + +def test_line_rejects_a_two_dimensional_array(): + data = _grid_iq() + with pytest.raises(ValidationError, match="exactly one dimension besides 'IQ'"): + build_figure(data, kind="line") + + +def test_heatmap_rejects_a_one_dimensional_array(): + data = _sweep_iq() + with pytest.raises(ValidationError, match="exactly two dimensions besides 'IQ'"): + build_figure(data, kind="heatmap") + + +# --------------------------------------------------------------------------- +# Axes and their labels +# --------------------------------------------------------------------------- + + +def test_coordinate_attributes_become_the_axis_label(): + data = _sweep_iq(attrs={"long_name": "Drive amplitude", "units": "V"}) + assert build_figure(data).x_label == "Drive amplitude (V)" + + +def test_a_label_without_units_stands_alone(): + data = _sweep_iq(attrs={"long_name": "Drive amplitude"}) + assert build_figure(data).x_label == "Drive amplitude" + + +def test_units_without_a_label_fall_back_to_the_dimension_name(): + data = _sweep_iq(attrs={"units": "V"}) + assert build_figure(data).x_label == "gain (V)" + + +def test_a_bare_coordinate_labels_itself_with_the_variable_id(): + assert build_figure(_sweep_iq()).x_label == "gain" + + +def test_line_x_values_come_from_the_coordinate(): + figure = build_figure(_sweep_iq(n=4)) + assert np.allclose(figure.marks[0].x, [0.0, 1.0 / 3, 2.0 / 3, 1.0]) + + +# --------------------------------------------------------------------------- +# Composed sweep dimensions +# --------------------------------------------------------------------------- + + +def test_a_composed_dimension_draws_its_first_coordinate_and_twins_the_second(): + figure = build_figure(_composed()) + assert figure.x_label == "Gain (V)" + assert np.allclose(figure.marks[0].x, [0.0, 1.0, 2.0, 3.0]) + assert figure.x_twin.label == "b (ns)" + assert np.allclose(figure.x_twin.values, [10.0, 20.0, 30.0, 40.0]) + + +def test_a_twin_carries_the_positions_of_the_axis_it_doubles(): + figure = build_figure(_composed()) + assert np.allclose(figure.x_twin.positions, figure.marks[0].x) + + +def test_the_dimension_name_orders_the_axis_and_its_twin(): + data = _composed().rename({"a|b": "b|a"}) + figure = build_figure(data) + assert (figure.x_label, figure.x_twin.label) == ("b (ns)", "Gain (V)") + assert np.allclose(figure.marks[0].x, [10.0, 20.0, 30.0, 40.0]) + + +def test_a_composition_of_three_draws_the_first_two(): + data = _composed().assign_coords(c=xr.DataArray([7.0, 8.0, 9.0, 10.0], dims="a|b")).rename({"a|b": "a|b|c"}) + figure = build_figure(data) + assert (figure.x_label, figure.x_twin.label) == ("Gain (V)", "b (ns)") + + +def test_x_names_one_of_the_composed_coordinates_and_leaves_no_twin(): + figure = build_figure(_composed(), x="a") + assert figure.x_label == "Gain (V)" + assert np.allclose(figure.marks[0].x, [0.0, 1.0, 2.0, 3.0]) + assert figure.x_twin is None + + +def test_x_can_name_the_second_composed_coordinate(): + figure = build_figure(_composed(), x="b") + assert figure.x_label == "b (ns)" + assert np.allclose(figure.marks[0].x, [10.0, 20.0, 30.0, 40.0]) + assert figure.x_twin is None + + +def test_naming_the_composed_dimension_plots_the_sweep_index(): + figure = build_figure(_composed(), x="a|b") + assert figure.x_label == "a|b" + assert np.allclose(figure.marks[0].x, [0, 1, 2, 3]) + assert figure.x_twin is None + + +def test_an_ordinary_sweep_has_no_twin(): + figure = build_figure(_sweep_iq()) + assert figure.x_twin is None + assert figure.y_twin is None + + +def test_a_twin_is_restated_by_its_own_key(): + figure = build_figure(_composed(), coords={"b": Quantity(units="us", transform=lambda v: v / 1e3)}) + assert figure.x_twin.label == "b (us)" + assert np.allclose(figure.x_twin.values, [0.01, 0.02, 0.03, 0.04]) + + +def test_a_twin_carries_the_restated_positions_of_its_axis(): + figure = build_figure(_composed(), coords={"a": Quantity(units="mV", transform=lambda v: v * 1e3)}) + assert np.allclose(figure.x_twin.positions, [0.0, 1000.0, 2000.0, 3000.0]) + + +def test_the_third_of_a_composition_is_told_it_is_not_drawn(): + data = _composed().assign_coords(c=xr.DataArray([7.0, 8.0, 9.0, 10.0], dims="a|b")).rename({"a|b": "a|b|c"}) + coords = {"c": Quantity("C")} + with pytest.raises(ValidationError, match=r"'c'\] names a coordinate this figure does not draw"): + build_figure(data, coords=coords) + + +def test_a_twin_is_named_as_a_twin_when_a_key_reaches_nothing(): + data = _composed() + coords = {"nope": Quantity("X")} + with pytest.raises(ValidationError, match=r"'a' \(the x axis\), 'b' \(the twin of the x axis\)"): + build_figure(data, coords=coords) + + +def test_a_heatmap_twins_both_of_its_axes(): + values = np.arange(4 * 3 * 2, dtype=float).reshape(4, 3, 2) + data = xr.DataArray( + values, + dims=("a|b", "c|d", "IQ"), + coords={ + "a": xr.DataArray([0.0, 1.0, 2.0, 3.0], dims="a|b"), + "b": xr.DataArray([10.0, 20.0, 30.0, 40.0], dims="a|b"), + "c": xr.DataArray([0.0, 0.5, 1.0], dims="c|d"), + "d": xr.DataArray([100.0, 200.0, 300.0], dims="c|d"), + "IQ": ["I", "Q"], + }, + ) + figure = build_figure(data) + # The inner dimension runs along x, so 'c|d' is across and 'a|b' is up. + assert (figure.x_label, figure.x_twin.label) == ("c", "d") + assert (figure.y_label, figure.y_twin.label) == ("a", "b") + + +def test_an_unknown_x_names_what_is_available(): + data = _composed() + with pytest.raises(ValidationError, match="Coordinates: a, b, IQ"): + build_figure(data, x="nope") + + +def test_a_lone_coordinate_on_a_bare_dimension_needs_no_choosing(): + data = xr.DataArray( + np.zeros((3, 2)), + dims=("a|", "IQ"), + coords={"a": xr.DataArray([1.0, 2.0, 3.0], dims="a|"), "IQ": ["I", "Q"]}, + ) + assert build_figure(data).x_label == "a" + + +def test_a_coordinate_belonging_to_another_dimension_is_not_this_axis_s(): + # xarray allows a coordinate named after one dimension to live on a second, and the executor + # builds exactly that when one variable is swept at two nesting levels. Taking the name at face + # value would draw the other dimension's values under this one's label. + data = xr.DataArray( + np.arange(12.0).reshape(3, 4), + dims=("a", "b"), + coords={"a": ("b", np.arange(4.0))}, + ) + mesh = build_figure(data, x="b", y="a").marks[0] + assert len(mesh.y) == data.sizes["a"] + assert np.allclose(mesh.y, [0, 1, 2]) + assert mesh.values.shape == (len(mesh.y), len(mesh.x)) + + +def test_a_dimension_with_no_coordinate_at_all_plots_its_index(): + data = xr.DataArray(np.zeros((3, 2)), dims=("shot", "IQ"), coords={"IQ": ["I", "Q"]}) + figure = build_figure(data) + assert figure.x_label == "shot" + assert np.allclose(figure.marks[0].x, [0, 1, 2]) + + +# --------------------------------------------------------------------------- +# Channels +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("channels", "labels"), + [ + ("iq", ["I", "Q"]), + ("i", ["I"]), + ("q", ["Q"]), + ("magnitude", ["Magnitude"]), + ("phase", ["Phase"]), + ], +) +def test_each_channel_draws_its_own_series(channels, labels): + figure = build_figure(_sweep_iq(), channels=channels) + assert [mark.label for mark in figure.marks] == labels + + +def test_magnitude_is_the_hypotenuse(): + data = _sweep_iq(n=3) + figure = build_figure(data, channels="magnitude") + assert np.allclose(figure.marks[0].y, np.hypot(data.sel(IQ="I"), data.sel(IQ="Q"))) + + +def test_phase_is_the_arctangent(): + data = _sweep_iq(n=3) + figure = build_figure(data, channels="phase") + assert np.allclose(figure.marks[0].y, np.arctan2(data.sel(IQ="Q"), data.sel(IQ="I"))) + + +def test_unknown_channels_are_rejected(): + data = _sweep_iq() + with pytest.raises(ValidationError, match="channels must be one of"): + build_figure(data, channels="abs") + + +@pytest.mark.parametrize("channels", ["iq", "i", "q", "magnitude", "phase"]) +def test_every_channel_needs_quadratures_to_choose_between(channels): + data = _state() + with pytest.raises(ValidationError, match="needs an 'IQ' dimension"): + build_figure(data, channels=channels) + + +def test_an_array_without_quadratures_draws_one_unnamed_line(): + figure = build_figure(_state()) + assert [mark.label for mark in figure.marks] == [None] + assert figure.y_label == "Value" + + +def test_a_derived_channel_names_the_quantity_it_derived(): + # The array names the values that went in; an arctangent of them is a different quantity, so + # its own name travels with its own unit rather than borrowing half of each. + data = _sweep_iq() + data.attrs = {"long_name": "Readout voltage", "units": "V"} + assert build_figure(data, channels="phase").y_label == "Phase (rad)" + assert build_figure(data, channels="magnitude").y_label == "Readout voltage (V)" + + +# --------------------------------------------------------------------------- +# Heatmaps +# --------------------------------------------------------------------------- + + +def test_the_innermost_sweep_runs_along_the_x_axis(): + figure = build_figure(_grid_iq()) + assert figure.x_label == "dur (ns)" + assert figure.y_label == "Flux amplitude (V)" + + +def test_x_swaps_the_two_axes(): + figure = build_figure(_grid_iq(), x="amp") + assert figure.x_label == "Flux amplitude (V)" + assert figure.y_label == "dur (ns)" + + +def test_y_alone_settles_both_axes(): + figure = build_figure(_grid_iq(), y="dur") + assert figure.x_label == "Flux amplitude (V)" + assert figure.y_label == "dur (ns)" + + +def test_x_and_y_cannot_name_the_same_dimension(): + data = _grid_iq() + with pytest.raises(ValidationError, match="a heatmap needs one on each axis"): + build_figure(data, x="amp", y="amp") + + +def test_the_grid_is_indexed_row_by_column(): + data = _grid_iq() + mesh = build_figure(data, channels="i").marks[0] + assert mesh.values.shape == (len(mesh.y), len(mesh.x)) + assert np.allclose(mesh.values, data.sel(IQ="I").transpose("amp", "dur").values) + + +def test_a_swapped_heatmap_transposes_its_grid(): + data = _grid_iq() + mesh = build_figure(data, x="amp", channels="i").marks[0] + assert np.allclose(mesh.values, data.sel(IQ="I").transpose("dur", "amp").values) + + +def test_a_heatmap_defaults_to_the_magnitude(): + data = _grid_iq() + mesh = build_figure(data).marks[0] + assert mesh.label == "Magnitude" + assert np.allclose(mesh.values, np.hypot(data.sel(IQ="I"), data.sel(IQ="Q")).transpose("amp", "dur")) + + +def test_a_heatmap_cannot_colour_both_quadratures(): + data = _grid_iq() + with pytest.raises(ValidationError, match="colours one surface"): + build_figure(data, channels="iq") + + +def test_a_heatmap_without_quadratures_needs_no_channel(): + data = xr.DataArray(np.zeros((2, 3)), dims=("a", "b")) + mesh = build_figure(data).marks[0] + assert isinstance(mesh, Mesh) + assert mesh.label == "Value" + + +# --------------------------------------------------------------------------- +# Scatter +# --------------------------------------------------------------------------- + + +def test_scatter_is_never_inferred(): + assert [type(mark) for mark in build_figure(_sweep_iq()).marks] == [Line, Line] + + +def test_scatter_puts_i_against_q(): + data = _sweep_iq(n=4) + figure = build_figure(data, kind="scatter") + (points,) = figure.marks + assert isinstance(points, Points) + assert (figure.x_label, figure.y_label) == ("I", "Q") + assert np.allclose(points.x, data.sel(IQ="I").values) + assert np.allclose(points.y, data.sel(IQ="Q").values) + + +def test_scatter_flattens_every_other_dimension(): + (points,) = build_figure(_grid_iq(), kind="scatter").marks + assert points.x.shape == (6,) + + +def test_scatter_needs_quadratures(): + data = _state() + with pytest.raises(ValidationError, match="no 'IQ' dimension"): + build_figure(data, kind="scatter") + + +@pytest.mark.parametrize("argument", ["x", "y", "channels"]) +def test_scatter_has_nothing_left_for_an_axis_argument_to_choose(argument): + data = _sweep_iq() + with pytest.raises(ValidationError, match=f"{argument} has nothing left to choose"): + build_figure(data, kind="scatter", **{argument: "i"}) + + +def test_a_line_figure_has_no_second_dimension_for_y_to_name(): + data = _sweep_iq() + with pytest.raises(ValidationError, match="only a heatmap has"): + build_figure(data, y="gain") + + +# --------------------------------------------------------------------------- +# The label on the measured quantity +# --------------------------------------------------------------------------- + + +def test_the_caller_s_label_for_the_measured_quantity_wins(): + assert build_figure(_sweep_iq(), value=Quantity("Readout response")).y_label == "Readout response" + + +def test_the_array_s_own_attributes_are_read_next(): + data = _sweep_iq() + data.attrs = {"long_name": "Transmission", "units": "dB"} + assert build_figure(data).y_label == "Transmission (dB)" + + +def test_an_attribute_without_units_stands_alone(): + data = _sweep_iq() + data.attrs = {"long_name": "Transmission"} + assert build_figure(data).y_label == "Transmission" + + +def test_the_array_name_is_read_after_that(): + assert build_figure(_sweep_iq().rename("m0")).y_label == "m0" + + +@pytest.mark.parametrize( + ("channels", "expected"), + [("iq", "Signal"), ("i", "I"), ("q", "Q"), ("magnitude", "Magnitude"), ("phase", "Phase (rad)")], +) +def test_otherwise_the_channel_says_what_the_axis_is(channels, expected): + assert build_figure(_sweep_iq(), channels=channels).y_label == expected + + +def test_a_title_is_carried_through(): + assert build_figure(_sweep_iq(), title="Rabi").title == "Rabi" + + +def test_no_title_by_default(): + assert build_figure(_sweep_iq()).title is None + + +# --------------------------------------------------------------------------- +# Quantity: construction +# --------------------------------------------------------------------------- + + +def test_a_quantity_that_restates_nothing_is_refused(): + with pytest.raises(ValidationError, match="restates nothing"): + Quantity() + + +@pytest.mark.parametrize("field", ["label", "units"]) +def test_the_words_of_a_quantity_must_be_words(field): + with pytest.raises(ValidationError, match=f"Quantity {field} must be a string"): + Quantity(**{field: 1e-9}) + + +def test_the_arithmetic_of_a_quantity_must_be_arithmetic(): + with pytest.raises(ValidationError, match="transform must be callable"): + Quantity(units="GHz", transform=1e-9) + + +def test_a_quantity_reads_positionally_as_the_sentence_the_axis_does(): + quantity = Quantity("Detuning", "MHz", abs) + assert (quantity.label, quantity.units, quantity.transform) == ("Detuning", "MHz", abs) + + +# --------------------------------------------------------------------------- +# Restating a coordinate +# --------------------------------------------------------------------------- + + +_HERTZ = np.linspace(4.6e9, 5.4e9, 5) + + +def _hertz() -> xr.DataArray: + """A frequency sweep whose coordinate declares a name and a unit, as the executor writes them.""" + freq = xr.DataArray(_HERTZ, dims="freq", attrs={"long_name": "Drive frequency", "units": "Hz"}) + values = np.stack([np.arange(5.0), np.arange(5.0) * 2], axis=-1) + return xr.DataArray(values, dims=("freq", "IQ"), coords={"freq": freq, "IQ": ["I", "Q"]}) + + +def test_a_restatement_moves_the_numbers_and_the_unit_together(): + figure = build_figure(_hertz(), coords={"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}) + assert figure.x_label == "Drive frequency (GHz)" + assert np.allclose(figure.marks[0].x, _HERTZ / 1e9) + + +def test_a_restatement_can_rename_as_well_as_rescale(): + figure = build_figure(_hertz(), coords={"freq": Quantity("Detuning", "MHz", lambda v: (v - 5e9) / 1e6)}) + assert figure.x_label == "Detuning (MHz)" + assert np.allclose(figure.marks[0].x, [-400.0, -200.0, 0.0, 200.0, 400.0]) + + +def test_a_label_alone_leaves_the_unit_it_inherited(): + assert build_figure(_hertz(), coords={"freq": Quantity("Frequency")}).x_label == "Frequency (Hz)" + + +def test_emptying_a_unit_without_the_arithmetic_is_refused(): + data = _hertz() + quantity = Quantity(units="") + with pytest.raises(ValidationError, match="carry no unit at all"): + build_figure(data, coords={"freq": quantity}) + + +def test_a_transform_that_leaves_a_bare_ratio_says_so_with_an_empty_unit(): + figure = build_figure(_hertz(), coords={"freq": Quantity(units="", transform=lambda v: v / v[-1])}) + assert figure.x_label == "Drive frequency" + assert figure.marks[0].x[-1] == 1.0 + + +def test_an_empty_unit_drops_nothing_where_the_coordinate_declared_none(): + data = _sweep_iq(attrs={"long_name": "Shot"}) + assert build_figure(data, coords={"gain": Quantity(units="")}).x_label == "Shot" + + +def test_a_unit_the_coordinate_never_declared_is_taken_as_a_correction(): + data = _sweep_iq(attrs={"long_name": "Drive amplitude"}) + assert build_figure(data, coords={"gain": Quantity(units="V")}).x_label == "Drive amplitude (V)" + + +def test_rescaling_without_saying_the_new_unit_is_refused(): + data = _hertz() + quantity = Quantity(transform=lambda v: v / 1e9) + with pytest.raises(ValidationError, match="no longer describes them"): + build_figure(data, coords={"freq": quantity}) + + +def test_renaming_the_unit_without_the_arithmetic_is_refused(): + data = _hertz() + quantity = Quantity(units="GHz") + with pytest.raises(ValidationError, match="without changing the numbers"): + build_figure(data, coords={"freq": quantity}) + + +def test_a_shift_that_keeps_its_unit_says_so_by_repeating_it(): + figure = build_figure(_hertz(), coords={"freq": Quantity(units="Hz", transform=lambda v: v - v[0])}) + assert figure.x_label == "Drive frequency (Hz)" + assert figure.marks[0].x[0] == 0.0 + + +def test_a_coordinate_with_no_unit_takes_a_bare_transform(): + data = _sweep_iq(attrs={"long_name": "Shot"}) + figure = build_figure(data, coords={"gain": Quantity(transform=lambda v: v * 2)}) + assert figure.x_label == "Shot" + + +def test_the_array_the_figure_came_from_is_left_alone(): + data = _hertz() + before = data.coords["freq"].values.copy() + build_figure(data, coords={"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}) + assert np.array_equal(data.coords["freq"].values, before) + + +def test_an_in_place_transform_cannot_reach_the_stored_result(): + data = _hertz() + before = data.coords["freq"].values.copy() + + def baseline(values): + values -= values[0] + return values + + build_figure(data, coords={"freq": Quantity(units="Hz", transform=baseline)}) + assert np.array_equal(data.coords["freq"].values, before) + + +def test_both_axes_of_a_heatmap_can_be_restated(): + figure = build_figure( + _grid_iq(), + channels="i", + coords={ + "amp": Quantity(units="mV", transform=lambda v: v * 1e3), + "dur": Quantity(units="us", transform=lambda v: v / 1e3), + }, + ) + assert (figure.x_label, figure.y_label) == ("dur (us)", "Flux amplitude (mV)") + assert np.allclose(figure.marks[0].y, [0.0, 1000.0]) + + +def test_a_composed_dimension_is_keyed_by_the_coordinate_that_won_the_axis(): + figure = build_figure(_composed(), x="a", coords={"a": Quantity(units="mV", transform=lambda v: v * 1e3)}) + assert figure.x_label == "Gain (mV)" + + +def test_the_sweep_index_of_a_composed_dimension_is_keyed_by_the_dimension(): + figure = build_figure(_composed(), x="a|b", coords={"a|b": Quantity("Sweep step")}) + assert figure.x_label == "Sweep step" + + +# --------------------------------------------------------------------------- +# A key that reaches no axis +# --------------------------------------------------------------------------- + + +def test_a_misspelled_key_names_what_the_figure_draws(): + data = _hertz() + coords = {"frequency": Quantity("Frequency")} + with pytest.raises(ValidationError, match="names nothing on this result"): + build_figure(data, coords=coords) + + +def test_the_error_lists_the_axes_with_their_roles(): + data = _grid_iq() + coords = {"nope": Quantity("X")} + with pytest.raises(ValidationError, match=r"'dur' \(the x axis\), 'amp' \(the y axis\)"): + build_figure(data, channels="i", coords=coords) + + +def test_the_error_points_at_the_argument_the_measured_quantity_uses(): + data = _hertz() + coords = {"nope": Quantity("X")} + with pytest.raises(ValidationError, match="name it with value=Quantity"): + build_figure(data, coords=coords) + + +def test_a_sibling_coordinate_that_lost_the_axis_is_told_apart_from_a_typo(): + data = _composed() + coords = {"b": Quantity("B")} + with pytest.raises(ValidationError, match=r"'b'\] names a coordinate this figure does not draw"): + build_figure(data, x="a", coords=coords) + + +def test_a_dimension_name_where_a_coordinate_is_drawn_is_told_apart_too(): + data = _composed() + coords = {"a|b": Quantity("Step")} + with pytest.raises(ValidationError, match="names a dimension, and this figure draws a coordinate"): + build_figure(data, x="a", coords=coords) + + +def test_every_unused_key_is_reported_at_once(): + data = _hertz() + coords = {"second": Quantity("B"), "first": Quantity("A")} + with pytest.raises(ValidationError, match=r"'first'.*'second'"): + build_figure(data, coords=coords) + + +# --------------------------------------------------------------------------- +# The type gates +# --------------------------------------------------------------------------- + + +def test_a_bare_function_says_what_it_is_missing(): + data = _hertz() + with pytest.raises(ValidationError, match="A bare function rescales the numbers"): + build_figure(data, coords={"freq": lambda v: v / 1e9}) + + +def test_a_bare_string_says_what_to_wrap_it_in(): + data = _sweep_iq() + with pytest.raises(ValidationError, match=r"write it as Quantity\('Readout response'\)"): + build_figure(data, value="Readout response") + + +def test_a_coords_argument_that_is_not_a_mapping_is_refused(): + data = _sweep_iq() + pairs = [("gain", Quantity("G"))] + with pytest.raises(ValidationError, match="must be a mapping"): + build_figure(data, coords=pairs) + + +# --------------------------------------------------------------------------- +# What a transform is checked for +# --------------------------------------------------------------------------- + + +def test_an_exception_inside_a_transform_names_the_argument_that_carried_it(): + data = _hertz() + quantity = Quantity(units="GHz", transform=lambda v: v[99]) + with pytest.raises(ValidationError, match=r"coords\['freq'\] raised IndexError"): + build_figure(data, coords={"freq": quantity}) + + +def test_a_transform_that_changes_the_shape_is_refused(): + data = _hertz() + quantity = Quantity(units="GHz", transform=lambda v: v[:2]) + with pytest.raises(ValidationError, match=r"returned shape \(2,\) for an input of shape \(5,\)"): + build_figure(data, coords={"freq": quantity}) + + +def test_a_transform_that_returns_complex_numbers_is_refused(): + data = _hertz() + quantity = Quantity(units="GHz", transform=lambda v: v.astype(complex)) + with pytest.raises(ValidationError, match="an axis is drawn on real numbers"): + build_figure(data, coords={"freq": quantity}) + + +def test_a_transform_that_introduces_a_non_finite_value_names_the_first_one(): + data = _hertz() + quantity = Quantity(units="GHz", transform=lambda v: np.full_like(v, np.nan)) + with pytest.raises(ValidationError, match=r"turned 5 of 5 finite values into inf or nan"): + build_figure(data, coords={"freq": quantity}) + + +def test_a_nan_the_measurement_already_carried_is_not_blamed_on_the_transform(): + data = _sweep_iq(n=4) + data.values[0, 0] = np.nan + figure = build_figure(data, channels="i", value=Quantity(transform=lambda v: v * 2)) + assert np.isnan(figure.marks[0].y[0]) + + +def test_a_coordinate_that_is_not_numbers_skips_the_finiteness_check(): + data = xr.DataArray( + np.zeros((3, 2)), + dims=("label", "IQ"), + coords={"label": ["a", "b", "c"], "IQ": ["I", "Q"]}, + ) + figure = build_figure(data, coords={"label": Quantity(transform=lambda v: np.arange(len(v), dtype=float))}) + assert np.allclose(figure.marks[0].x, [0, 1, 2]) + + +# --------------------------------------------------------------------------- +# Restating the measured quantity +# --------------------------------------------------------------------------- + + +def test_the_measured_values_of_a_line_are_restated_per_series(): + data = _sweep_iq(n=3) + figure = build_figure(data, value=Quantity("Change", transform=lambda v: v - v[0])) + assert figure.y_label == "Change" + assert [float(mark.y[0]) for mark in figure.marks] == [0.0, 0.0] + + +def test_the_coloured_values_of_a_heatmap_are_restated_with_their_bar(): + figure = build_figure( + _grid_iq(), + channels="i", + value=Quantity("Population transferred", "%", lambda v: v * 100), + ) + mesh = figure.marks[0] + assert mesh.label == "Population transferred (%)" + assert np.allclose(mesh.values, _grid_iq().sel(IQ="I").transpose("amp", "dur").values * 100) + + +def test_a_scatter_restates_both_quadratures_at_once(): + data = _sweep_iq(n=4) + figure = build_figure(data, kind="scatter", value=Quantity(units="mV", transform=lambda v: v * 1e3)) + assert (figure.x_label, figure.y_label) == ("I (mV)", "Q (mV)") + assert np.allclose(figure.marks[0].x, data.sel(IQ="I").values * 1e3) + assert np.allclose(figure.marks[0].y, data.sel(IQ="Q").values * 1e3) + + +def test_a_scatter_refuses_one_label_for_its_two_axes(): + data = _sweep_iq() + quantity = Quantity("Readout") + with pytest.raises(ValidationError, match="which already name themselves"): + build_figure(data, kind="scatter", value=quantity) + + +def test_a_scatter_draws_no_coordinate_to_restate(): + data = _sweep_iq() + coords = {"gain": Quantity("G")} + with pytest.raises(ValidationError, match="A scatter draws no coordinate"): + build_figure(data, kind="scatter", coords=coords) + + +def test_the_unit_of_a_phase_is_its_own_and_can_be_restated(): + data = _sweep_iq() + assert build_figure(data, channels="phase").y_label == "Phase (rad)" + figure = build_figure(data, channels="phase", value=Quantity(units="deg", transform=np.degrees)) + assert figure.y_label == "Phase (deg)" + + +# --------------------------------------------------------------------------- +# Themes and styles +# --------------------------------------------------------------------------- + + +def test_series_colours_cycle(): + style = Style(theme=Theme(surface="w", text="k", muted="k", grid="k", series=("a", "b"), ramp=("a",))) + assert [style.color(i) for i in range(4)] == ["a", "b", "a", "b"] + + +def test_the_default_style_is_the_light_theme(): + assert Style().theme is LIGHT + + +def test_the_two_themes_differ_where_it_matters(): + assert LIGHT.surface != DARK.surface + assert LIGHT.ramp[0] != DARK.ramp[0] + + +# --------------------------------------------------------------------------- +# The renderer registry +# --------------------------------------------------------------------------- + + +def test_the_default_renderer_is_matplotlib(): + assert resolve_renderer() is matplotlib_renderer.render + assert "matplotlib" in available_renderers() + + +def test_a_renderer_can_be_registered_and_resolved(): + def fake(figure, style, target=None): # ruff: ignore[unused-function-argument] + return "drawn" + + try: + register_renderer("test-registry", fake) + assert resolve_renderer("test-registry") is fake + assert "test-registry" in available_renderers() + finally: + qp.plotting.renderers._renderers.pop("test-registry", None) + + +def test_registering_the_same_renderer_twice_is_a_no_op(): + def fake(figure, style, target=None): # ruff: ignore[unused-function-argument] + return None + + try: + assert register_renderer("test-idempotent", fake) is fake + register_renderer("test-idempotent", fake) + finally: + qp.plotting.renderers._renderers.pop("test-idempotent", None) + + +def test_a_taken_name_cannot_be_reassigned(): + def one(figure, style, target=None): # ruff: ignore[unused-function-argument] + return None + + def two(figure, style, target=None): # ruff: ignore[unused-function-argument] + return None + + try: + register_renderer("test-taken", one) + with pytest.raises(ValueError, match="already registered"): + register_renderer("test-taken", two) + finally: + qp.plotting.renderers._renderers.pop("test-taken", None) + + +def test_an_unknown_renderer_lists_the_known_ones(): + with pytest.raises(KeyError, match="registered: matplotlib"): + resolve_renderer("svg") + + +def test_an_empty_renderer_name_is_a_lost_name_and_not_a_request_for_the_default(): + with pytest.raises(KeyError, match="No renderer named ''"): + resolve_renderer("") + + +# --------------------------------------------------------------------------- +# The matplotlib renderer +# --------------------------------------------------------------------------- + + +def test_rendering_returns_the_axes_it_drew_on(): + ax = matplotlib_renderer.render(build_figure(_sweep_iq()), Style()) + assert isinstance(ax, plt.Axes) + assert len(ax.lines) == 2 + + +def test_the_axis_labels_reach_the_axes(): + data = _sweep_iq(attrs={"long_name": "Drive amplitude", "units": "V"}) + ax = matplotlib_renderer.render(build_figure(data, title="Rabi"), Style()) + assert ax.get_xlabel() == "Drive amplitude (V)" + assert ax.get_ylabel() == "Signal" + assert ax.get_title(loc="left") == "Rabi" + + +def test_an_existing_axes_is_drawn_on_rather_than_replaced(): + _, ax = plt.subplots() + returned = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(), ax) + assert returned is ax + + +def test_two_series_get_a_legend_and_one_does_not(): + with_two = matplotlib_renderer.render(build_figure(_sweep_iq()), Style()) + assert with_two.get_legend() is not None + with_one = matplotlib_renderer.render(build_figure(_sweep_iq(), channels="i"), Style()) + assert with_one.get_legend() is None + + +def test_the_legend_can_be_switched_off(): + ax = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(legend=False)) + assert ax.get_legend() is None + + +def test_the_theme_paints_the_surface(): + ax = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(theme=DARK)) + assert mpl.colors.to_hex(ax.get_facecolor()) == DARK.surface + + +def test_the_grid_can_be_switched_off(): + ax = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(grid=False)) + assert not any(line.get_visible() for line in ax.get_xgridlines()) + + +def test_markers_are_off_by_default_and_can_be_turned_on(): + plain = matplotlib_renderer.render(build_figure(_sweep_iq()), Style()) + assert plain.lines[0].get_marker() == "None" + marked = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(markers=True)) + assert marked.lines[0].get_marker() == "o" + + +def test_a_heatmap_draws_a_mesh_and_a_colour_bar(): + ax = matplotlib_renderer.render(build_figure(_grid_iq()), Style()) + assert len(ax.collections) == 1 + assert any(other is not ax for other in ax.get_figure().axes) + + +def test_the_colour_bar_can_be_switched_off(): + ax = matplotlib_renderer.render(build_figure(_grid_iq()), Style(colorbar=False)) + assert ax.get_figure().axes == [ax] + + +def test_a_scatter_draws_one_collection(): + ax = matplotlib_renderer.render(build_figure(_sweep_iq(), kind="scatter"), Style()) + assert len(ax.collections) == 1 + + +def test_a_twin_reads_from_the_top_of_a_line_figure(): + ax = matplotlib_renderer.render(build_figure(_composed()), Style()) + (twin,) = ax.child_axes + assert twin.xaxis.get_label_position() == "top" + assert twin.get_xlabel() == "b (ns)" + assert twin.spines["top"].get_edgecolor() == mpl.colors.to_rgba(LIGHT.grid) + + +def test_a_twin_ticks_every_sample_of_a_sweep_shorter_than_the_style_asks_for(): + data = _composed().isel({"a|b": [0, 1, 2]}) + ax = matplotlib_renderer.render(build_figure(data), Style()) + (twin,) = ax.child_axes + assert [text.get_text() for text in twin.get_xticklabels()] == ["10", "20", "30"] + + +def test_a_twinned_figure_is_laid_out_constrained_to_make_room_for_the_scale(): + twinned = matplotlib_renderer.render(build_figure(_composed()), Style()) + assert twinned.get_figure().get_layout_engine() is not None + plain = matplotlib_renderer.render(build_figure(_sweep_iq()), Style()) + assert plain.get_figure().get_layout_engine() is None + + +def test_a_twinned_heatmap_keeps_the_colour_bar_clear_of_the_right_hand_scale(): + values = np.arange(4 * 3 * 2, dtype=float).reshape(4, 3, 2) + data = xr.DataArray( + values, + dims=("a|b", "dur", "IQ"), + coords={ + "a": xr.DataArray([0.0, 1.0, 2.0, 3.0], dims="a|b"), + "b": xr.DataArray([10.0, 20.0, 30.0, 40.0], dims="a|b"), + "dur": xr.DataArray([1.0, 2.0, 3.0], dims="dur"), + "IQ": ["I", "Q"], + }, + ) + ax = matplotlib_renderer.render(build_figure(data), Style()) + (twin,) = ax.child_axes + assert twin.yaxis.get_label_position() == "right" + fig = ax.get_figure() + fig.canvas.draw() + (bar,) = (other for other in fig.axes if other is not ax) + assert bar.get_position().x0 > twin.get_tightbbox().transformed(fig.transFigure.inverted()).x1 + + +def test_a_figure_can_mix_marks(): + figure = Figure( + marks=( + Mesh(x=np.arange(3), y=np.arange(2), values=np.zeros((2, 3)), label=None), + Line(x=np.arange(3), y=np.zeros(3), label=None), + ), + x_label="x", + y_label="y", + ) + ax = matplotlib_renderer.render(figure, Style()) + assert len(ax.collections) == 1 + assert len(ax.lines) == 1 + + +# --------------------------------------------------------------------------- +# QProgramResult.plot +# --------------------------------------------------------------------------- + +_READOUT = qp.waveforms.IQPair(qp.waveforms.Square(1.0, 200), qp.waveforms.Square(0.0, 200)) +_WEIGHTS = qp.waveforms.IQPair(qp.waveforms.Square(1.0, 200), qp.waveforms.Square(1.0, 200)) +_LIBRARY = {"pi": qp.waveforms.IQDrag(0.5, 40, 8, 0.1), "readout": _READOUT, "weights": _WEIGHTS} + + +def _rabi_response(bus: str, env: dict) -> complex: # ruff: ignore[unused-function-argument] + return np.sin(np.pi * env["gain"]) ** 2 + 0j + + +def _rabi_population(bus: str, env: dict) -> float: # ruff: ignore[unused-function-argument] + return float(env["gain"]) + + +def _rabi_result() -> tuple[qp.QProgramResult, qp.MeasurementHandle]: + """Run a small labelled sweep, so the coordinate attributes are the executor's own.""" + schema = qp.BusSchema.transmon() + program = qp.QProgram(label="rabi", schema=schema) + gain = program.variable("gain", label="Drive amplitude", units="V") + with program.average(4), program.sweep(gain, qp.Range(0.0, 1.0, 0.25)): + program.set_gain(schema.q[0].drive, gain) + program.play(schema.q[0].drive, "pi") + program.sync() + handle = program.measure( + schema.q[0].readout, + "readout", + "weights", + fields=(qp.MeasurementField.IQ, qp.MeasurementField.STATE), + ) + model = qp.MockMeasurementModel(response=_rabi_response, p_excited=_rabi_population, seed=0) + return qp.simulate(program.with_waveforms(_LIBRARY), model=model), handle + + +def test_plot_labels_its_axis_from_the_swept_variable(): + result, handle = _rabi_result() + ax = result.plot(handle) + assert ax.get_xlabel() == "Drive amplitude (V)" + assert len(ax.lines) == 2 + + +def test_plot_finds_a_measurement_the_same_ways_get_does(): + result, handle = _rabi_result() + by_position = result.plot(0) + by_name = result.plot(handle.name) + by_bus = result.plot(0, bus="q0/readout") + assert all(isinstance(ax, plt.Axes) for ax in (by_position, by_name, by_bus)) + + +def test_plot_names_the_state_field_on_the_axis(): + result, handle = _rabi_result() + ax = result.plot(handle, field=qp.MeasurementField.STATE) + assert ax.get_ylabel() == "State" + assert len(ax.lines) == 1 + + +def test_plot_passes_the_style_through(): + result, handle = _rabi_result() + ax = result.plot(handle, style=Style(theme=DARK)) + assert mpl.colors.to_hex(ax.get_facecolor()) == DARK.surface + + +def test_plot_draws_on_an_axes_it_is_given(): + result, handle = _rabi_result() + _, ax = plt.subplots() + assert result.plot(handle, target=ax) is ax + + +def test_plot_uses_the_renderer_it_is_told_to(): + result, handle = _rabi_result() + + def fake(figure, style, target=None): # ruff: ignore[unused-function-argument] + return figure + + try: + register_renderer("test-plot", fake) + figure = result.plot(handle, renderer="test-plot") + finally: + qp.plotting.renderers._renderers.pop("test-plot", None) + assert isinstance(figure, Figure) + assert figure.x_label == "Drive amplitude (V)" + + +def test_plot_takes_a_label_for_the_measured_quantity(): + result, handle = _rabi_result() + ax = result.plot(handle, value=Quantity("Readout response")) + assert ax.get_ylabel() == "Readout response" + + +def test_plot_restates_a_coordinate_for_the_axis(): + result, handle = _rabi_result() + ax = result.plot(handle, coords={"gain": Quantity(units="mV", transform=lambda v: v * 1e3)}) + assert ax.get_xlabel() == "Drive amplitude (mV)" + assert np.allclose(ax.lines[0].get_xdata(), [0.0, 250.0, 500.0, 750.0, 1000.0]) + + +def test_plot_leaves_the_result_in_the_units_it_was_measured_in(): + result, handle = _rabi_result() + result.plot(handle, coords={"gain": Quantity(units="mV", transform=lambda v: v * 1e3)}) + assert np.allclose(result.get(handle).coords["gain"].values, [0.0, 0.25, 0.5, 0.75, 1.0]) + + +def test_plot_fills_in_the_label_a_field_implies_and_yields_to_the_caller_s(): + result, handle = _rabi_result() + implied = result.plot(handle, field=qp.MeasurementField.STATE) + assert implied.get_ylabel() == "State" + named = result.plot(handle, field=qp.MeasurementField.STATE, value=Quantity("Excited population")) + assert named.get_ylabel() == "Excited population" + + +def test_plot_restates_a_field_whose_label_it_supplies(): + result, handle = _rabi_result() + ax = result.plot(handle, field=qp.MeasurementField.STATE, value=Quantity(units="%", transform=lambda v: v * 100)) + assert ax.get_ylabel() == "State (%)" + + +def test_plot_refuses_a_bare_string_where_a_quantity_belongs(): + result, handle = _rabi_result() + with pytest.raises(ValidationError, match="must be a Quantity, got str"): + result.plot(handle, field=qp.MeasurementField.STATE, value="Excited population") + + +def test_plot_of_a_scatter_takes_no_label_from_the_field(): + result, handle = _rabi_result() + ax = result.plot(handle, kind="scatter", value=Quantity(units="mV", transform=lambda v: v * 1e3)) + assert (ax.get_xlabel(), ax.get_ylabel()) == ("I (mV)", "Q (mV)") + + +def test_plot_reports_a_missing_field_the_way_get_does(): + result, handle = _rabi_result() + with pytest.raises(KeyError, match="has no field 'raw'"): + result.plot(handle, field=qp.MeasurementField.RAW) + + +def _parallel_result() -> tuple[qp.QProgramResult, qp.MeasurementHandle]: + """Run a parallel composition, so the dimension and its coordinates are the executor's own.""" + schema = qp.BusSchema.transmon() + program = qp.QProgram(label="parallel", schema=schema) + amp = program.variable("amp", label="Amplitude", units="V") + freq = program.variable("freq", label="Frequency", units="Hz") + with program.sweep(amp, qp.Range(0.0, 1.0, 0.25)) | program.sweep(freq, qp.Range(4e9, 5e9, 0.25e9)): + handle = program.measure(schema.q[0].readout, "readout", "weights") + return qp.simulate(program.with_waveforms(_LIBRARY)), handle + + +def test_plot_of_a_parallel_sweep_reads_the_second_variable_on_a_twin_axis(): + result, handle = _parallel_result() + ax = result.plot(handle) + assert ax.get_xlabel() == "Amplitude (V)" + (twin,) = ax.child_axes + assert twin.get_xlabel() == "Frequency (Hz)" + assert twin.xaxis.get_label_position() == "top" + + +def test_plot_of_a_parallel_sweep_takes_the_axis_it_is_told_to(): + result, handle = _parallel_result() + ax = result.plot(handle, x="freq") + assert ax.get_xlabel() == "Frequency (Hz)" + assert ax.child_axes == [] + + +def test_a_twin_ticks_at_the_samples_it_shares_with_its_axis(): + result, handle = _parallel_result() + ax = result.plot(handle) + (twin,) = ax.child_axes + ax.get_figure().canvas.draw() + assert np.allclose(twin.get_xticks(), [0.0, 0.25, 0.5, 0.75, 1.0]) + assert [text.get_text() for text in twin.get_xticklabels()] == ["4e+09", "4.25e+09", "4.5e+09", "4.75e+09", "5e+09"] + + +def test_twin_ticks_thin_out_a_sweep_longer_than_the_style_asks_for(): + result, handle = _parallel_result() + ax = result.plot(handle, style=Style(twin_ticks=3)) + (twin,) = ax.child_axes + assert np.allclose(twin.get_xticks(), [0.0, 0.5, 1.0]) + + +def test_a_twin_takes_the_limits_of_the_axis_it_doubles(): + result, handle = _parallel_result() + ax = result.plot(handle) + (twin,) = ax.child_axes + ax.set_xlim(0.2, 0.8) + ax.get_figure().canvas.draw() + assert twin.get_xlim() == ax.get_xlim() + + +def test_a_twin_reads_in_whatever_unit_it_was_restated_to(): + result, handle = _parallel_result() + ax = result.plot(handle, coords={"freq": Quantity(units="GHz", transform=lambda v: v / 1e9)}) + (twin,) = ax.child_axes + assert twin.get_xlabel() == "Frequency (GHz)" + assert [text.get_text() for text in twin.get_xticklabels()] == ["4", "4.25", "4.5", "4.75", "5"] diff --git a/zensical.toml b/zensical.toml index 864385e..1c410e3 100644 --- a/zensical.toml +++ b/zensical.toml @@ -25,6 +25,7 @@ nav = [ { "Measurements and results" = "guide/measurements.md" }, { "Capabilities, diagnostics, and profiles" = "guide/capabilities.md" }, { "Running programs" = "guide/execution.md" }, + { "Plotting results" = "guide/plotting.md" }, { "Saving and loading" = "guide/serialization.md" }, ] }, { "Examples" = [