diff --git a/changelog/36.changed.md b/changelog/36.changed.md new file mode 100644 index 0000000..f958ec5 --- /dev/null +++ b/changelog/36.changed.md @@ -0,0 +1 @@ +`Waveform.plot` and `IQWaveform.plot` draw through the palette and the renderer registry `QProgramResult.plot` draws through, so a pulse and the sweep it produced no longer look like they came from two libraries. All three now take the same `style`, `renderer` and `target`: the envelope is described as a `qp.plotting.Figure` and handed to a renderer resolved by name, the style defaults to the same `qp.plotting.Style()`, and `target` replaces `ax` and `axes`. `Style.size` defaults to `None`, meaning the size that suits what is being drawn: `qp.plotting.DEFAULT_SIZE` for a measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, which are the figure sizes the two plotting methods always had, and `Style.sized` is how a caller fills one in. A `Figure` also carries `series`, the palette slot its first mark takes, which is what draws the two panels of an IQ envelope in the theme's first two colours. Those panels are the one thing a renderer does not decide, since two axes sharing a scale is a matplotlib layout, so any other renderer has to be given the `(I, Q)` surfaces to draw on. `_repr_html_` returns a `` holding the envelope drawn once for a light surface and once for a dark one, chosen by `prefers-color-scheme`, so a waveform in a dark notebook is no longer a white rectangle; it also takes the figure off the axes `plot` returned rather than off pyplot's current figure, which was an ordering contract that held only by convention. diff --git a/docs/developer/adding-waveforms.md b/docs/developer/adding-waveforms.md index 8822151..45e4c0c 100644 --- a/docs/developer/adding-waveforms.md +++ b/docs/developer/adding-waveforms.md @@ -80,9 +80,11 @@ subclass inherits all of it and writes none of it. `peak_amplitude()` is `max(|envelope|)`, `rms_amplitude()` the root mean square of the samples, `area()` the trapezoidal integral in nanosecond-amplitude units (`np.trapezoid(env, dx=resolution)`), and `spectrum()` a one-sided `np.fft.rfft` -paired with frequencies in Hz. `plot()` and the Jupyter `_repr_html_` need -matplotlib, which ships in the `viz` extra and is imported inside the call so -the package stays importable without it. +paired with frequencies in Hz. `plot()` describes the envelope as a +`qp.plotting.Figure` and hands it to a renderer, and the Jupyter `_repr_html_` +draws it once per surface; both reach matplotlib by default, which ships in the +`viz` extra and is imported the first time something draws with it, so the +package stays importable without it. Nothing in core calls `envelope()`. Validation and serialization work on the constructor arguments alone, so samples are rendered only when someone asks for diff --git a/docs/guide/plotting.md b/docs/guide/plotting.md index d644c4e..f9e879c 100644 --- a/docs/guide/plotting.md +++ b/docs/guide/plotting.md @@ -24,6 +24,10 @@ ax.axvline(0.5, linestyle="--") ax.set_ylabel("Readout response") ``` +In a notebook that axes is also the cell's value, so a `result.plot(m0)` on a +line of its own shows `` beside the figure. Bind it the way the +snippet above does, or end the call with a semicolon. + 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 @@ -201,16 +205,10 @@ one already there has to come with the arithmetic that earns it: # 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: @@ -262,6 +260,20 @@ result.plot(m0, style=qp.plotting.Style(theme=house)) `markers` is worth turning on for a coarse sweep, where the points are the measurement and the line between them is interpolation. +`size` is the one field with no default of its own. `None` means the size that +suits what is being drawn, which is `qp.plotting.DEFAULT_SIZE` for a +measurement and `ENVELOPE_SIZE` or `IQ_ENVELOPE_SIZE` for a waveform, and it is +read only when the figure is made here: axes you pass as `target=` keep the size +they came with. + +`Waveform.plot` takes the same `style`, `renderer` and `target`, which is most +of why the palette and the registry are objects of their own: a pi pulse and the +Rabi sweep it produced are one experiment, and a pair that speaks two visual +languages is a papercut. Its style defaults to `Style()` the way this one does, +and the only differences are the size a figure of a pulse comes out at and the +`(I, Q)` pair of panels an IQ shape wants for a `target`. +[Waveforms](waveforms.md) has the rest. + ## Another renderer A renderer is any callable taking a figure, a `Style`, and a surface to draw @@ -283,6 +295,7 @@ def to_text(figure, style, target=None): register_renderer("text", to_text) result.plot(m0, renderer="text") +qp.waveforms.Square(0.5, 100).plot(renderer="text") ``` `build_figure` is the half worth reading first when writing one. It returns a @@ -291,6 +304,12 @@ 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. +A figure hands over everything a renderer needs to draw it and nothing about +how: the marks, the two labels, a title, either `Twin` scale, and `series`, the +palette slot its first mark takes. That last one is only ever set when a figure +is one panel of several that should not repeat a colour, which is what the `Q` +panel of an IQ envelope is; a renderer drawing in one colour ignores it. + ## What it does not draw `plot` returns composable axes rather than trying to be the whole figure. A diff --git a/docs/guide/waveforms.md b/docs/guide/waveforms.md index c192cbb..e02b139 100644 --- a/docs/guide/waveforms.md +++ b/docs/guide/waveforms.md @@ -58,7 +58,7 @@ own, answers them without extra code: | `peak_amplitude(resolution=1)` | `max(abs(envelope))`, and for an IQ shape the peak magnitude `max(abs(I + 1j*Q))`. | | `rms_amplitude(resolution=1)` | the root mean square of the samples, or of the complex magnitudes for an IQ shape. | | `spectrum(resolution=1)` | a `(frequencies_hz, complex_spectrum)` pair. Real shapes use `numpy.fft.rfft`, so 64 samples at `resolution=1` give 33 one-sided bins up to 500 MHz; IQ shapes use a `fftshift`ed two-sided `numpy.fft.fft`. | -| `plot(resolution=1, ...)` | a matplotlib `Axes`, or an `(I_axes, Q_axes)` pair for an IQ shape. | +| `plot(resolution=1, ...)` | whatever the renderer returns, which for matplotlib is an `Axes`, or an `(I, Q)` pair of them for an IQ shape. | `envelope()` resolves every symbolic parameter before it samples anything, by calling `Expression.evaluate_or_raise()` on it. A variable with no value @@ -71,15 +71,74 @@ UnassignedVariableError: Cannot evaluate expression Variable('amp'): unassigned Every measure above is computed from `envelope()`, so they all raise the same error under the same conditions. -`plot()` takes the axes to draw on: `Waveform.plot(resolution=1, ax=None)` -returns one `Axes`, and -`IQWaveform.plot(resolution=1, axes=None)` returns two stacked axes sharing an -x axis, labeled `I` and `Q`. Passing `None` creates a fresh figure. matplotlib -is imported inside the call, from the `qprogram[viz]` extra, which keeps the -rest of the package importable without it; without matplotlib installed the -call raises `ModuleNotFoundError`. Both bases also define `_repr_html_`, which -returns the same plot as an inline SVG, so a bare waveform renders in a Jupyter -cell without an explicit `plot()`. +`plot()` takes the same three arguments `result.plot` takes, and means the same +things by them: + + + +```python +Waveform.plot(resolution=1, *, style=None, renderer=None, target=None) +IQWaveform.plot(resolution=1, *, style=None, renderer=None, target=None) +``` + +The envelope is described as a `qp.plotting.Figure` and handed to a renderer, +which for the default matplotlib one returns the `Axes` it drew on. An IQ shape +draws two panels stacked on a shared x axis, labeled `I` and `Q` and in the +theme's first two colours, and returns the two handles as an `(I, Q)` pair. +matplotlib is imported when it is first drawn with, from the `qprogram[viz]` +extra, which keeps the rest of the package importable without it; without +matplotlib installed the call raises `ModuleNotFoundError`. +[Plotting results](plotting.md) is the walkthrough for all three arguments; +what follows is what a waveform does differently. + +The style defaults to a plain `Style()`, the same as a result's, so a pulse and +the sweep it produced sit on one palette instead of looking like two libraries: + +```python +import qprogram as qp + +pi_pulse = qp.waveforms.Gaussian(amplitude=0.5, duration=40, sigma=8) +pi_pulse.plot(style=qp.plotting.Style(theme=qp.plotting.DARK)) +``` + +The one thing a `Style` does not carry by default is a figure size, which is +what lets the same one suit a measurement and a pulse. A style that names none +is drawn at `qp.plotting.ENVELOPE_SIZE`, six inches by two, or +`qp.plotting.IQ_ENVELOPE_SIZE` for the stacked pair, which is an inch taller; +`Style(size=(4, 1.5))` overrides that, and a `target` you pass in keeps whatever +size its figure already has. + +`target` is one surface for a single-channel shape and an `(I, Q)` pair for an +IQ one, which is how a pulse composes into a layout of your own: + +```python +import matplotlib.pyplot as plt + +drag = qp.waveforms.IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1) + +fig, (top, bottom) = plt.subplots(2, 1, sharex=True) +drag.plot(target=(top, bottom)) +``` + +That pair is also the one place a waveform asks for more than a result does. Two +panels sharing a scale is a matplotlib layout rather than anything the figure +describes, and it is the only pair the package knows how to build, so a +registered `renderer` other than the built-in one raises `ValidationError` when +it is asked for with no `target`: the panels it would otherwise be handed are +matplotlib's. + +Both bases also define `_repr_html_`, so a bare waveform renders in a Jupyter +cell without an explicit `plot()`. It draws the envelope once per surface and +returns a `` holding both, with the dark one behind a +`prefers-color-scheme` source, so a cell in a dark notebook is not a white +rectangle. It draws with the default renderer, since what a cell wants is an +image. That reads the browser's setting, which is the editor's own theme in VS +Code and the operating system's under JupyterLab; where the two disagree, +`plot(style=...)` is how to say which surface you are on. + +Only the waveform itself takes that path. `wf.plot()` makes the axes the cell's +value instead, which a notebook shows as `` beside the figure, so +bind it or end the call with a semicolon. ## Single-channel built-ins diff --git a/docs/reference/api-qprogram.md b/docs/reference/api-qprogram.md index f89f500..8582a78 100644 --- a/docs/reference/api-qprogram.md +++ b/docs/reference/api-qprogram.md @@ -454,7 +454,9 @@ compared, hashed, and serialized in. `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 +`Renderer` reads numpy and xarray only. `Waveform.plot` and `IQWaveform.plot` +take the same `style`, `renderer` and `target` and describe an envelope as the +same `Figure`, which is what keeps a pulse and a result on one palette. See [Plotting results](../guide/plotting.md) for the walkthrough. These names live in `qprogram.plotting`, which the top level does not re-export. @@ -496,6 +498,12 @@ in `qprogram.plotting`, which the top level does not re-export. ::: qprogram.plotting.DARK +::: qprogram.plotting.DEFAULT_SIZE + +::: qprogram.plotting.ENVELOPE_SIZE + +::: qprogram.plotting.IQ_ENVELOPE_SIZE + ::: qprogram.plotting.Renderer options: show_root_full_path: false diff --git a/src/qprogram/plotting/__init__.py b/src/qprogram/plotting/__init__.py index a62b015..a419499 100644 --- a/src/qprogram/plotting/__init__.py +++ b/src/qprogram/plotting/__init__.py @@ -26,6 +26,9 @@ result.plot(m0, channels="magnitude") # hypot(I, Q) result.plot(m0, x="freq", style=Style(theme=DARK)) +[`Waveform.plot`][qprogram.waveforms.Waveform.plot] runs the same two halves over an envelope, so a +pulse and the sweep it produced sit on one palette rather than looking like two libraries. + Only the drawing half needs matplotlib, which ships in the ``viz`` extra and is imported the first time a figure is rendered. """ @@ -42,13 +45,24 @@ register_renderer, resolve_renderer, ) -from qprogram.plotting.theme import DARK, LIGHT, Style, Theme +from qprogram.plotting.theme import ( + DARK, + DEFAULT_SIZE, + ENVELOPE_SIZE, + IQ_ENVELOPE_SIZE, + LIGHT, + Style, + Theme, +) __all__ = [ "CHANNELS", "DARK", "DEFAULT_RENDERER", + "DEFAULT_SIZE", + "ENVELOPE_SIZE", "IQ_DIM", + "IQ_ENVELOPE_SIZE", "KINDS", "LIGHT", "Figure", diff --git a/src/qprogram/plotting/matplotlib_renderer.py b/src/qprogram/plotting/matplotlib_renderer.py index 4b28186..4975034 100644 --- a/src/qprogram/plotting/matplotlib_renderer.py +++ b/src/qprogram/plotting/matplotlib_renderer.py @@ -13,10 +13,10 @@ # 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`. +Nothing imports it at module scope. It is loaded the first time something resolves the +``"matplotlib"`` renderer or plots a waveform, 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 @@ -33,6 +33,7 @@ from matplotlib.colors import LinearSegmentedColormap from qprogram.plotting.model import Line, Points +from qprogram.plotting.theme import DEFAULT_SIZE if TYPE_CHECKING: from typing import Literal @@ -77,11 +78,11 @@ def render(figure: Figure, style: Style, target: Axes | None = None) -> Axes: # 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, ax = plt.subplots(figsize=style.size or DEFAULT_SIZE, layout="constrained" if twinned else None) fig.set_facecolor(style.theme.surface) _frame(ax, style) - series = 0 + series = figure.series for mark in figure.marks: if isinstance(mark, Line): _line(ax, mark, style, series) diff --git a/src/qprogram/plotting/model.py b/src/qprogram/plotting/model.py index 883245a..4d90270 100644 --- a/src/qprogram/plotting/model.py +++ b/src/qprogram/plotting/model.py @@ -128,6 +128,11 @@ class Figure: 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. + series (int): Which categorical slot the first mark takes, the rest counting up from it. + Zero unless this figure is one panel of several that should not repeat a colour, which + is what keeps the Q panel of an IQ envelope off the I panel's hue. It is an index and + not a colour: what the slot holds is the renderer's business, and one drawing in a + single colour ignores it. """ marks: tuple[Mark, ...] @@ -136,3 +141,4 @@ class Figure: title: str | None = None x_twin: Twin | None = None y_twin: Twin | None = None + series: int = 0 diff --git a/src/qprogram/plotting/quantity.py b/src/qprogram/plotting/quantity.py index f6c2387..b2b8851 100644 --- a/src/qprogram/plotting/quantity.py +++ b/src/qprogram/plotting/quantity.py @@ -60,8 +60,7 @@ class Quantity: 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. + unit at all — a ratio, a normalised population. 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 @@ -137,10 +136,9 @@ def checked(value: object, where: str) -> Quantity | None: 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. + The input is copied first: 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. 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 @@ -152,7 +150,7 @@ def restated(quantity: Quantity | None, values: np.ndarray, where: str) -> np.nd 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. + The restated numbers, or ``values`` unchanged when there is no transform. Raises: ValidationError: If the transform raises, returns a different shape, returns something other @@ -193,9 +191,7 @@ def text(quantity: Quantity | None, label: str, units: str | None, where: str) - 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. + transform or a bare ``units`` without complaint. Args: quantity (Quantity | None): The restatement, or ``None`` for none. @@ -223,12 +219,12 @@ def text(quantity: Quantity | None, label: str, units: str | None, where: str) - ) 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" + if quantity.units and quantity.transform is None and units and quantity.units != units: 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." + f"{where} relabels the unit from {units!r} to {quantity.units!r} without changing the " + f"numbers, so the axis would read ({quantity.units}) over values still in {units}. Pass " + f"the transform= that converts them, or correct the unit on the variable the " + f"coordinate came from." ) raise ValidationError(msg) return _joined(quantity.label if quantity.label is not None else label, quantity.units) diff --git a/src/qprogram/plotting/renderers.py b/src/qprogram/plotting/renderers.py index 7ee348b..d80d68f 100644 --- a/src/qprogram/plotting/renderers.py +++ b/src/qprogram/plotting/renderers.py @@ -85,10 +85,6 @@ def register_renderer(name: str, renderer: Renderer) -> 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`. @@ -100,17 +96,14 @@ def resolve_renderer(name: str | None = None) -> Renderer: ModuleNotFoundError: If the default renderer is asked for without ``matplotlib`` installed — install ``qprogram[viz]``. """ - if name is None: - name = DEFAULT_RENDERER + name = name or 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})) + available = ", ".join(sorted(_renderers)) or "none" msg = f"No renderer named {name!r}; registered: {available}" raise KeyError(msg) return _renderers[name] diff --git a/src/qprogram/plotting/theme.py b/src/qprogram/plotting/theme.py index da5f6f6..2f18abe 100644 --- a/src/qprogram/plotting/theme.py +++ b/src/qprogram/plotting/theme.py @@ -19,11 +19,14 @@ 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. + +A style names no figure size of its own by default, so the same `Style()` suits a measurement and a +pulse: whatever is drawing fills in the size that suits it, from the three named here. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace @dataclass(frozen=True) @@ -72,13 +75,31 @@ class Theme: page.""" +DEFAULT_SIZE = (7.2, 4.0) +"""Figure size in inches a renderer makes a new figure at, which is what +[`QProgramResult.plot`][qprogram.QProgramResult.plot] draws a measurement at.""" + +ENVELOPE_SIZE = (6.0, 2.0) +"""Figure size for [`Waveform.plot`][qprogram.waveforms.Waveform.plot]. An envelope is one line over +a few hundred nanoseconds, so four inches of height would be mostly empty surface.""" + +IQ_ENVELOPE_SIZE = (6.0, 3.0) +"""Figure size for [`IQWaveform.plot`][qprogram.waveforms.IQWaveform.plot], an inch taller than +[`ENVELOPE_SIZE`][qprogram.plotting.ENVELOPE_SIZE] because it stacks two panels.""" + + @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)``. + size (tuple[float, float] | None): Figure size in inches, ``(width, height)``, or ``None`` + for the size that suits what is being drawn: + [`DEFAULT_SIZE`][qprogram.plotting.DEFAULT_SIZE] for a measurement, + [`ENVELOPE_SIZE`][qprogram.plotting.ENVELOPE_SIZE] or + [`IQ_ENVELOPE_SIZE`][qprogram.plotting.IQ_ENVELOPE_SIZE] for a waveform. Only consulted + when the figure is made here; one the caller brought keeps the size it has. 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. @@ -95,7 +116,7 @@ class Style: """ theme: Theme = LIGHT - size: tuple[float, float] = (7.2, 4.0) + size: tuple[float, float] | None = None linewidth: float = 1.8 markers: bool = False markersize: float = 3.5 @@ -116,3 +137,18 @@ def color(self, index: int) -> str: One of the theme's `series` colours. """ return self.theme.series[index % len(self.theme.series)] + + def sized(self, default: tuple[float, float]) -> Style: + """Return this style with a figure size on it, ``default`` when it names none of its own. + + What a figure of a pulse should measure is not something a renderer can know, so a caller + that knows fills it in before handing the style over. A style that already names a size is + returned unchanged, which is what makes ``size=`` on a call the last word. + + Args: + default (tuple[float, float]): Size in inches to use when this style names none. + + Returns: + This style, or a copy of it carrying ``default``. + """ + return self if self.size is not None else replace(self, size=default) diff --git a/src/qprogram/waveforms/waveform.py b/src/qprogram/waveforms/waveform.py index e1ab531..f506b7b 100644 --- a/src/qprogram/waveforms/waveform.py +++ b/src/qprogram/waveforms/waveform.py @@ -20,16 +20,21 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast import numpy as np from qprogram._structural import ast_eq, ast_hash +from qprogram.errors import ValidationError +from qprogram.plotting.model import Figure, Line +from qprogram.plotting.renderers import DEFAULT_RENDERER, resolve_renderer +from qprogram.plotting.theme import DARK, ENVELOPE_SIZE, IQ_ENVELOPE_SIZE, LIGHT, Style if TYPE_CHECKING: from collections.abc import Callable from matplotlib.axes import Axes + from matplotlib.figure import Figure as MatplotlibFigure class _StructuralEqMixin: @@ -170,46 +175,62 @@ def spectrum(self, resolution: int = 1) -> tuple[np.ndarray, np.ndarray]: # -- visualization ----------------------------------------------------- - def plot(self, resolution: int = 1, ax: Axes | None = None) -> Axes: - """Plot the envelope on a matplotlib `Axes`. - - Requires ``matplotlib``, which ships in the ``viz`` extra; it is imported inside the call so the - rest of the package stays importable without it. + def plot( + self, + resolution: int = 1, + *, + style: Style | None = None, + renderer: str | None = None, + target: object = None, + ) -> Any: # ruff: ignore[any-type] # whatever handle the renderer gives back + """Plot the envelope. + + The envelope is described as a [`Figure`][qprogram.plotting.Figure] and handed to a renderer, + which is how [`QProgramResult.plot`][qprogram.QProgramResult.plot] draws too, so a pulse and + the sweep it produced read as one experiment rather than as two libraries. The default + renderer is matplotlib, from the ``viz`` extra, and it returns the `Axes` it drew on. Args: resolution (int, optional): Sample period in nanoseconds. - ax (Axes | None): Axes to draw on. A fresh figure+axes is created when ``None``. + style (Style | None): Palette and drawing weights. Defaults to + [`Style`][qprogram.plotting.Style]``()``, which is the light theme; a style that + names no size of its own is drawn at + [`ENVELOPE_SIZE`][qprogram.plotting.ENVELOPE_SIZE]. + 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: - The `Axes` containing the plot. + Whatever the renderer returns: the `Axes` for the matplotlib one. Raises: - ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. + KeyError: When ``renderer`` names none that is registered. + ModuleNotFoundError: When the matplotlib renderer is used without matplotlib + installed — install ``qprogram[viz]``. UnassignedVariableError: If the envelope depends on a variable that has no value. """ - import matplotlib.pyplot as plt # ruff: ignore[import-outside-top-level] - - if ax is None: - _, ax = plt.subplots(figsize=(6, 2)) env = self.envelope(resolution=resolution) - t = np.arange(len(env)) * resolution - ax.plot(t, env) - ax.set_xlabel("Time (ns)") - ax.set_ylabel("Amplitude") - ax.set_title(type(self).__name__) - return ax + figure = Figure( + marks=(Line(np.arange(len(env)) * resolution, env),), + x_label="Time (ns)", + y_label="Amplitude", + title=type(self).__name__, + ) + return resolve_renderer(renderer)(figure, (style or Style()).sized(ENVELOPE_SIZE), target) def _repr_html_(self) -> str: - """Return an inline SVG of the envelope for Jupyter display. + """Return the envelope as an image for Jupyter display. Returns: - The SVG markup of the envelope plot. + The markup for one cell, holding the figure drawn for a light surface and for a dark one. Raises: ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. UnassignedVariableError: If the envelope depends on a variable that has no value. """ - return _waveform_svg(self.plot) + name = type(self).__name__ + return _envelope_html(lambda style: self.plot(style=style), f"{name} envelope") class IQWaveform(_StructuralEqMixin, ABC): @@ -337,80 +358,153 @@ def spectrum(self, resolution: int = 1) -> tuple[np.ndarray, np.ndarray]: def plot( self, resolution: int = 1, - axes: tuple[Axes, Axes] | None = None, - ) -> tuple[Axes, Axes]: - """Plot the I and Q channels on two stacked matplotlib axes. + *, + style: Style | None = None, + renderer: str | None = None, + target: tuple[object, object] | None = None, + ) -> tuple[Any, Any]: + """Plot the I and Q channels on two stacked panels. - Requires ``matplotlib``, which ships in the ``viz`` extra; it is imported inside the call so the - rest of the package stays importable without it. + Each channel is described as a [`Figure`][qprogram.plotting.Figure] and handed to a renderer, + the second asking for the theme's second colour so the pair does not read as one series. The + panels share an x axis, so only the lower one is labelled. + + The panels themselves are the one thing here a renderer does not decide: two axes sharing a + scale is a matplotlib layout, and it is the only one this knows how to build, so any other + renderer has to be given the surfaces to draw on. Args: resolution (int, optional): Sample period in nanoseconds. - axes (tuple[Axes, Axes] | None): Pair of axes to draw on. A fresh figure with two stacked axes - is created when ``None``. + style (Style | None): Palette and drawing weights. Defaults to + [`Style`][qprogram.plotting.Style]``()``, which is the light theme; a style that + names no size of its own is drawn at + [`IQ_ENVELOPE_SIZE`][qprogram.plotting.IQ_ENVELOPE_SIZE]. + renderer (str | None): A name passed to + [`resolve_renderer`][qprogram.plotting.resolve_renderer]. Defaults to ``"matplotlib"``. + target (tuple[object, object] | None): The ``(I, Q)`` surfaces to draw the two panels + on — a pair of matplotlib `Axes` for the default renderer. A new two-panel figure is + made when omitted. Returns: - The ``(I_axes, Q_axes)`` pair used for the plot. + What the renderer gave back for each panel, ``(I, Q)``: the two `Axes` for the matplotlib + one. Raises: - ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. + KeyError: When ``renderer`` names none that is registered. + ValidationError: When a renderer other than the built-in one is asked for with no + ``target``, since the panels it would be handed are matplotlib's. + ModuleNotFoundError: When the matplotlib renderer is used without matplotlib + installed — install ``qprogram[viz]``. UnassignedVariableError: If either channel's envelope depends on a variable that has no value. """ - import matplotlib.pyplot as plt # ruff: ignore[import-outside-top-level] - - if axes is None: - _, ax_pair = plt.subplots(2, 1, sharex=True, figsize=(6, 3)) - axes = (ax_pair[0], ax_pair[1]) + draw = resolve_renderer(renderer) + style = (style or Style()).sized(IQ_ENVELOPE_SIZE) + panels = target if target is not None else _stacked_panels(style, renderer) i_env = self.get_I().envelope(resolution=resolution) q_env = self.get_Q().envelope(resolution=resolution) t = np.arange(len(i_env)) * resolution - axes[0].plot(t, i_env) - axes[0].set_ylabel("I") - axes[0].set_title(type(self).__name__) - axes[1].plot(t, q_env) - axes[1].set_ylabel("Q") - axes[1].set_xlabel("Time (ns)") - return axes + i_figure = Figure(marks=(Line(t, i_env),), x_label="", y_label="I", title=type(self).__name__) + q_figure = Figure(marks=(Line(t, q_env),), x_label="Time (ns)", y_label="Q", series=1) + return draw(i_figure, style, panels[0]), draw(q_figure, style, panels[1]) def _repr_html_(self) -> str: - """Return an inline SVG of the I/Q envelopes for Jupyter display. + """Return the I and Q envelopes as an image for Jupyter display. Returns: - The SVG markup of the stacked I and Q plots. + The markup for one cell, holding the figure drawn for a light surface and for a dark one. Raises: ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. UnassignedVariableError: If either channel's envelope depends on a variable that has no value. """ - return _waveform_svg(self.plot) + name = type(self).__name__ + return _envelope_html(lambda style: self.plot(style=style)[0], f"{name} I and Q envelopes") + + +def _stacked_panels(style: Style, renderer: str | None) -> tuple[Axes, Axes]: + """Make the two axes an IQ envelope is drawn on, stacked and sharing an x axis. + + Args: + style (Style): Read for the figure size and the surface colour behind the panels. + renderer (str | None): The renderer the caller asked for, read only to refuse the ones whose + surfaces this cannot make. + + Returns: + The ``(I, Q)`` axes, in that order. + + Raises: + ValidationError: When ``renderer`` names anything but the built-in one. + ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. + """ + if renderer is not None and renderer != DEFAULT_RENDERER: + msg = ( + f"renderer {renderer!r} has to be given target=(I, Q) to draw on: two panels sharing an " + f"x axis is a matplotlib layout, and it is the only pair this knows how to make" + ) + raise ValidationError(msg) + import matplotlib.pyplot as plt # ruff: ignore[import-outside-top-level] + + figure, axes = plt.subplots(2, 1, sharex=True, figsize=style.size) + figure.set_facecolor(style.theme.surface) + return axes[0], axes[1] + + +def _envelope_html(draw: Callable[[Style], Axes], alt: str) -> str: + """Draw an envelope for both surfaces and wrap the pair for a Jupyter cell. + + A figure drawn for a white surface is a white rectangle in a dark notebook, and the display + protocol takes no argument to say which surface is being read on. So both are drawn and the + browser picks: ```` with a ``prefers-color-scheme`` source is the plain HTML way to ask, + which is the notebook's own theme under VS Code and the operating system's under JupyterLab. A + host that strips the ```` is left with the light figure, which is what a cell had before. + + Args: + draw (Callable[[Style], Axes]): Draws the envelope with the style it is handed and returns an + axes on the figure to serialize. Both styles name no size, so it draws at whichever of + the two envelope sizes suits it. + alt (str): Alt text naming what the figure shows. + + Returns: + The markup for one cell. + + Raises: + ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. + """ + dark = _envelope_data_uri(draw, Style(theme=DARK)) + light = _envelope_data_uri(draw, Style(theme=LIGHT)) + return ( + f'' + f'{alt}' + ) -def _waveform_svg(plot_fn: Callable[[], object]) -> str: - """Capture ``plot_fn()``'s output as an inline SVG string for Jupyter ``_repr_html_``. +def _envelope_data_uri(draw: Callable[[Style], Axes], style: Style) -> str: + """Draw an envelope once and return the figure as an SVG ``data:`` URI. - The figure is closed after serialization so a notebook cell does not also render it through the - pyplot display hook. + The figure is closed after serialization, so a notebook cell does not also render it through the + pyplot display hook and a session that displays many waveforms does not trip matplotlib's + open-figure warning. Args: - plot_fn (Callable[[], object]): Plotting callable that leaves the figure it drew as pyplot's - current figure — which is what creating one through `matplotlib.pyplot.subplots` - does. Its return value is discarded; the figure is picked up with - `matplotlib.pyplot.gcf`. + draw (Callable[[Style], Axes]): Draws the envelope and returns an axes on the figure wanted. + style (Style): The style to draw with. Returns: - The SVG markup of the figure ``plot_fn`` drew. + A ``data:image/svg+xml`` URI holding the figure. Raises: ModuleNotFoundError: When ``matplotlib`` is not installed — install ``qprogram[viz]``. """ + import base64 # ruff: ignore[import-outside-top-level] import io # ruff: ignore[import-outside-top-level] import matplotlib.pyplot as plt # ruff: ignore[import-outside-top-level] - plot_fn() - buf = io.StringIO() - plt.gcf().savefig(buf, format="svg", bbox_inches="tight") - plt.close(plt.gcf()) - return buf.getvalue() + # An Axes always belongs to a figure; the annotation admits None for an axes under teardown. + figure = cast("MatplotlibFigure", draw(style).get_figure()) + buf = io.BytesIO() + figure.savefig(buf, format="svg", bbox_inches="tight") + plt.close(figure) + return "data:image/svg+xml;base64," + base64.b64encode(buf.getvalue()).decode("ascii") diff --git a/tests/conftest.py b/tests/conftest.py index d2e864a..467bdc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,13 +11,20 @@ # 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. -"""Shared fixtures for the qprogram test suite.""" +"""Shared fixtures for the qprogram test suite. + +The matplotlib backend and the figure teardown live here rather than in the modules that draw: +`filterwarnings = ["error"]` turns matplotlib's too-many-figures warning into a failure, and which +module leaked the twentieth figure is not something the failure would say. +""" from __future__ import annotations from typing import TYPE_CHECKING import _dummy_vendor +import matplotlib as mpl +import matplotlib.pyplot as plt import numpy as np import pytest @@ -29,6 +36,15 @@ if TYPE_CHECKING: from collections.abc import Iterator +mpl.use("Agg") + + +@pytest.fixture(autouse=True) +def _close_figures() -> Iterator[None]: + """Close every figure a test opened, so the suite never trips matplotlib's open-figure warning.""" + yield + plt.close("all") + @pytest.fixture def transmon_schema() -> BusSchema: diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 96b4862..155ecd6 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -19,6 +19,8 @@ from __future__ import annotations +from dataclasses import replace + import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np @@ -29,6 +31,7 @@ from qprogram import ValidationError from qprogram.plotting import ( DARK, + DEFAULT_SIZE, LIGHT, Figure, Line, @@ -44,16 +47,6 @@ 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 # --------------------------------------------------------------------------- @@ -591,22 +584,8 @@ 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_an_empty_unit_drops_the_one_it_inherited(): + assert build_figure(_hertz(), coords={"freq": Quantity(units="")}).x_label == "Drive frequency" def test_a_unit_the_coordinate_never_declared_is_taken_as_a_correction(): @@ -869,6 +848,11 @@ def test_the_default_style_is_the_light_theme(): assert Style().theme is LIGHT +def test_the_default_style_names_no_figure_size(): + """Which is what lets the same style suit a measurement and a pulse.""" + assert Style().size is None + + def test_the_two_themes_differ_where_it_matters(): assert LIGHT.surface != DARK.surface assert LIGHT.ramp[0] != DARK.ramp[0] @@ -923,15 +907,11 @@ def two(figure, style, target=None): # ruff: ignore[unused-function-argument] def test_an_unknown_renderer_lists_the_known_ones(): - with pytest.raises(KeyError, match="registered: matplotlib"): + resolve_renderer() # make sure the default is registered, so the message is not empty + with pytest.raises(KeyError, match="registered: "): 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 # --------------------------------------------------------------------------- @@ -951,6 +931,27 @@ def test_the_axis_labels_reach_the_axes(): assert ax.get_title(loc="left") == "Rabi" +def test_a_new_figure_takes_the_default_size_or_the_style_own(): + default = matplotlib_renderer.render(build_figure(_sweep_iq()), Style()) + assert tuple(default.get_figure().get_size_inches()) == DEFAULT_SIZE + named = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(size=(4.0, 3.0))) + assert tuple(named.get_figure().get_size_inches()) == (4.0, 3.0) + + +def test_sized_fills_a_size_in_and_never_overwrites_one(): + assert Style().sized((6.0, 2.0)).size == (6.0, 2.0) + named = Style(size=(4.0, 3.0)) + assert named.sized((6.0, 2.0)) is named + + +def test_the_figure_says_which_colour_slot_its_first_mark_takes(): + first = matplotlib_renderer.render(build_figure(_sweep_iq(), channels="i"), Style()) + figure = build_figure(_sweep_iq(), channels="i") + second = matplotlib_renderer.render(replace(figure, series=1), Style()) + assert first.get_lines()[0].get_color() == LIGHT.series[0] + assert second.get_lines()[0].get_color() == LIGHT.series[1] + + def test_an_existing_axes_is_drawn_on_rather_than_replaced(): _, ax = plt.subplots() returned = matplotlib_renderer.render(build_figure(_sweep_iq()), Style(), ax) diff --git a/tests/test_waveforms.py b/tests/test_waveforms.py index 6b43f26..db026f9 100644 --- a/tests/test_waveforms.py +++ b/tests/test_waveforms.py @@ -15,14 +15,19 @@ from __future__ import annotations -from typing import cast +import base64 +import re +from typing import TYPE_CHECKING, cast import matplotlib as mpl +import matplotlib.pyplot as plt import numpy as np import pytest from matplotlib.axes import Axes +import qprogram as qp from qprogram import UnassignedVariableError, ValidationError, Variable +from qprogram.plotting import DARK, ENVELOPE_SIZE, IQ_ENVELOPE_SIZE, LIGHT, Style, register_renderer from qprogram.waveforms import ( Arbitrary, Chained, @@ -45,7 +50,8 @@ Waveform, ) -mpl.use("Agg") +if TYPE_CHECKING: + from matplotlib.figure import Figure # --------------------------------------------------------------------------- # Square @@ -768,10 +774,22 @@ def test_iq_spectrum_returns_complex(): # --------------------------------------------------------------------------- -# Visualization helpers (smoke tests — visual correctness is human-checked) +# Visualization helpers (appearance is human-checked; what is decidable is asserted) # --------------------------------------------------------------------------- +def _svg_size(html: str, index: int = -1) -> tuple[float, float]: + """Return the ``(width, height)`` in points of one figure in a ``_repr_html_`` fragment. + + The fragment holds the dark render first and the light one second, so the default index is the + light figure, which is the one an ```` shows. + """ + uri = re.findall(r'data:image/svg\+xml;base64,([^"]+)', html)[index] + svg = base64.b64decode(uri).decode("utf-8") + width, height = re.findall(r'(?:width|height)="([\d.]+)pt"', svg)[:2] + return float(width), float(height) + + def test_waveform_plot_returns_axes(): ax = Square(0.5, 20).plot() assert isinstance(ax, Axes) @@ -783,14 +801,108 @@ def test_iq_plot_returns_axes_pair(): assert all(isinstance(a, Axes) for a in axes) -def test_waveform_repr_html_returns_svg(): +def test_a_style_naming_no_size_is_drawn_at_the_envelope_size(): + ax = Square(0.5, 20).plot(style=Style(theme=DARK)) + assert tuple(cast("Figure", ax.get_figure()).get_size_inches()) == ENVELOPE_SIZE + axes = IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)).plot(style=Style(theme=DARK)) + assert tuple(cast("Figure", axes[0].get_figure()).get_size_inches()) == IQ_ENVELOPE_SIZE + + +def test_the_default_style_is_drawn_at_the_envelope_size(): + ax = Square(0.5, 20).plot() + assert tuple(cast("Figure", ax.get_figure()).get_size_inches()) == ENVELOPE_SIZE + axes = IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)).plot() + assert tuple(cast("Figure", axes[0].get_figure()).get_size_inches()) == IQ_ENVELOPE_SIZE + + +def test_a_size_on_the_style_wins(): + ax = Square(0.5, 20).plot(style=Style(size=(4.0, 1.5))) + assert tuple(cast("Figure", ax.get_figure()).get_size_inches()) == (4.0, 1.5) + axes = IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)).plot(style=Style(size=(4.0, 2.5))) + assert tuple(cast("Figure", axes[0].get_figure()).get_size_inches()) == (4.0, 2.5) + + +def test_plot_draws_on_the_surface_the_style_names(): + light = Square(0.5, 20).plot() + dark = Square(0.5, 20).plot(style=Style(theme=DARK)) + assert mpl.colors.to_hex(light.get_facecolor()) == LIGHT.surface + assert mpl.colors.to_hex(dark.get_facecolor()) == DARK.surface + + +def test_plot_draws_the_envelope_in_the_first_series_colour(): + ax = Square(0.5, 20).plot() + assert ax.get_lines()[0].get_color() == LIGHT.series[0] + + +def test_iq_plot_gives_the_two_channels_the_first_two_colours(): + i_axes, q_axes = IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)).plot() + assert i_axes.get_lines()[0].get_color() == LIGHT.series[0] + assert q_axes.get_lines()[0].get_color() == LIGHT.series[1] + + +def test_plot_draws_on_the_target_it_is_given(): + _, ax = plt.subplots() + assert Square(0.5, 20).plot(target=ax) is ax + _, (top, bottom) = plt.subplots(2, 1) + assert IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)).plot(target=(top, bottom)) == (top, bottom) + + +def test_plot_uses_the_renderer_it_is_told_to(): + figures = [] + + def fake(figure, style, target=None): # ruff: ignore[unused-function-argument] + figures.append(figure) + return figure + + try: + register_renderer("test-waveform", fake) + one = Square(0.5, 20).plot(renderer="test-waveform") + _, (top, bottom) = plt.subplots(2, 1) + pair = IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)).plot(renderer="test-waveform", target=(top, bottom)) + finally: + qp.plotting.renderers._renderers.pop("test-waveform", None) + assert one.x_label == "Time (ns)" + assert one.y_label == "Amplitude" + assert one.title == "Square" + assert [f.y_label for f in pair] == ["I", "Q"] + assert [f.series for f in pair] == [0, 1] + assert len(figures) == 3 + + +def test_an_iq_pair_refuses_a_foreign_renderer_with_no_target_to_draw_on(): + def fake(figure, style, target=None): # ruff: ignore[unused-function-argument] + return figure + + wf = IQPair(I=Square(0.5, 20), Q=Square(0.3, 20)) + try: + register_renderer("test-no-panels", fake) + with pytest.raises(ValidationError, match="target=\\(I, Q\\)"): + wf.plot(renderer="test-no-panels") + finally: + qp.plotting.renderers._renderers.pop("test-no-panels", None) + + +def test_repr_html_carries_one_figure_per_surface(): html = Square(0.5, 20)._repr_html_() - assert " height + + +def test_repr_html_closes_the_figures_it_drew(): + Square(0.5, 20)._repr_html_() + assert plt.get_fignums() == [] # ---------------------------------------------------------------------------