Source code for quchip.viz.results

"""Plots of simulation results: populations, snapshots, expectations, Wigner.

These helpers turn a backend-agnostic
:class:`~quchip.results.results.SimulationResult` into Matplotlib figures,
independent of the solver backend.

References
----------
- Nielsen & Chuang, *Quantum Computation and Quantum Information*,
  Cambridge University Press (2010) — density matrices, populations.
- Wigner, "On the Quantum Correction for Thermodynamic Equilibrium",
  *Phys. Rev.* **40**, 749 (1932) — original Wigner-function definition.
- Cahill & Glauber, "Density Operators and Quasiprobability Distributions",
  *Phys. Rev.* **177**, 1882 (1969) — Laguerre-polynomial expansion used
  by :func:`_wigner_from_density_matrix`.
- Leonhardt, *Essential Quantum Optics*, Cambridge University Press (2010)
  — modern continuous-variable treatment of the Wigner function.
"""
# Partial-trace, `expect`, and ket construction all go through `Backend`.

from __future__ import annotations

from typing import Any, Literal

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.figure import Figure

from quchip.devices.base import BaseDevice
from quchip.results.input_output import SParameterResult
from quchip.utils.labeling import resolve_label
from quchip.viz._common import (
    _basis_label,
    _normalize_time_index,
    _project_density_matrix,
    _reduce_result,
    _reduce_state,
    _to_dense_array,
)
from quchip.viz._style import _cyclic_colors, _quchip_style, _resolve_dual_axes, _resolve_single_axes

StateMode = Literal["population", "dm"]


def _state_colors(
    states: list[tuple[int, ...]],
    override: dict[tuple[int, ...], str] | None = None,
) -> dict[tuple[int, ...], Any]:
    """tab20 defaults with optional per-state overrides."""
    colors = _cyclic_colors(states, "tab20")
    if override:
        colors.update(override)
    return colors


[docs] def plot_populations( result: Any, *, trace_out: str | BaseDevice | list[str | BaseDevice] | None = None, computational: bool = False, ax: Any = None, linewidth: float = 2.5, legend: bool = True, colors: dict[tuple[int, ...], str] | None = None, threshold: float = 0.01, ) -> Figure: """Plot basis-state populations over time. The x-axis is time (ns) and the y-axis is the population ``p_n(t) = Tr(|n><n| rho(t))``. One line is drawn for each represented-basis ket ``|n> = |n_1 n_2 ...>`` of the retained subsystems. States whose peak population stays below *threshold* are hidden. Set *threshold* to ``0`` to show all states. Parameters ---------- result : SimulationResult Output of :func:`quchip.engine.simulate`. trace_out : device, label, or list thereof, optional Subsystems to partial-trace over before computing populations. Accepts device objects or their string labels. The solver call must use ``states="all"``. computational : bool When ``True``, restrict computational subsystems to their ``{|0>, |1>}`` subspace. ax : matplotlib.axes.Axes, optional Existing axes to draw on. When ``None``, a new figure is created. linewidth : float Line width for all population traces. legend : bool Draw a legend of the visible states. colors : dict, optional Per-state colour overrides, keyed by the internal basis-state tuples. States not in the dict use a ``tab20`` cycle. threshold : float Populations whose maximum over time is below this value are omitted. Returns ------- Figure The figure that holds the population-trace axes (``ax.figure`` when *ax* was given). Raises ------ RuntimeError *trace_out* is given but no states were stored (pass ``states="all"`` to the solver). ValueError *trace_out* would remove all subsystems. """ times, populations, _keep, _info = _reduce_result(result, trace_out, computational=computational) visible_states = [s for s in populations if np.max(populations[s]) >= threshold] if not visible_states: visible_states = list(populations) state_colors = _state_colors(visible_states, colors) with _quchip_style(): fig, axis = _resolve_single_axes(ax) for state in visible_states: axis.plot( times, populations[state], label=_basis_label(state), linewidth=linewidth, color=state_colors[state], ) axis.set_xlabel("Time (ns)") axis.set_ylabel("Population") axis.set_ylim(-0.02, 1.05) axis.set_title("State populations", fontfamily="sans-serif") if legend and visible_states: ncol = max(1, (len(visible_states) + 2) // 3) axis.legend( ncol=ncol, fontsize="small", loc="upper center", bbox_to_anchor=(0.5, 1.0), framealpha=0.8, ) fig.tight_layout() return fig
[docs] def plot_state( result: Any, index: int, *, trace_out: str | BaseDevice | list[str | BaseDevice] | None = None, computational: bool = False, mode: StateMode = "population", ax: Any = None, cmap: str = "RdBu_r", color: str | None = None, ) -> Figure: """Plot a single stored state at time-index *index*. Two modes are supported: - ``"population"``: a bar chart of diagonal elements ``p_n``. - ``"dm"``: side-by-side heatmaps of ``Re(rho)`` and ``Im(rho)`` with the divergent colormap *cmap*. *Both* heatmaps share one symmetric normalization (``vmin=-m, vmax=+m`` for ``m = max(|Re(rho)|, |Im(rho)|)``), so their colours compare directly. Equal saturation in the two panels means equal magnitude. If computational and non-computational subsystems are both present, pass *trace_out* to focus on a target register. Alternatively, set *computational* ``= True`` to restrict computational devices to the ``{|0>, |1>}`` subspace (see Nielsen & Chuang, Ch. 2). Parameters ---------- result : SimulationResult Output of :func:`quchip.engine.simulate`, with ``states="all"``. index : int Stored-time index to plot. Python-style negative indexes are supported (``-1`` is the last stored time). The index must satisfy ``-N <= index < N`` for ``N = len(result.times)``. trace_out : device, label, or list thereof, optional Subsystems to partial-trace over before plotting. computational : bool When ``True``, restrict computational subsystems to their ``{|0>, |1>}`` subspace. mode : {"population", "dm"} The representation to draw. ax : matplotlib.axes.Axes, optional For ``mode="population"``: a single axes (or ``None`` for a new figure). For ``mode="dm"``: an iterable of exactly two axes ``(real_ax, imag_ax)`` (or ``None`` for a new 1x2 figure). cmap : str Divergent colormap for ``mode="dm"`` heatmaps. color : str, optional Bar colour override for ``mode="population"``. The default is a ``tab10`` cycle per state. Returns ------- Figure The figure that holds the plotted axes (``ax.figure`` when *ax* was given). Raises ------ IndexError *index* is outside ``[-N, N)`` for ``N = len(result.times)``. ValueError *mode* is not ``"population"`` or ``"dm"``, or *trace_out* would remove all subsystems. RuntimeError No states were stored (pass ``states="all"`` to the solver). """ index = _normalize_time_index(result, index) if mode == "population": times, populations, _keep, _info = _reduce_result( result, trace_out, computational=computational, ) states = list(populations) values = [float(populations[state][index]) for state in states] cmap10 = plt.get_cmap("tab10") bar_colors: Any = ( [cmap10(idx % cmap10.N) for idx in range(len(states))] if color is None else color ) with _quchip_style(): fig, axis = _resolve_single_axes(ax) x = np.arange(len(states)) axis.bar(x, values, color=bar_colors) axis.set_xticks(x, [_basis_label(state) for state in states]) axis.set_ylim(0.0, 1.0) axis.set_ylabel("Population") axis.set_title( f"State populations at t={float(times[index]):.3g} ns", fontfamily="sans-serif", ) fig.tight_layout() return fig if mode != "dm": raise ValueError("mode must be 'population' or 'dm'") reduced_state, keep_indices, device_info = _reduce_state(result, index, trace_out) dims = [result.dims[idx] for idx in keep_indices] backend = result._backend dm = backend.as_density_matrix(reduced_state) dense_dm = _to_dense_array(dm, backend) projected_dm, plotted_states = _project_density_matrix(dense_dm, dims, device_info, computational) labels = [_basis_label(state) for state in plotted_states] real_part, imag_part = np.real(projected_dm), np.imag(projected_dm) m = max(float(np.max(np.abs(real_part))), float(np.max(np.abs(imag_part))), 1e-12) with _quchip_style(): fig, (real_ax, imag_ax) = _resolve_dual_axes(ax) real_ax.imshow(real_part, cmap=cmap, interpolation="nearest", vmin=-m, vmax=m) imag_ax.imshow(imag_part, cmap=cmap, interpolation="nearest", vmin=-m, vmax=m) for axis, title in ((real_ax, "Re(rho)"), (imag_ax, "Im(rho)")): axis.set_title(title, fontfamily="sans-serif") axis.set_xticks(range(len(labels)), labels) axis.set_yticks(range(len(labels)), labels) fig.tight_layout() return fig
def _resolved_candidate_keys(entry: Any) -> list[Any]: """Return exact-match candidate keys for *entry*, raw form first. The raw *entry* is always a candidate — it matches already-correct keys and any exotic hashable ``resolve_label`` cannot handle. A ``resolve_label``-resolved form is added when it applies cleanly: element-wise for tuples (so a device-object correlator key like ``(q0, q1)`` resolves to ``("q0", "q1")``), or directly otherwise. ``resolve_label`` raises ``TypeError`` on values it cannot resolve (e.g. the trailing integer of a ``(key, index)`` selector); that candidate is skipped. """ candidates = [entry] try: candidates.append( tuple(resolve_label(part) for part in entry) if isinstance(entry, tuple) else resolve_label(entry) ) except TypeError: pass return candidates def _collect_expectation_traces( result: Any, keys: list[Any] | None, ) -> list[tuple[str, np.ndarray]]: """Return ``(label, values)`` traces to plot from dict-form ``e_ops``. When *keys* is ``None`` every recorded trace is returned. Otherwise each entry is matched against the registered :attr:`~quchip.results.results.SimulationResult.observable_traces` keys FIRST (see :func:`_resolved_candidate_keys`) — including tuple entries, so a legitimate correlator key like ``("q0", "q1")`` is never misread as a list-trace selector. Only when a two-element tuple does not itself resolve to a registered key is it interpreted as a ``(key, index)`` pair selecting one element of a list-valued observable. Raises ------ KeyError An entry in *keys* is neither a registered ``observable_traces`` key (raw or resolved) nor a valid ``(key, index)`` list-trace selector. """ traces_dict = result.observable_traces traces: list[tuple[str, np.ndarray]] = [] def _append(label: str, trace: Any) -> None: if isinstance(trace, list): for idx, tr in enumerate(trace): traces.append((f"{label}[{idx}]", np.asarray(tr.values))) else: traces.append((label, np.asarray(trace.values))) if keys is None: for key, trace in traces_dict.items(): _append(str(key), trace) return traces for entry in keys: for candidate in _resolved_candidate_keys(entry): if candidate in traces_dict: _append(str(entry), traces_dict[candidate]) break else: if isinstance(entry, tuple) and len(entry) == 2 and isinstance(entry[1], int): key, index = entry traces.append((f"{key}[{index}]", np.asarray(result.expect(key, index=index)))) else: raise KeyError( f"{entry!r} is not a registered observable_traces key and not a " "valid (key, index) list-trace selector" ) return traces
[docs] def plot_expectation( result: Any, *, keys: list[Any] | None = None, ax: Any = None, linewidth: float = 2.5, legend: bool = True, real: bool = True, ) -> Figure: """Plot dict-form expectation values over time. The x-axis is time (ns). The y-axis is :attr:`~quchip.results.results.SimulationResult.observable_traces` ``[key].values``, the *post-processed* recorded trace for each observable ``O`` registered in the solver's ``e_ops`` dict, which is not always ``Tr(O rho(t))``. ``.values`` depends on how you requested the observable and can already include demodulation, phase correction, or band summation (see :class:`~quchip.results.results.ObservableTrace`). Its ``.raw`` field holds the unprocessed quantity. When *real* is ``True`` (the default), only ``Re`` of the trace is drawn. When *real* is ``False``, the real part (solid) and the imaginary part (dashed, lower alpha) are drawn in the same colour per key. Parameters ---------- result : SimulationResult Output of :func:`quchip.engine.simulate`, with ``e_ops`` passed as a dict. keys : list, optional Each entry is a bare key (``"cav"``) or a ``(key, index)`` tuple that selects one element of a list-valued observable. Entries are matched against the registered ``observable_traces`` keys first (see :func:`_collect_expectation_traces`). ``resolve_label`` resolves string keys and device/drive keys, so the two forms are interchangeable. The default is all registered traces. ax : matplotlib.axes.Axes, optional Existing axes to draw on. When ``None``, a new figure is created. linewidth : float Line width for all traces. legend : bool Draw a legend of the plotted keys. real : bool When ``True``, draw only the real part of each trace. When ``False``, draw the real (solid) and imaginary (dashed) parts. Returns ------- Figure The figure that holds the expectation-trace axes (``ax.figure`` when *ax* was given). Raises ------ TypeError ``e_ops`` was not passed as a dict (``observable_traces`` is ``None``). KeyError An entry in *keys* does not resolve to a registered trace or a valid ``(key, index)`` selector. """ if not isinstance(result.observable_traces, dict): raise TypeError("plot_expectation requires dict-form e_ops") traces = _collect_expectation_traces(result, keys) times = np.asarray(result.times, dtype=float) cmap = plt.get_cmap("tab10") with _quchip_style(): fig, axis = _resolve_single_axes(ax) for idx, (label, values) in enumerate(traces): color = cmap(idx % cmap.N) axis.plot( times, np.real(values), label=label if real else f"Re({label})", linewidth=linewidth, color=color, ) if not real: axis.plot( times, np.imag(values), label=f"Im({label})", linewidth=linewidth, color=color, linestyle="--", alpha=0.7, ) axis.set_xlabel("Time (ns)") axis.set_ylabel("Expectation value") if legend and traces: axis.legend(fontsize="small", framealpha=0.8) axis.grid(True, alpha=0.3) fig.tight_layout() return fig
def _wigner_from_density_matrix(rho: np.ndarray, xvec: np.ndarray, yvec: np.ndarray) -> np.ndarray: """Evaluate W(x, p) with [X, P] = i and integral Tr(rho). QuTiP's Clenshaw expansion uses alpha = (x + i*p)/sqrt(2). See Leonhardt, Essential Quantum Optics, chapter 3. """ from qutip import Qobj, wigner return wigner(Qobj(rho), xvec, yvec, g=np.sqrt(2), method="clenshaw")
[docs] def plot_wigner( result: Any, index: int = -1, *, trace_out: str | BaseDevice | list[str | BaseDevice] | None = None, xvec: np.ndarray | None = None, yvec: np.ndarray | None = None, ax: Any = None, cmap: str = "RdBu_r", colorbar: bool = True, ) -> Figure: """Plot the Wigner quasi-probability distribution of a stored state. The axes are the phase-space quadratures ``x`` (position-like) and ``p`` (momentum-like). The colourmap is divergent and symmetric about zero, so negative regions, the hallmark of non-classical states, are easy to see. Without *xvec*, the plot window is sized from the reduced state's mean photon number ``<n> = Tr(rho n_hat)`` and extends to at least ``+/-3``. The mean photon number is computed directly from ``diag(rho)``. Exactly one subsystem must remain after *trace_out*. A Wigner function is a single-mode phase-space picture, and its basis indices are read directly as photon numbers ``n = 0, 1, 2, ...``. The function checks this condition. One more precondition cannot be seen from the result metadata, so the function does *not* check it: the retained subsystem's represented basis must *be* a photon-number ladder. This holds for a bosonic mode such as ``Resonator``, but not for a device whose represented basis is not Fock, for example a charge-basis or flux-basis qubit. If you pass such a device, the function silently makes a Wigner-shaped plot without physical meaning. Parameters ---------- result : SimulationResult Output of :func:`quchip.engine.simulate`, with ``states="all"``. index : int Stored-time index to plot. Python-style negative indexes are supported (``-1``, the default, is the last stored time). The index must satisfy ``-N <= index < N`` for ``N = len(result.times)``. trace_out : device, label, or list thereof, optional Subsystems to partial-trace over before plotting. Required when more than one subsystem is stored (see Raises). xvec, yvec : ndarray, optional Phase-space grids for the ``x``/``p`` quadratures. The default is an automatically sized, equally spaced grid (see above). If only *xvec* is given, *yvec* defaults to *xvec*. ax : matplotlib.axes.Axes, optional Existing axes to draw on. When ``None``, a new figure is created. cmap : str Divergent colormap, symmetric about zero. colorbar : bool Attach a colorbar. Returns ------- Figure The figure that holds the Wigner-function axes (``ax.figure`` when *ax* was given). Raises ------ IndexError *index* is outside ``[-N, N)`` for ``N = len(result.times)``. ValueError More or fewer than one subsystem remains after *trace_out*. The message lists the retained device labels and a *trace_out* value that isolates a single one of them. RuntimeError No states were stored (pass ``states="all"`` to the solver). References ---------- - Wigner, *Phys. Rev.* **40**, 749 (1932). - Cahill & Glauber, *Phys. Rev.* **177**, 1882 (1969). - Leonhardt, *Essential Quantum Optics* (2010), Ch. 3. """ # Computing the mean photon number from `diag(rho)` avoids an O(d^2) matmul # for a diagonal observable. index = _normalize_time_index(result, index) reduced_state, keep_indices, device_info = _reduce_state(result, index, trace_out) if len(keep_indices) != 1: retained_labels = [label for label, _computational in device_info] raise ValueError( "plot_wigner requires exactly one retained subsystem (its basis indices are " f"interpreted as photon numbers); {len(retained_labels)} are retained: " f"{retained_labels}. Pass trace_out naming all but one of these devices, e.g. " f"trace_out={retained_labels[:-1]!r} to keep only {retained_labels[-1]!r}." ) backend = result._backend dm = backend.as_density_matrix(reduced_state) rho = np.asarray(backend.to_array(dm), dtype=complex) dim = rho.shape[0] if xvec is None: n_mean = float(np.real(np.sum(np.diag(rho) * np.arange(dim)))) extent = max(np.sqrt(max(n_mean, 0.0)) * 2.5, 3.0) xvec = np.linspace(-extent, extent, 200) if yvec is None: yvec = xvec W = _wigner_from_density_matrix(rho, xvec, yvec) wmax = np.max(np.abs(W)) with _quchip_style(): fig, axis = _resolve_single_axes(ax) im = axis.contourf(xvec, yvec, W, levels=100, cmap=cmap, vmin=-wmax, vmax=wmax) axis.set_xlabel(r"$x$") axis.set_ylabel(r"$p$") axis.set_aspect("equal") t = float(result.times[index]) axis.set_title(f"Wigner function at t = {t:.1f} ns", fontfamily="sans-serif") if colorbar: fig.colorbar(im, ax=axis, label=r"$W(\alpha)$") fig.tight_layout() return fig
[docs] def plot_sparameters( result: Any, pairs: list[tuple[Any, Any]] | None = None, *, select: dict[str, int] | None = None, kind: Literal["db_phase", "magnitude", "iq"] = "db_phase", axes: Any = None, ) -> Figure: """Plot entries from a small-signal scattering result. Parameters ---------- result : SParameterResult Result that :meth:`VNA.sweep` returns. The function rejects other result types. pairs : list of (output, input), optional Matrix entries to draw. If omitted with exactly two planes, all four entries are drawn. Otherwise, the first input column is drawn, up to six entries. select : dict of str to int, optional Indices on sweep axes other than frequency. Omitted axes use index 0, and the plot title shows those defaults. Frequency stays the x axis, and you cannot select it. kind : {"db_phase", "magnitude", "iq"} ``"db_phase"`` stacks ``20 log10|S|`` in dB above unwrapped phase in degrees. ``"magnitude"`` draws ``|S|``. ``"iq"`` draws ``Im S`` against ``Re S``. axes : matplotlib.axes.Axes or iterable of matplotlib.axes.Axes, optional Existing axes to draw on: two for ``"db_phase"`` and one for the other kinds. When ``None``, create the necessary axes. Returns ------- Figure The figure that contains the plots. Raises ------ TypeError *result* is not an SParameterResult. ValueError *kind* is not ``"db_phase"``, ``"magnitude"``, or ``"iq"``, *select* names ``"frequency"``, or no sweep axis remains for the x axis. KeyError *select* contains an unknown sweep-axis name. """ if not isinstance(result, SParameterResult): raise TypeError(f"plot_sparameters requires an SParameterResult; got {type(result).__name__}.") if kind not in {"db_phase", "magnitude", "iq"}: raise ValueError(f"Unknown plot kind {kind!r}; use 'db_phase', 'magnitude', or 'iq'.") ports = result.ports if pairs is None: pairs = ( [(out, inp) for inp in ports for out in ports] if len(ports) == 2 else [(out, ports[0]) for out in ports][:6] ) names = result.axis_names select = select or {} unknown = set(select) - set(names) if unknown: raise KeyError(f"Unknown axes {sorted(unknown)}; available: {list(names)}") if "frequency" in select: raise ValueError("select indexes non-frequency axes; frequency is always the x axis.") free = [name for name in names if name not in select] if not free: raise ValueError("plot_sparameters needs one unselected sweep axis; the result has none.") sweep_axis = "frequency" if "frequency" in free else free[0] index = tuple(slice(None) if name == sweep_axis else select.get(name, 0) for name in names) x_values = np.asarray(dict(result.axes)[sweep_axis], dtype=float) defaulted = [name for name in names if name != sweep_axis and name not in select] title = f"defaults: {', '.join(f'{name}[0]' for name in defaulted)}" if defaulted else "" colors = _cyclic_colors(range(len(pairs)), "tab10") with _quchip_style(): drawn: tuple[Any, ...] if kind == "db_phase": if axes is None: fig, drawn = plt.subplots(2, 1, sharex=True, figsize=(6.0, 6.0)) else: drawn = tuple(axes) fig = drawn[0].figure else: fig, single = _resolve_single_axes(axes) drawn = (single,) for idx, (output, input_) in enumerate(pairs): values = np.asarray(result.s(output, input_))[index] label = f"S({resolve_label(output)}, {resolve_label(input_)})" color = colors[idx] if kind == "db_phase": with np.errstate(divide="ignore"): drawn[0].plot(x_values, 20.0 * np.log10(np.abs(values)), label=label, color=color) drawn[1].plot(x_values, np.degrees(np.unwrap(np.angle(values))), label=label, color=color) elif kind == "magnitude": drawn[0].plot(x_values, np.abs(values), label=label, color=color) else: drawn[0].plot(np.real(values), np.imag(values), label=label, color=color) x_label = "Frequency (GHz)" if sweep_axis == "frequency" else sweep_axis if kind == "db_phase": drawn[0].set_ylabel("|S| (dB)") drawn[1].set_ylabel("Phase (deg)") drawn[1].set_xlabel(x_label) elif kind == "magnitude": drawn[0].set_ylabel("|S|") drawn[0].set_xlabel(x_label) else: drawn[0].set_xlabel("Re S") drawn[0].set_ylabel("Im S") drawn[0].set_aspect("equal", adjustable="datalim") drawn[0].legend() if title: drawn[0].set_title(title, fontsize=9) return fig