diff --git a/pyproject.toml b/pyproject.toml
index 76cda4b..1255711 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,6 +27,7 @@ requires-python = ">=3.10"
dependencies = [
"matplotlib>=3.10.5, <4",
+ "plotly>=6.5.0, <7",
"pyscf>=2.10, <3",
"qiskit[visualization]>=2.2, <3",
"pauli-prop>=0.1.0, <1",
diff --git a/qiskit_addon_slc/visualization/__init__.py b/qiskit_addon_slc/visualization/__init__.py
index 3f95641..de2a035 100644
--- a/qiskit_addon_slc/visualization/__init__.py
+++ b/qiskit_addon_slc/visualization/__init__.py
@@ -20,6 +20,8 @@
This module provides visualization methods for shaded lightcones.
+.. autofunction:: animate_shaded_lightcone
+
.. autofunction:: draw_shaded_lightcone
.. autofunction:: accumulate_filtered_bounds
@@ -37,6 +39,7 @@
from ..bounds.commutator_bounds import Bounds
from .accumulate_filtered_bounds import accumulate_filtered_bounds
+from .animate_shaded_lightcone import animate_shaded_lightcone
from .overlay_bounds import overlay_bounds_onto_circuit
from .render_bounds import render_bounds
@@ -78,6 +81,7 @@ def draw_shaded_lightcone(
__all__ = [
"accumulate_filtered_bounds",
+ "animate_shaded_lightcone",
"draw_shaded_lightcone",
"overlay_bounds_onto_circuit",
"render_bounds",
diff --git a/qiskit_addon_slc/visualization/animate_shaded_lightcone.py b/qiskit_addon_slc/visualization/animate_shaded_lightcone.py
new file mode 100644
index 0000000..f3d3ad3
--- /dev/null
+++ b/qiskit_addon_slc/visualization/animate_shaded_lightcone.py
@@ -0,0 +1,570 @@
+# This code is a Qiskit project.
+#
+# (C) Copyright IBM 2025.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+# Warning: this module is not documented and it does not have an RST file.
+# If we ever publicly expose interfaces users can import from this module,
+# we should set up its RST file.
+
+"""An animated visualization of a shaded lightcone, powered by ``plotly``."""
+
+import numpy as np
+from plotly import graph_objects as go
+from plotly.colors import sample_colorscale
+from qiskit import QuantumCircuit
+from qiskit.converters import circuit_to_dag
+from qiskit.quantum_info import PauliLindbladMap
+from qiskit.transpiler import CouplingMap
+from qiskit_ibm_runtime.visualization.utils import get_rgb_color, pie_slice
+
+from ..bounds.commutator_bounds import Bounds
+from ..utils import find_indices, iter_circuit
+
+
+def _get_coords(coupling_map: CouplingMap) -> list[tuple[int, int]]:
+ graph = coupling_map.graph
+ assigned = set()
+ rows: list[set[int]] = [set()]
+ for node in graph.node_indices()[1:]:
+ prev_node = node - 1
+ if prev_node in graph.neighbors_undirected(node):
+ rows[-1].update({prev_node, node})
+ assigned.update({prev_node, node})
+ continue
+ rows.append(set())
+
+ rows = [row for row in rows if len(row) > 0]
+
+ unassigned = set(graph.node_indices()) - assigned
+
+ cur_y = 0
+ cur_row = 0
+ next_row_start = min(rows[cur_row + 1])
+
+ # fix 0 to x=0 and the resulting first row
+ x_pos = {i: i for i in sorted(rows[0])}
+ y_pos = {i: cur_y for i in rows[0]}
+
+ for node in sorted(unassigned):
+ if node > next_row_start:
+ cur_row += 1
+ cur_y += 2
+ for node_ in rows[cur_row]:
+ y_pos[node_] = cur_y
+ if node_ in x_pos:
+ continue
+ for other_node in rows[cur_row]:
+ if other_node in x_pos:
+ x_pos[node_] = x_pos[other_node] + (node_ - other_node)
+ break
+ next_row_start = min(rows[cur_row + 1])
+
+ for neighbor in sorted(graph.neighbors_undirected(node)):
+ if neighbor in x_pos:
+ x = x_pos[neighbor]
+
+ for neighbor in graph.neighbors_undirected(node):
+ if neighbor not in x_pos:
+ x_pos[neighbor] = x
+ x_pos[node] = x
+ y_pos[node] = cur_y + 1
+
+ # final row
+ cur_row += 1
+ cur_y += 2
+ for node in rows[cur_row]:
+ y_pos[node] = cur_y
+ if node in x_pos:
+ continue
+ for other_node in rows[cur_row]:
+ if other_node in x_pos:
+ x_pos[node] = x_pos[other_node] + (node - other_node)
+ break
+
+ coords = [(y_pos[qb], x_pos[qb]) for qb in sorted(x_pos)]
+ return coords
+
+
+def _restrict_num_bodies(plm: PauliLindbladMap, num_qubits: int) -> PauliLindbladMap:
+ if num_qubits < 0:
+ raise ValueError("``num_qubits`` must be ``0`` or larger.")
+ paulis = plm.get_qubit_sparse_pauli_list_copy().to_pauli_list()
+ mask = np.sum(paulis.x | paulis.z, axis=1) == num_qubits
+ return paulis[mask], plm.rates[mask] + 1e-17
+
+
+def animate_shaded_lightcone(
+ circuit: QuantumCircuit,
+ bounds: Bounds,
+ coupling_map: CouplingMap,
+ *,
+ reverse: bool = False,
+) -> go.Figure:
+ """Animates a shaded lightcone.
+
+ This animation permits visualization of a shaded lightcone on top of a QPU's coupling map,
+ making interpretation of shaded lightcones easier for circuits that act on qubits with
+ connectivity higher than a 1D line.
+
+ Args:
+ circuit: the circuit whose shaded lightcone to animate.
+ bounds: the bounds to use for the shaded lightcone.
+ coupling_map: the qubit connectivity map onto which to project the 1- and 2-weight bounds.
+ reverse: whether to animate the circuit layers in reverse order.
+
+ Returns:
+ The ``plotly`` figure.
+ """
+ color_no_data = "lightgray"
+ color_out_of_scale = "lightred"
+ background_color = "white"
+ highest_rate = 2.01
+ edge_width = 4
+ radius = 0.25
+ height = 1000
+ width = 1000
+ colorscale = "viridis"
+
+ # fig = go.Figure(layout=go.Layout(width=width, height=height))
+ frames = []
+
+ sliders_dict = {
+ "active": 0,
+ "yanchor": "top",
+ "xanchor": "left",
+ "currentvalue": {
+ "font": {"size": 16},
+ "prefix": "Box: ",
+ "visible": True,
+ "xanchor": "right",
+ },
+ "transition": {"duration": 300, "easing": "cubic-in-out"},
+ "pad": {"t": 0},
+ "len": 0.9,
+ "x": 0.1,
+ "y": 0,
+ "steps": [],
+ }
+
+ coordinates = _get_coords(coupling_map)
+
+ dag = circuit_to_dag(circuit)
+ idle_qubits = set(dag.idle_wires(ignore=["barrier"]))
+ active_qubits_ = set(dag.qubits) - idle_qubits
+ active_qubit_indices = set(find_indices(circuit, list(active_qubits_))) # type: ignore[arg-type]
+
+ # The coordinates come in the format ``(row, column)`` and place qubit ``0`` in the bottom row.
+ # We turn them into ``(x, y)`` coordinates for convenience, multiplying the ``ys`` by ``-1`` so
+ # that the map matches the map displayed on the ibmq website.
+ ys = [-row for row, _ in coordinates]
+ xs = [col for _, col in coordinates]
+
+ # Add a line for each edge
+ all_edges = set(tuple(sorted(edge)) for edge in list(coupling_map))
+ data = []
+ for q1, q2 in all_edges:
+ x0 = xs[q1]
+ x1 = xs[q2]
+ y0 = ys[q1]
+ y1 = ys[q2]
+
+ edge = go.Scatter(
+ x=[x0, x1],
+ y=[y0, y1],
+ hoverinfo="skip",
+ # hovertemplate="No data",
+ mode="lines",
+ line={
+ "color": color_no_data,
+ "width": edge_width,
+ },
+ showlegend=False,
+ name="",
+ )
+ data.append(edge)
+
+ colorbar = go.Scatter(
+ x=[float("NaN")],
+ y=[float("NaN")],
+ marker=dict(
+ size=0,
+ cmax=2,
+ cmin=0,
+ color=[float("NaN")],
+ colorbar={
+ "title": "",
+ "x": 0.95,
+ },
+ colorscale=colorscale,
+ ),
+ showlegend=False,
+ name="",
+ )
+ data.append(colorbar)
+
+ for _, qargs, box_id, _ in iter_circuit(circuit, reverse=reverse, log_process=False):
+ if box_id is None:
+ continue
+
+ if box_id not in bounds:
+ # HACK: remove me!
+ break
+
+ layer_error = bounds[box_id].apply_layout(qargs, circuit.num_qubits)
+
+ layout = go.Layout(width=width, height=height)
+
+ # A set of unique edges ``(i, j)``, with ``i < j``.
+ edges = set(tuple(sorted(edge)) for edge in list(coupling_map))
+
+ # The highest rate
+ max_rate = 0
+
+ # Initialize a dictionary of one-qubit errors
+ paulis_1q, rates_1q_ = _restrict_num_bodies(layer_error, 1)
+ rates_1q: dict[int, dict[str, float]] = {
+ qubit: {} for qubit in coupling_map.physical_qubits
+ }
+ for pauli, rate in zip(paulis_1q, rates_1q_, strict=True):
+ qubit_idx = np.where(pauli.x | pauli.z)[0][0]
+ rates_1q[qubit_idx][str(pauli[qubit_idx])] = rate
+ max_rate = max(max_rate, rate)
+
+ # Initialize a dictionary of two-qubit errors
+ paulis_2q, rates_2q_ = _restrict_num_bodies(layer_error, 2)
+ rates_2q: dict[tuple[int, int], dict[str, float]] = {edge: {} for edge in edges}
+ for pauli, rate in zip(paulis_2q, rates_2q_, strict=True):
+ err_idxs = tuple(sorted([i for i, q in enumerate(pauli) if str(q) != "I"]))
+ edge = (err_idxs[0], err_idxs[1])
+ rates_2q[edge][str(pauli[[err_idxs[0], err_idxs[1]]])] = rate
+ max_rate = max(max_rate, rate)
+
+ highest_rate = highest_rate if highest_rate else max_rate
+
+ # A discrete colorscale that contains 1000 hues.
+ discrete_colorscale = sample_colorscale(colorscale, np.linspace(0, 1, 1000))
+
+ # Plot the pie charts showing X, Y, and Z for each qubit
+ shapes = []
+ # hoverinfo_1q = [] # the info displayed when hovering over the pie charts
+ for qubit, (x, y) in enumerate(zip(xs, ys, strict=True)):
+ # hoverinfo = ""
+ for pauli, angle in [("Z", -30), ("X", 90), ("Y", 210)]:
+ rate = rates_1q.get(qubit, {}).get(pauli, 0)
+ # print(qubit, pauli, rate)
+ fillcolor = get_rgb_color(
+ discrete_colorscale, rate / highest_rate, color_no_data, color_out_of_scale
+ )
+ line_color = "black"
+ if fillcolor == color_no_data:
+ line_color = color_no_data
+ if qubit in active_qubit_indices:
+ line_color = "black"
+ shapes += [
+ {
+ "type": "path",
+ "path": pie_slice(angle, angle + 120, x, y, radius),
+ "fillcolor": fillcolor,
+ "line_color": line_color,
+ "line_width": 1,
+ },
+ ]
+
+ # if rate:
+ # hoverinfo += f"
{pauli}: {rate}"
+ # hoverinfo_1q += [hoverinfo or "No data"]
+
+ # Add annotation with qubit label
+ # fig.add_annotation(x=x + 0.3, y=y + 0.4, text=f"{qubit}", showarrow=False)
+
+ for q1, q2 in edges:
+ # NOTE: x > 0
+ x0 = xs[q1]
+ x1 = xs[q2]
+ xmin = min(x0, x1) + 0.25
+ xmax = max(x0, x1) - 0.25
+ if xmin > xmax:
+ xmin, xmax = xmax, xmin
+ # NOTE: y < 0
+ y0 = ys[q1]
+ y1 = ys[q2]
+ ymin = min(y0, y1) + 0.25
+ ymax = max(y0, y1) - 0.25
+ if ymin > ymax:
+ ymin, ymax = ymax, ymin
+
+ locs = {
+ "XX": {
+ "x0": xmin,
+ "x1": xmax - 1 / 3,
+ "y0": ymin + 1 / 3,
+ "y1": ymax,
+ "line_width": 2,
+ },
+ "XY": {
+ "x0": xmin + 1 / 6,
+ "x1": xmax - 1 / 6,
+ "y0": ymin + 1 / 3,
+ "y1": ymax,
+ },
+ "XZ": {
+ "x0": xmin + 1 / 3,
+ "x1": xmax,
+ "y0": ymin + 1 / 3,
+ "y1": ymax,
+ },
+ "YX": {
+ "x0": xmin,
+ "x1": xmax - 1 / 3,
+ "y0": ymin + 1 / 6,
+ "y1": ymax - 1 / 6,
+ },
+ "YY": {
+ "x0": xmin + 1 / 6,
+ "x1": xmax - 1 / 6,
+ "y0": ymin + 1 / 6,
+ "y1": ymax - 1 / 6,
+ },
+ "YZ": {
+ "x0": xmin + 1 / 3,
+ "x1": xmax,
+ "y0": ymin + 1 / 6,
+ "y1": ymax - 1 / 6,
+ },
+ "ZX": {
+ "x0": xmin,
+ "x1": xmax - 1 / 3,
+ "y0": ymin,
+ "y1": ymax - 1 / 3,
+ },
+ "ZY": {
+ "x0": xmin + 1 / 6,
+ "x1": xmax - 1 / 6,
+ "y0": ymin,
+ "y1": ymax - 1 / 3,
+ },
+ "ZZ": {
+ "x0": xmin + 1 / 3,
+ "x1": xmax,
+ "y0": ymin,
+ "y1": ymax - 1 / 3,
+ },
+ }
+
+ if rates_2q[(q1, q2)].values():
+ for pauli, rate in rates_2q[(q1, q2)].items():
+ if pauli not in locs:
+ continue
+ fillcolor = get_rgb_color(
+ discrete_colorscale, rate / highest_rate, color_no_data, color_out_of_scale
+ )
+ shapes += [
+ {
+ "type": "rect",
+ "fillcolor": fillcolor,
+ "line_color": "black",
+ "line_width": 1,
+ **locs[pauli],
+ },
+ ]
+
+ # hoverinfo_2q = ""
+ # for pauli, rate in rates_2q[(q1, q2)].items():
+ # hoverinfo_2q += f"
{pauli}: {rate}"
+
+ elif q1 in active_qubit_indices and q2 in active_qubit_indices:
+ for pauli in locs:
+ shapes += [
+ {
+ "type": "rect",
+ "fillcolor": color_no_data,
+ "line_color": "black",
+ "line_width": 1,
+ **locs[pauli],
+ },
+ ]
+
+ # Add a "legend" pie to show how pies work
+ x_legend = max(xs) - 3.0
+ y_legend = 1
+ for pauli, angle in [("Z", -30), ("X", 90), ("Y", 210)]:
+ shapes += [
+ {
+ "type": "path",
+ "path": pie_slice(angle, angle + 120, x_legend, y_legend, 0.5),
+ "fillcolor": color_no_data,
+ "line_color": "black",
+ "line_width": 1,
+ "label": {"text": f"{pauli}"},
+ },
+ ]
+
+ # Add a "legend" square to show how edges work
+ xmin = x_legend + 1.0
+ xmax = x_legend + 3.0
+ ymin = y_legend - 0.5
+ ymax = y_legend + 0.5
+
+ locs = {
+ "XX": {
+ "x0": xmin,
+ "x1": xmax - 4 / 3,
+ "y0": ymin + 2 / 3,
+ "y1": ymax,
+ "line_width": 2,
+ },
+ "XY": {
+ "x0": xmin + 4 / 6,
+ "x1": xmax - 4 / 6,
+ "y0": ymin + 2 / 3,
+ "y1": ymax,
+ },
+ "XZ": {
+ "x0": xmin + 4 / 3,
+ "x1": xmax,
+ "y0": ymin + 2 / 3,
+ "y1": ymax,
+ },
+ "YX": {
+ "x0": xmin,
+ "x1": xmax - 4 / 3,
+ "y0": ymin + 2 / 6,
+ "y1": ymax - 2 / 6,
+ },
+ "YY": {
+ "x0": xmin + 4 / 6,
+ "x1": xmax - 4 / 6,
+ "y0": ymin + 2 / 6,
+ "y1": ymax - 2 / 6,
+ },
+ "YZ": {
+ "x0": xmin + 4 / 3,
+ "x1": xmax,
+ "y0": ymin + 2 / 6,
+ "y1": ymax - 2 / 6,
+ },
+ "ZX": {
+ "x0": xmin,
+ "x1": xmax - 4 / 3,
+ "y0": ymin,
+ "y1": ymax - 2 / 3,
+ },
+ "ZY": {
+ "x0": xmin + 4 / 6,
+ "x1": xmax - 4 / 6,
+ "y0": ymin,
+ "y1": ymax - 2 / 3,
+ },
+ "ZZ": {
+ "x0": xmin + 4 / 3,
+ "x1": xmax,
+ "y0": ymin,
+ "y1": ymax - 2 / 3,
+ },
+ }
+ for pauli in locs:
+ shapes += [
+ {
+ "type": "rect",
+ "fillcolor": color_no_data,
+ "line_color": "black",
+ "line_width": 1,
+ "label": {"text": f"{pauli}"},
+ **locs[pauli],
+ },
+ ]
+
+ layout.shapes = shapes
+
+ frame = go.Frame(data=[], layout=layout, name=box_id)
+ frames.append(frame)
+
+ slider_step = {
+ "args": [
+ [box_id],
+ {
+ "frame": {"duration": 300, "redraw": False},
+ "mode": "immediate",
+ "transition": {"duration": 300},
+ },
+ ],
+ "label": box_id,
+ "method": "animate",
+ }
+ sliders_dict["steps"].append(slider_step) # type: ignore[attr-defined]
+
+ # Set x and y range
+ fig = go.Figure(
+ data=data,
+ layout=frames[0].layout,
+ frames=frames,
+ )
+
+ fig.update_layout(
+ updatemenus=[
+ {
+ "buttons": [
+ {
+ "args": [
+ None,
+ {
+ "frame": {"duration": 500, "redraw": False},
+ "fromcurrent": True,
+ "transition": {"duration": 300, "easing": "quadratic-in-out"},
+ },
+ ],
+ "label": "Play",
+ "method": "animate",
+ },
+ {
+ "args": [
+ [None],
+ {
+ "frame": {"duration": 0, "redraw": False},
+ "mode": "immediate",
+ "transition": {"duration": 0},
+ },
+ ],
+ "label": "Pause",
+ "method": "animate",
+ },
+ ],
+ "direction": "left",
+ "pad": {"r": 10, "t": 22},
+ "showactive": False,
+ "type": "buttons",
+ "x": 0.1,
+ "xanchor": "right",
+ "y": 0,
+ "yanchor": "top",
+ }
+ ],
+ sliders=[sliders_dict],
+ )
+
+ fig.update_xaxes(
+ range=[min(xs) - 1, max(xs) + 2],
+ showticklabels=False,
+ showgrid=False,
+ zeroline=False,
+ )
+ fig.update_yaxes(
+ range=[min(ys) - 1, max(ys) + 1],
+ showticklabels=False,
+ showgrid=False,
+ zeroline=False,
+ )
+
+ # Ensure that the circle is non-deformed
+ fig.update_yaxes(scaleanchor="x", scaleratio=1)
+ fig.update_layout(plot_bgcolor=background_color)
+
+ return fig