Source code for qarp.plotting._circuit_plotter

import uuid
from dataclasses import dataclass
from io import StringIO
from typing import Any, Dict, List, Optional, Tuple

import matplotlib.pyplot as plt
from IPython.display import HTML, display

from ._config import LabelMode, PlotConfig
from ._processors.circuit_processor import CircuitProcessor, CircuitValidationError
from ._renderers import LabelInfo, RendererFactory
from .styles._colors import get_text_color
from .styles._theme import qarp_rc


def _in_notebook() -> bool:
    """True when running inside a Jupyter/IPython notebook kernel."""
    try:
        from IPython import get_ipython

        shell = get_ipython()
        return shell is not None and shell.__class__.__name__ == "ZMQInteractiveShell"
    except Exception:
        return False


# matplotlib's file/headless backends.  Calling ``plt.show()`` under any of
# these cannot open a window and only emits a ``UserWarning`` (e.g.
# "FigureCanvasAgg is non-interactive, and thus cannot be shown") — which is
# what surfaced under pytest/CI, where the backend defaults to Agg.
_NON_INTERACTIVE_BACKENDS = {"agg", "pdf", "ps", "svg", "template", "cairo", "pgf"}


def _backend_can_display() -> bool:
    """True if the active matplotlib backend can actually open a window.

    Used to skip ``plt.show()`` on headless/file backends, where it would be a
    no-op that only warns.
    """
    return plt.get_backend().lower() not in _NON_INTERACTIVE_BACKENDS


[docs] @dataclass class GateInfo: """Information about a gate in the circuit plot for querying.""" index: int # Gate index in the plot gate_type: str # Type of gate (e.g., "Ry", "CX", "U3") full_label: str # Full label including parameters displayed_label: str # Label as displayed in the plot (may be truncated) qubits: List[str] # List of qubit names the gate acts on x_position: float # X position in the plot y_position: float # Y position (center) in the plot command: Any # The original qx.Command object def __repr__(self) -> str: qubits_str = ", ".join(self.qubits) return f"Gate[{self.index}]: {self.full_label} on [{qubits_str}]" def __str__(self) -> str: qubits_str = ", ".join(self.qubits) lines = [ f"Gate Index: {self.index}", f"Gate Type: {self.gate_type}", f"Full Expression: {self.full_label}", f"Displayed As: {self.displayed_label}", f"Qubits: [{qubits_str}]", ] return "\n".join(lines)
[docs] class CircuitPlotter: def __init__(self, config: Optional[PlotConfig] = None): self.config = config or PlotConfig() self.processor = CircuitProcessor(self.config) self.renderer_factory = RendererFactory(self.config) self._gate_registry: List[GateInfo] = [] # Stores gate info for querying self._measure_payload: List[Dict] = [] # Terminal-measure tooltip entries self._plot_uid: str = "" # Per-plot id scoping SVG gids for interactivity @property def gates(self) -> List[GateInfo]: """Get list of all gates from the last plotted circuit. Returns: List of GateInfo objects containing full gate information. """ return self._gate_registry
[docs] def query_gate(self, index: int) -> Optional[GateInfo]: """Query a gate by its index to get full information. Args: index: The gate index as shown in the plot. Returns: GateInfo object with full gate details, or None if index not found. Example: >>> plotter.plot(circuit, show_gate_indices=True) >>> gate = plotter.query_gate(3) >>> print(gate) Gate Index: 3 Gate Type: Ry Full Expression: Ry(0.123456789*pi + theta) Displayed As: Ry Qubits: [q[0]] """ for gate in self._gate_registry: if gate.index == index: return gate return None
[docs] def get_inner_circuit(self, index: int): """Extract the inner circuit from a box gate by its index. This method works with boxed sub-blocks (plain or quantum-controlled) that contain inner circuits. Args: index: The gate index as shown in the plot. Returns: The inner Circuit if the gate is a box type, None otherwise. Example: >>> plotter.plot(circuit, show_gate_indices=True) >>> inner = plotter.get_inner_circuit(5) # Get inner circuit of gate #5 >>> if inner: ... plot(inner) # Plot the inner circuit """ gate = self.query_gate(index) if gate is None: print(f"No gate found at index {index}") return None cmd = gate.command op = cmd.op # Try to get the inner circuit from the operation # Works for plain and quantum-controlled block boxes try: # First, try to get circuit directly (plain block box) if hasattr(op, "get_circuit"): return op.get_circuit() # For controlled block boxes, get the inner op first if hasattr(op, "get_op"): inner_op = op.get_op() if hasattr(inner_op, "get_circuit"): return inner_op.get_circuit() print( f"Gate '{gate.gate_type}' at index {index} is not a box type with an inner circuit." ) return None except Exception as e: print(f"Could not extract inner circuit: {e}") return None
[docs] def list_gates(self) -> None: """Print a summary of all gates from the last plotted circuit.""" if not self._gate_registry: print("No gates recorded. Plot a circuit first.") return print(f"Total gates: {len(self._gate_registry)}\n") for gate in self._gate_registry: print(repr(gate))
[docs] def plot_inner(self, index: int, **kwargs) -> Optional["CircuitPlotter"]: """Plot the inner circuit of a box gate. This is a convenience method that extracts and plots the inner circuit of a boxed sub-block (plain or quantum-controlled) in a single call. Args: index: The gate index as shown in the plot. **kwargs: Additional arguments passed to plot() (e.g., figsize, save_fig). Returns: A new CircuitPlotter instance for the inner circuit, or None if the gate is not a box type. Example: >>> plotter = plot(circuit, show_gate_indices=True, return_plotter=True) >>> inner_plotter = plotter.plot_inner(5) # Plot inner circuit of gate #5 """ inner_circuit = self.get_inner_circuit(index) if inner_circuit is None: return None # Create a new plotter for the inner circuit inner_plotter = CircuitPlotter(config=self.config) inner_plotter.plot(inner_circuit, **kwargs) return inner_plotter
[docs] def plot( self, circ, figsize: Optional[Tuple[float, float]] = None, save_fig: Optional[str] = None, decompose_boxes: bool = False, flatten_layers: bool = True, invert_order: bool = False, scrollable: Optional[bool] = None, interactive: Optional[bool] = None, use_latex: bool = False, spacing: Optional[float] = None, verbose: bool = False, label_mode: Optional[LabelMode] = None, show_gate_indices: Optional[bool] = None, _show: bool = True, ): """Main class for plotting quantum circuits. Args: circ: The quantum circuit to plot. figsize: Size of the figure (width, height). If None, size is auto-calculated. save_fig: If provided, saves the figure to the given file path. decompose_boxes: Whether to decompose box gates before plotting. flatten_layers: Whether to flatten layers in the circuit. invert_order: Whether to invert the order of qubits in the plot. scrollable: If True, display the plot in a scrollable HTML container (for Jupyter). If None (default), auto-detects: scrollable inside a Jupyter notebook, plain ``plt.show()`` otherwise. interactive: If True (default when the scrollable HTML path is used), the notebook output gains hover tooltips — full gate expression, qubits, gate index — with the rest of the circuit dimmed while a gate is hovered. Pure client-side (no widgets), so it survives ``nbconvert`` to HTML. Set False for the plain static SVG. use_latex: Whether to use LaTeX for rendering text. spacing: Horizontal spacing between gates. If None, uses default from config. verbose: If True, prints additional information about the circuit. label_mode: How to handle gate labels. Options: - LabelMode.TRUNCATE: Show only gate names without parameters (e.g., "Ry" instead of "Ry(0.5)") - LabelMode.SMART: Intelligent positioning to avoid overlaps (default) - LabelMode.FULL: Full labels without adjustment (may overlap) show_gate_indices: If True, display small index numbers near each gate. Use query_gate(index) or list_gates() to get full gate information. """ # Override spacing if provided if spacing is not None: self.config.spacing = spacing # Override label_mode if provided if label_mode is not None: self.config.label_mode = label_mode # Recreate the renderer factory with updated config self.renderer_factory = RendererFactory(self.config) # Override show_gate_indices if provided if show_gate_indices is not None: self.config.show_gate_indices = show_gate_indices # Reset label manager and gate registry before each plot self.renderer_factory.reset_label_manager() self._gate_registry = [] self._measure_payload = [] self._plot_uid = uuid.uuid4().hex[:8] # Process circuit data try: circuit_data = self.processor.process_circuit( circ, decompose_boxes, flatten_layers, invert_order ) except CircuitValidationError as e: raise ValueError(str(e)) from e # Surface validation failures as ValueError to callers if verbose: self._print_circuit_info(circuit_data) # Create figure if figsize is None: # Height tracks the full y-axis span — ``set_ylim(-1, n_qubits)`` adds # one unit of padding above and below — so the per-wire spacing stays # constant (0.5 in/wire) regardless of qubit count. Scaling by # ``n_qubits`` alone squashes the wires of small circuits. figsize = ( (circuit_data.n_layers + 1) * self.config.spacing * 4, (circuit_data.n_qubits + 1) / 2 * 0.9, # 0.9 to adjust for less vertical spacing ) # Theme rc params scoped to this figure only — never mutate the user's # global rcParams. Rendering (show/savefig) must also happen inside # the context: fonts resolve at draw time. with plt.rc_context(qarp_rc(use_latex)): fig, ax = plt.subplots(figsize=figsize) # Draw circuit elements self._draw_qubit_lines( ax, circuit_data.qubits, circuit_data.y_positions, circuit_data.n_layers * self.config.spacing, ) prev_label_y: LabelInfo = (None, None) for idx, cmd in enumerate(circuit_data.non_measure_cmds): x = circuit_data.x_positions[idx] * self.config.spacing prev_label_y = self._draw_gate( ax, cmd, x, circuit_data.y_positions, prev_label_y, gate_index=idx ) if circuit_data.n_measures > 0: self._draw_measurements( ax, circuit_data.commands, (circuit_data.n_layers + 1) * self.config.spacing, circuit_data.y_positions, ) # Set axis properties ax.set_ylim(-1, circuit_data.n_qubits) last_x = ( circuit_data.x_positions[-1] * self.config.spacing if circuit_data.x_positions else 0 ) ax.set_xlim(-self.config.spacing / 2, last_x + 2 * self.config.spacing) ax.axis("off") # Handle output if scrollable is None: scrollable = _in_notebook() if _show: if scrollable: self._create_scrollable_output( fig, interactive=interactive if interactive is not None else True ) elif _backend_can_display(): plt.show() # else: headless/file backend (e.g. Agg under pytest/CI) — plt.show() # can't render and would only emit a UserWarning, so skip it. The # figure is left open exactly as the no-op plt.show() would leave it. else: plt.close(fig) if save_fig: fig.savefig(save_fig, bbox_inches="tight") if verbose: print(f"Figure saved to {save_fig}")
def _draw_gate( self, ax, cmd, x: float, y_positions: dict, prev_label_y: LabelInfo, gate_index: Optional[int] = None, ) -> LabelInfo: """Draw a gate using appropriate renderer and optionally record gate info.""" gate_type = self.renderer_factory.classify_gate(cmd) renderer = self.renderer_factory.get_renderer(gate_type) before = list(ax.get_children()) result = renderer.render(ax, cmd, x, y_positions, prev_label_y) # Record gate info for querying if gate_index is not None: # Tag this gate's artists so the notebook SVG can attach hover # behavior to them (gids survive into matplotlib's SVG output). before_ids = {id(a) for a in before} for j, artist in enumerate(a for a in ax.get_children() if id(a) not in before_ids): artist.set_gid(f"qarp-{self._plot_uid}-g{gate_index}-a{j}") full_label = str(cmd.op) # Get displayed label from the label manager displayed_label = self.renderer_factory.label_manager.format_label(full_label) # Extract gate type name paren_idx = full_label.find("(") gate_type_name = full_label[:paren_idx] if paren_idx > 0 else full_label # Calculate y position (center of gate) y_coords = [y_positions[q] for q in cmd.qubits] y_center = sum(y_coords) / len(y_coords) if y_coords else 0.0 # Store gate info gate_info = GateInfo( index=gate_index, gate_type=gate_type_name, full_label=full_label, displayed_label=displayed_label, qubits=[str(q) for q in cmd.qubits], x_position=x, y_position=y_center, command=cmd, ) self._gate_registry.append(gate_info) # Draw gate index if enabled (skip for global phase gates with no qubits) if self.config.show_gate_indices and y_coords: # Position the index slightly above and to the right of the gate y_top = max(y_coords) ax.text( x + self.config.spacing * 0.15, y_top + 0.25, str(gate_index), fontsize=self.config.gate_index_size, color=self.config.gate_index_color, ha="left", va="bottom", weight="bold", zorder=10, # bbox=dict( # boxstyle="circle,pad=0.15", # facecolor="white", # edgecolor="red", # linewidth=0.5, # alpha=0.8, # ), ) return result def _draw_qubit_lines(self, ax, qubits, y_positions, x_end): """Draw horizontal lines for qubits.""" for q in qubits: y = y_positions[q] ax.hlines( y, -self.config.spacing / 2, x_end + self.config.spacing, color=self.config.wire_color, linewidth=1.4, zorder=0, ) label_kwargs = dict( va="center", fontsize=self.config.text_size, color=self.config.muted_color ) ax.text(-self.config.spacing, y, str(q), ha="right", **label_kwargs) ax.text(x_end + 1.5 * self.config.spacing, y, str(q), ha="left", **label_kwargs) def _draw_measurements(self, ax, commands, x, y_positions): """Draw measurement operations.""" k = 0 for cmd in commands: if str(cmd.op).lower() == "measure": before_ids = {id(a) for a in ax.get_children()} y = y_positions[cmd.qubits[0]] measure_color = self.config.gate_colors["measure"] if self.config.gate_style == "outline": # Meter box: amber outline on a tinted face, ink glyph. face, edge = self.config.gate_face_edge(measure_color) ax.plot( x, y, "s", mfc=face, mec=edge, mew=self.config.gate_edge_width, markersize=self.config.gate_size, ) ax.text( x, y, "M", ha="center", va="center", fontsize=self.config.text_size, color=self.config.text_color, ) else: ax.plot( x, y, ">", color=measure_color, markersize=self.config.gate_size, ) ax.text( x, y, "M", ha="right", va="center", fontsize=self.config.text_size, color=get_text_color(measure_color), ) for j, artist in enumerate(a for a in ax.get_children() if id(a) not in before_ids): artist.set_gid(f"qarp-{self._plot_uid}-m{k}-a{j}") self._measure_payload.append( {"key": f"m{k}", "qubits": [str(q) for q in cmd.qubits]} ) k += 1 def _print_circuit_info(self, circuit_data): """Print circuit information.""" print("Circuit information:") print(f" Qubits: {circuit_data.n_qubits}") print(f" Operations (measurements excluded): {len(circuit_data.non_measure_cmds)}") print(f" Global phase: {circuit_data.global_phase}") def _create_scrollable_output(self, fig, interactive: bool = False): """Create scrollable (optionally hover-interactive) HTML output.""" f = StringIO() fig.savefig(f, format="svg", bbox_inches="tight") svg = f.getvalue() f.close() if interactive and (self._gate_registry or self._measure_payload): from ._interactive import build_payload, interactive_html payload = build_payload(self._gate_registry, self._measure_payload) html = interactive_html(svg, payload, self._plot_uid) else: html = f""" <div style="overflow-x:auto; border:1px solid #ccc; width:100%;"> {svg} </div> """ display(HTML(html)) plt.close(fig)