Source code for quchip.viz.chip

"""Chip topology (pyvis) and dressed-spectrum (matplotlib) plots."""

from __future__ import annotations

from pathlib import Path
from typing import Any

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

from quchip.chip.chip import Chip
from quchip.utils.jax_utils import maybe_concrete_scalar
from quchip.viz._common import _basis_label, _draw_energy_ladder
from quchip.viz._style import _GRAPH_PALETTE, _cyclic_colors, _quchip_style, _resolve_single_axes

_NODE_SHAPES = {"device": "dot", "drive": "diamond", "coupling": "square"}
_NODE_SIZES = {"device": 25, "drive": 15, "coupling": 12}
_EDGE_COLORS = {"coupling": "#4d4d4d", "wiring": "#ff7f0e", "crosstalk": "#b22222"}
_EDGE_LENGTHS = {"coupling": 150, "wiring": 80, "crosstalk": 300}
_VALUE_MODES = {"bare", "dressed", "both"}


def _device_node_id(label: str) -> str:
    """Namespace a device label as a topology node id (``"device:<label>"``)."""
    return f"device:{label}"


def _coupling_node_id(label: str) -> str:
    """Namespace a coupling label as a topology node id (``"coupling:<label>"``)."""
    return f"coupling:{label}"


def _drive_node_id(label: str) -> str:
    """Namespace a drive label as a topology node id (``"drive:<label>"``)."""
    return f"drive:{label}"


def _frequency_text(value: Any, *, precision: int = 3) -> str:
    """Format a concrete frequency in GHz, or ``"n/a"`` when unavailable."""
    concrete = maybe_concrete_scalar(value)
    return "n/a" if concrete is None else f"{concrete:.{precision}f} GHz"


def _device_label(device: Any, *, dressed_frequency: Any = None, values: str = "bare") -> str:
    """Return a device label annotated with bare, dressed, or both frequencies."""
    bare_text = _frequency_text(getattr(device, "freq", None))
    dressed_text = _frequency_text(dressed_frequency, precision=6)
    if values == "bare":
        return f"{device.label}\n{bare_text}"
    if values == "dressed":
        return f"{device.label}\nf01={dressed_text}"
    return f"{device.label}\nbare={bare_text}\nf01={dressed_text}"


def _coupling_label(coupling: Any, *, dressed_kerr: Any = None, values: str = "bare") -> tuple[str, str]:
    """Return junction and edge labels for bare strength and/or dressed cross-Kerr."""
    strength = maybe_concrete_scalar(coupling.coupling_strength)
    strength_text = "n/a" if strength is None else f"{strength:.3g} GHz"
    kerr = maybe_concrete_scalar(dressed_kerr)
    kerr_text = "n/a" if kerr is None else f"{kerr:.3g} GHz"
    class_name = type(coupling).__name__
    if values == "bare":
        physics_lines = [f"g={strength_text}"]
    elif values == "dressed":
        physics_lines = [f"K={kerr_text}"]
    else:
        physics_lines = [f"g={strength_text}", f"K={kerr_text}"]
    edge_label = "\n".join((class_name, *physics_lines))
    return f"{coupling.label}\n{edge_label}", edge_label


def _control_label(drive: Any) -> str:
    """Return the multi-line node label for *drive* (edge pumps always name their pumped coupling)."""
    from quchip.control.drive import CouplingDrive

    class_name = type(drive).__name__
    if isinstance(drive, CouplingDrive):
        return f"{drive.label}\n({class_name})\n→ {drive.target_label}"
    if drive.label:
        return f"{drive.label}\n({class_name})"
    return f"{class_name}\n→ {drive.device_label}"


def _collect_topology(
    chip: Chip,
    *,
    exclude: set[str] | None = None,
    values: str = "bare",
) -> tuple[dict[str, dict[str, Any]], list[tuple[str, str, dict[str, Any]]]]:
    """Collect node/edge dicts for devices, couplings, drives, and crosstalks.

    Every coupling is its own junction node splitting the device-device
    edge in two (``device -- junction -- device``), so an edge-pump
    control (:class:`~quchip.control.drive.ParametricDrive`) has a node
    to attach to — it modulates the coupling element itself, not either
    endpoint device. Node ids are namespaced by kind
    (``"device:q0"``, ``"coupling:tc"``, ``"drive:pump"``) so a device,
    coupling, and drive that happen to share a user-chosen label can
    never collide or silently overwrite one another in *nodes*.

    When couplings are excluded from the render (``"coupling" in
    exclude``), any edge-pump drives that would otherwise attach to a
    now-absent junction node are omitted too, rather than left dangling.

    Returns ``(nodes, edges)`` where *nodes* maps namespaced node id to
    attributes (insertion-ordered: devices, then couplings, then
    drives) and *edges* is a list of ``(start, end, attributes)``
    triples of namespaced node ids.
    """
    if values not in _VALUE_MODES:
        raise ValueError(f"values must be one of {sorted(_VALUE_MODES)}, got {values!r}")

    _exclude = exclude or set()
    nodes: dict[str, dict[str, Any]] = {}
    edges: list[tuple[str, str, dict[str, Any]]] = []
    coupling_labels: set[str] = set()
    dressed_frequencies: dict[str, Any] = {}
    dressed_kerr: Any = None
    if values != "bare":
        dressed_frequencies = chip.freq()
        dressed_kerr = chip.kerr_matrix()

    for device in chip.devices:
        nodes[_device_node_id(device.label)] = {
            "kind": "device",
            "label": _device_label(
                device,
                dressed_frequency=dressed_frequencies.get(device.label),
                values=values,
            ),
            "class_name": type(device).__name__,
            "computational": device.computational,
        }

    if "coupling" not in _exclude:
        for coupling in chip.couplings:
            coupling_labels.add(coupling.label)
            junction_id = _coupling_node_id(coupling.label)
            junction_label, edge_label = _coupling_label(
                coupling,
                dressed_kerr=(
                    None
                    if dressed_kerr is None
                    else dressed_kerr[coupling.device_a_label, coupling.device_b_label]
                ),
                values=values,
            )
            nodes[junction_id] = {
                "kind": "coupling",
                "label": junction_label,
                "class_name": type(coupling).__name__,
                "computational": False,
            }
            edges.append((
                _device_node_id(coupling.device_a_label), junction_id, {"kind": "coupling", "label": edge_label},
            ))
            edges.append((
                junction_id, _device_node_id(coupling.device_b_label), {"kind": "coupling", "label": edge_label},
            ))

    if "drive" not in _exclude and chip.control_equipment is not None:
        from quchip.control.drive import CouplingDrive

        for drive in chip.control_equipment.lines:
            is_edge_pump = isinstance(drive, CouplingDrive)
            if is_edge_pump and drive.target_label not in coupling_labels:
                # No junction node to attach to — either couplings were
                # excluded from this render, or the pump's coupling isn't
                # on this chip. Either way, omit the dangling control.
                continue
            drive_id = _drive_node_id(drive.label)
            nodes[drive_id] = {
                "kind": "drive",
                "label": _control_label(drive),
                "class_name": type(drive).__name__,
                "computational": False,
            }
            target_id = (
                # target_label is non-None here: edge-pump implies a
                # ParametricDrive, whose target_label override returns str,
                # never None.
                _coupling_node_id(drive.target_label)  # type: ignore[arg-type]
                if is_edge_pump
                # device_label is non-None here: Chip.connect() rejects unconnected
                # drives before they reach control_equipment.lines.
                else _device_node_id(drive.device_label)  # type: ignore[arg-type]
            )
            edges.append((
                drive_id,
                target_id,
                {"kind": "wiring", "label": f"{type(drive).__name__} → {drive.target_label}"},
            ))

        if "crosstalk" not in _exclude:
            for xt in chip.crosstalks:
                # Only connect channels the drive pass actually rendered: a
                # crosstalk edge must never resurrect a drive that was
                # deliberately omitted (an edge pump whose coupling is
                # excluded from this render).
                source_id, victim_id = _drive_node_id(xt.source), _drive_node_id(xt.victim)
                if source_id not in nodes or victim_id not in nodes:
                    continue
                beta = maybe_concrete_scalar(abs(xt.beta))
                beta_text = "n/a" if beta is None else f"{beta:.3g}"
                edges.append((
                    source_id, victim_id,
                    {"kind": "crosstalk", "label": f"|xi|={beta_text}"},
                ))

    return nodes, edges


[docs] def plot_graph( chip: Chip, path: str = "chip_topology.html", *, full: bool = True, exclude: set[str] | None = None, values: str = "bare", layout: str = "force_atlas", height: str = "800px", width: str = "100%", ) -> str: """Write an interactive HTML chip topology and return its path. The HTML file is standalone and works offline. Devices show as dots and drives as diamonds. Edge colour and dash pattern identify couplings, control wiring, and crosstalk. Node colours follow the *class* (``DuffingTransmon``, ``Resonator``, ``ChargeDrive``, ...) automatically, so new kinds of device and drive look different without user action. Layouts: - ``"force_atlas"`` (default): ``force_atlas_2based`` physics with larger and heavier computational devices, so the register sits at the center and the auxiliary couplers and drives orbit it. - ``"hierarchical"``: pyvis ``hrepulsion`` on explicit levels (computational devices → other devices/couplings → drives). It is useful for chips with many auxiliary drives or cross-resonance lines. *exclude* has priority over *full*. If you pass ``full=False`` and no *exclude*, the plot hides drives and crosstalk (chip-only view). Each coupling renders as its own junction node that splits the device-device edge, so an edge-pump control has a node to attach to. Excluding couplings also removes all edge-pump controls that would otherwise dangle. ``values="bare"`` keeps the declaration view: device frequencies and coupling strengths come directly from component parameters, without diagonalization. ``values="dressed"`` labels devices with dressed :math:`f_{01}` and coupling junctions with the full-pull cross-Kerr :math:`K_{ab}`. ``values="both"`` shows both annotations. These modes do not change the topology. Parameters ---------- chip : Chip The chip to visualise. path : str Destination ``.html`` path, also returned. full : bool If ``False``, hide drives and crosstalk unless *exclude* is given. exclude : set of str, optional The items of ``{"coupling", "drive", "crosstalk"}`` to hide fully. values : {"bare", "dressed", "both"} Physical values that show on device and coupling nodes. The default is the declared bare values. layout : str ``"force_atlas"`` or ``"hierarchical"``. height, width : str CSS dimensions passed to ``pyvis.network.Network``. Returns ------- str *path*, unchanged, so you can chain the call into code that opens or serves the file. Raises ------ ImportError ``pyvis`` is not installed. ValueError *layout* or *values* is not one of its supported choices. """ # The removal of dangling edge-pump controls is implemented in # `_collect_topology`. try: from pyvis.network import Network except ImportError as exc: raise ImportError("pyvis is required for graph visualization: pip install 'quchip[viz]'") from exc valid_layouts = {"force_atlas", "hierarchical"} if layout not in valid_layouts: raise ValueError(f"layout must be one of {sorted(valid_layouts)}, got {layout!r}") if exclude is None and not full: exclude = {"drive", "crosstalk"} nodes, edges = _collect_topology(chip, exclude=exclude, values=values) class_names = list(dict.fromkeys(data["class_name"] for data in nodes.values())) class_colors = _cyclic_colors(class_names, _GRAPH_PALETTE) net = Network(height=height, width=width, directed=False, notebook=False, cdn_resources="in_line") if layout == "force_atlas": net.force_atlas_2based( gravity=-50, central_gravity=0.005, spring_length=150, spring_strength=0.04, damping=0.5, overlap=1, ) else: # layout == "hierarchical" (the only other option; validated above) net.hrepulsion( node_distance=200, central_gravity=0.1, spring_length=200, spring_strength=0.05, damping=0.15, ) net.neighborhood_highlight = True net.select_menu = False net.set_edge_smooth("continuous") net.toggle_hide_edges_on_drag(True) net.toggle_stabilization(True) net.set_options('{"physics": {"stabilization": {"iterations": 200}}}') use_levels = layout == "hierarchical" for node, data in nodes.items(): kind = data["kind"] is_computational = data.get("computational", False) node_kwargs: dict[str, Any] = { "label": data["label"], "title": data["label"], "color": class_colors.get(data["class_name"], "#999999"), "shape": _NODE_SHAPES.get(kind, "dot"), } if use_levels: node_kwargs["size"] = _NODE_SIZES.get(kind, 20) if kind == "drive": node_kwargs["level"] = 2 elif is_computational: node_kwargs["level"] = 0 else: node_kwargs["level"] = 1 elif is_computational: node_kwargs["size"], node_kwargs["mass"] = 30, 3 elif kind == "device": node_kwargs["size"], node_kwargs["mass"] = 20, 2 elif kind == "coupling": node_kwargs["size"], node_kwargs["mass"] = 10, 1 else: node_kwargs["size"], node_kwargs["mass"] = 12, 1 net.add_node(node, **node_kwargs) for start, end, data in edges: kind = data["kind"] edge_kwargs: dict[str, Any] = { "title": data["label"], "color": _EDGE_COLORS.get(kind, "#4d4d4d"), "dashes": kind == "crosstalk", "width": 2.5 if kind == "coupling" else 1.5, } if not use_levels: edge_kwargs["length"] = _EDGE_LENGTHS.get(kind, 150) net.add_edge(start, end, **edge_kwargs) net.write_html(path) # Center tooltip text on multi-line labels. out = Path(path) out.write_text(out.read_text().replace( "<head>", "<head>\n<style>.vis-tooltip { text-align: center; }</style>", 1, )) return path
[docs] def plot_energy_levels( chip: Chip, *, ax: Any = None, max_states: int | None = None, ) -> Figure: """Plot dressed chip eigenenergies relative to the ground state. Each level shows as a horizontal bar with its dressed represented-basis tuple label ``|n_1 n_2 ...>``. This label is the per-device bare-basis assignment that the chip's dressing analysis selects (see ``chip.analysis``). The y-axis is energy in GHz, with the ground state shifted to zero. Parameters ---------- chip : Chip Chip whose dressed spectrum is plotted. ax : matplotlib.axes.Axes, optional Existing axes to draw on. When ``None``, the function makes a new figure. max_states : int, optional Number of lowest levels to show. The default is ``min(12, total_levels)``. Returns ------- Figure The figure that holds the energy-ladder axes (``ax.figure`` when *ax* was given). Examples -------- >>> import quchip as qc >>> from quchip.viz.chip import plot_energy_levels >>> qb0 = qc.DuffingTransmon(freq=5.0, anharmonicity=-0.3, levels=3) >>> qb1 = qc.DuffingTransmon(freq=5.2, anharmonicity=-0.3, levels=3) >>> chip = qc.Chip([qb0, qb1], [qc.Capacitive(qb0, qb1, g=0.005)]) >>> fig = plot_energy_levels(chip, max_states=6) """ dressed = chip._ensure_dressed() levels = sorted(dressed.dressed_eigenvalues.items(), key=lambda item: item[1]) if max_states is None: max_states = min(12, len(levels)) displayed = levels[:max_states] ground_energy = 0.0 if not displayed else float(displayed[0][1]) line_color = plt.get_cmap("tab10")(0) entries = [(float(energy) - ground_energy, _basis_label(state)) for state, energy in displayed] with _quchip_style(): fig, axis = _resolve_single_axes(ax) _draw_energy_ladder(axis, entries, color=line_color, linewidth=2.0) axis.set_ylabel("Energy relative to ground (GHz)") axis.set_title("Dressed energy levels", fontfamily="sans-serif") fig.tight_layout() return fig