Source code for qilisdk.utils.visualization.schedule_renderers

# Copyright 2025 Qilimanjaro Quantum Tech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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.


from __future__ import annotations

from typing import TYPE_CHECKING, Any, cast

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

from qilisdk.analog.hamiltonian import Hamiltonian

if TYPE_CHECKING:
    from qilisdk.analog.schedule import Schedule
    from qilisdk.core import QTensor
    from qilisdk.core.types import Number

from qilisdk.utils.visualization.style import ScheduleStyle


[docs] class MatplotlibScheduleRenderer: """Render a Schedule using matplotlib, with theme support.""" def __init__( self, schedule: Schedule, ax: plt.Axes | None = None, *, style: ScheduleStyle | None = None, ) -> None:
[docs] self.schedule = schedule
[docs] self.style = style or ScheduleStyle()
[docs] self.ax = ax or self._make_axes(self.style.dpi, self.style)
[docs] def setup_axes(self) -> None: style = self.style theme = style.theme title_color = theme.on_background label_color = theme.on_background legend_facecolor = theme.surface legend_edgecolor = theme.border tick_color = theme.on_background # Set axes and figure background to theme facecolor = theme.background self.ax.set_facecolor(facecolor) if hasattr(self.ax, "figure"): self.ax.figure.set_facecolor(facecolor) if style.grid: grid_style = dict(style.grid_style) if "color" not in grid_style: grid_style["color"] = theme.surface_muted self.ax.grid(**grid_style) leg = self.ax.legend( loc=cast("Any", style.legend_loc), fontsize=style.legend_fontsize, frameon=style.legend_frame, facecolor=legend_facecolor, edgecolor=legend_edgecolor, ) # Set legend text color to match theme text color if leg: for text in leg.get_texts(): text.set_color(title_color) self.ax.set_title( self.style.title or "Schedule", fontsize=style.title_fontsize, color=title_color, fontweight=style.fontweight, family=style.fontfamily, ) self.ax.set_xlabel( style.xlabel, fontsize=style.label_fontsize, color=label_color, fontweight=style.fontweight, family=style.fontfamily, ) self.ax.set_ylabel( style.ylabel, fontsize=style.label_fontsize, color=label_color, fontweight=style.fontweight, family=style.fontfamily, ) self.ax.tick_params(axis="x", labelsize=style.xtick_fontsize, colors=tick_color) self.ax.tick_params(axis="y", labelsize=style.ytick_fontsize, colors=tick_color) if style.tight_layout: plt.tight_layout()
# Generate gradient colors between primary and accent @staticmethod
[docs] def hex_to_rgb(hex_color: str) -> tuple[int, ...]: hex_color = hex_color.lstrip("#") return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
@staticmethod
[docs] def rgb_to_hex(rgb: tuple[int, ...]) -> str: return "#{:02x}{:02x}{:02x}".format(*rgb)
[docs] def gradient_colors(self, start_hex: str, end_hex: str, n: int) -> list[str]: start_rgb = self.hex_to_rgb(start_hex) end_rgb = self.hex_to_rgb(end_hex) colors = [] for i in range(n): ratio = i / max(n - 1, 1) rgb = tuple(int(start_rgb[j] + (end_rgb[j] - start_rgb[j]) * ratio) for j in range(3)) colors.append(self.rgb_to_hex(rgb)) return colors
[docs] def plot(self, ax: plt.Axes | None = None) -> None: """ Plot the schedule coefficients for each Hamiltonian over time. Args: ax (plt.Axes | None): The matplotlib axes to plot on. Default is None. """ style = self.style theme = style.theme logger.debug("[ScheduleRenderer] Rendering schedule with {} Hamiltonians", len(self.schedule.hamiltonians)) plots: dict[str, list[Number]] = {} hamiltonians = self.schedule.hamiltonians times = self.schedule.tlist for h in hamiltonians: coef = self.schedule.coefficients[h] plots[h] = [coef[float(t)] for t in times] n_hams = len(hamiltonians) grad_colors = self.gradient_colors(theme.primary, theme.accent, n_hams) for idx, h in enumerate(hamiltonians): line_style = style.line_styles.get(h, style.default_line_style) marker = style.marker # If no color specified, use gradient color if "color" not in line_style: color = grad_colors[idx] line_style = {**line_style, "color": color} self.ax.plot( times, plots[h], label=h, marker=marker, markersize=style.marker_size, **line_style, # ty:ignore[invalid-argument-type] ) self.setup_axes() plt.draw()
[docs] def save(self, filename: str) -> None: # thin wrapper """Save current figure to disk. Args: filename: Path to save the figure (e.g., 'circuit.png'). """ logger.debug("[ScheduleRenderer] Saving figure to {}", filename) if isinstance(self.ax.figure, Figure): self.ax.figure.savefig(filename, bbox_inches="tight")
[docs] def show(self) -> None: # ruff: ignore[no-self-use] """Show the current figure.""" plt.show()
@staticmethod def _make_axes(dpi: int, style: ScheduleStyle) -> plt.Axes: """ Create a new figure and axes with the given DPI. Args: style: Optional style configuration (for DPI). Returns: A newly created Matplotlib Axes. """ _, ax = plt.subplots(figsize=style.figsize, dpi=dpi or style.dpi, facecolor=style.theme.background) return ax
[docs] class MatplotlibEigenvalueRenderer(MatplotlibScheduleRenderer): """Render the eigenvalues Schedule using matplotlib, with theme support.""" def __init__( self, schedule: Schedule, ax: plt.Axes | None = None, *, style: ScheduleStyle | None = None, levels: int = 2, intermediate_states: list[QTensor] | None = None, show_overlaps: bool = True, ) -> None:
[docs] self.schedule: Schedule = schedule
[docs] self.style = style or ScheduleStyle(xlabel="Time", ylabel="Eigenvalue")
[docs] self.ax = ax or self._make_axes(self.style.dpi, self.style)
[docs] self.levels = levels
[docs] self.intermediate_states = intermediate_states
[docs] self.show_overlaps = show_overlaps
def _calculate_expectation_values(self) -> list[float]: actual_expectation_values = [] tlist = self.schedule.tlist if self.intermediate_states: if len(self.intermediate_states) < len(tlist): raise ValueError( f"Length of intermediate_states must match length of schedule tlist. Got {len(self.intermediate_states) if self.intermediate_states else 0} states and {len(tlist)} time points." ) for i in range(len(tlist)): state = self.intermediate_states[i] full_hamiltonian = sum( self.schedule.coefficients[h][float(self.schedule.tlist[i])] * self.schedule.hamiltonians[h] for h in self.schedule.hamiltonians ) if not isinstance(full_hamiltonian, Hamiltonian): raise ValueError(f"Expected full_hamiltonian to be a Hamiltonian, got {type(full_hamiltonian)}") as_qtensor = full_hamiltonian.to_qtensor(total_nqubits=self.schedule.nqubits) exp_val = state.expectation_value(as_qtensor) actual_expectation_values.append(float(exp_val.real)) return actual_expectation_values @staticmethod def _calculate_overlaps( state: QTensor, eigenstates: list[QTensor], eigenvalues: list[list[float]], time_index: int, eigen_range: float, sig_figs: int, ) -> list[tuple[float, float]]: overlaps = [] for j, eig in enumerate(eigenstates): overlap = 100.0 * state.fidelity(eig) y_loc = eigenvalues[j][time_index] if overlap > 10 ** (-sig_figs): overlaps.append((y_loc, overlap)) # Group nearby overlaps together to avoid clutter grouped_overlaps: list[tuple[float, float]] = [] for overlap in overlaps: found_group = False for idx, grouped_overlap in enumerate(grouped_overlaps): if abs(grouped_overlap[0] - overlap[0]) < 0.05 * eigen_range: # If within 5% of the eigenvalue range, group them together by averaging the y location and summing the overlap percentage new_y_loc = (grouped_overlap[0] + overlap[0]) / 2 new_overlap = grouped_overlap[1] + overlap[1] grouped_overlaps[idx] = (new_y_loc, new_overlap) found_group = True break if not found_group: grouped_overlaps.append(overlap) return grouped_overlaps
[docs] def plot(self, ax: plt.Axes | None = None) -> None: """ Plot the schedule coefficients for each Hamiltonian over time. Args: ax (plt.Axes | None): The matplotlib axes to plot on. Default is None. Raises: ValueError: If the full Hamiltonian cannot be constructed or is not a Hamiltonian instance. """ style = self.style theme = style.theme logger.debug("[ScheduleRenderer] Rendering eigenvalue schedule with {} levels", self.levels) plots: dict[str, list[Number]] = {} hamiltonians: dict[str, Hamiltonian] = self.schedule.hamiltonians times = self.schedule.tlist for h in hamiltonians: coef = self.schedule.coefficients[h] plots[h] = [coef[float(t)] for t in times] n_hams = len(hamiltonians) grad_colors = self.gradient_colors(theme.primary, theme.accent, n_hams) # Plot the eigenvalues of the full Hamiltonian as solid lines full_eigenvalues, full_eigenstates = self.schedule.eig(self.levels) actual_expectation_values = self._calculate_expectation_values() min_eigenvalue = min(min(evs) for evs in full_eigenvalues) max_eigenvalue = max(max(evs) for evs in full_eigenvalues) eigen_range = max_eigenvalue - min_eigenvalue color = grad_colors[-1] if grad_colors else theme.accent # We have the eigenvalues as a list of lists (one list per time step), # but we want to plot each eigenvalue trajectory over time, so we need to transpose the list of lists new_eigenvalues: list[list[float]] = [] for i in range(len(full_eigenvalues[0])): # iterate over eigenvalues at each time step new_eigenvalues.append([full_eigenvalues[j][i] for j in range(len(full_eigenvalues))]) full_eigenvalues = new_eigenvalues # only show the id for the first one for idx, evs in enumerate(full_eigenvalues): label = "Eigenvalues" if idx == 0 and self.intermediate_states else None self.ax.plot( times, evs, label=label, linestyle="--", color=color, ) if self.intermediate_states and actual_expectation_values: self.ax.plot( times, actual_expectation_values, label="State Expectation Value", linestyle="-", color="black", zorder=10, ) # Every 10% of the way through, write the overlap with each of the eigenstates at that time if self.show_overlaps: time_steps = list(range(0, len(times), max(1, len(times) // 9))) time_steps.append(len(times) - 1) for i in time_steps: t = times[i] eigenstates = full_eigenstates[i] state = self.intermediate_states[i] _sig_figs = 3 grouped_overlaps = self._calculate_overlaps( state, eigenstates[: self.levels], full_eigenvalues[: self.levels], i, eigen_range, _sig_figs ) # Plot each overlap with an arrow pointing to the eigenvalue, and annotate with the percentage for overlap in grouped_overlaps: overlap_text = f"{overlap[1]:.{_sig_figs}f}%" y_loc = overlap[0] self.ax.annotate( overlap_text, xy=(t, y_loc), xytext=(t, y_loc + 0.5), arrowprops={"arrowstyle": "->", "color": theme.on_background}, color=theme.on_background, bbox={"boxstyle": "round,pad=0.2", "fc": theme.surface, "ec": "none", "alpha": 0.8}, fontsize=style.label_fontsize * 0.4, ha="center", va="bottom", zorder=15, ) self.setup_axes() plt.draw()