Source code for qilisdk.utils.visualization.style

# 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 pathlib import Path
from typing import Any, Literal, Optional

import matplotlib.font_manager as fm
from pydantic import BaseModel, Field

from .themes import Theme, light

_DEFAULT_FONT_PATH = Path(__file__).parent / "PlusJakartaSans-SemiBold.ttf"

# Descriptions of the fields shared by several of the styles below
_FIGSIZE_DESCRIPTION = "Figure size in inches (width, height)."
_TIGHT_LAYOUT_DESCRIPTION = "Whether to use matplotlib's tight_layout for figure spacing."
_TITLE_FONTSIZE_DESCRIPTION = "Font size for the plot title."
_LEGEND_FONTSIZE_DESCRIPTION = "Font size for legend text."
_LEGEND_FRAME_DESCRIPTION = "Whether to draw a frame around the legend."


[docs] class Style(BaseModel): # --- FontProperties-mapped fields (mirror matplotlib.font_manager.FontProperties) --- # If `fontfname` exists, it takes precedence and loads the exact TTF.
[docs] theme: Theme = Field(default=light, description="Colour theme.")
[docs] fontfamily: str | list[str] | None = Field( default=None, description="Font family name(s), e.g. 'Outfit' or ['Outfit', 'DejaVu Sans']." )
[docs] fontstyle: Literal["normal", "italic", "oblique"] = Field( default="normal", description="Font style: 'normal', 'italic', or 'oblique'." )
[docs] fontvariant: Literal["normal", "small-caps"] = Field( default="normal", description="Font variant: typically 'normal' or 'small-caps'." )
[docs] fontweight: str | int = Field( default="normal", description="Font weight: 'normal', 'bold', 'light', or numeric (100-900)." )
[docs] fontstretch: str | int = Field( default="normal", description="Width/condensation: 'ultra-condensed'..'ultra-expanded' or numeric." )
[docs] fontsize: float | str = Field( default=10, description="Font size in pt or keywords like 'small', 'medium', 'large'." )
[docs] fontfname: str | None = Field( default=str(_DEFAULT_FONT_PATH), description="Absolute path to the TTF/OTF file. If present, overrides family." )
[docs] math_fontfamily: str | None = Field(default=None, description="Math text family, e.g. 'dejavusans', 'cm', or None.")
[docs] dpi: int = Field(default=150, description="Figure DPI.")
[docs] title: str | None = Field(default=None, description="Figure title.")
@property
[docs] def font(self) -> fm.FontProperties: """ Construct a Matplotlib FontProperties from the configured fields. If `fontfname` points to a real file, it is used (and overrides family). """ return fm.FontProperties( family=self.fontfamily, style=self.fontstyle, variant=self.fontvariant, weight=self.fontweight, stretch=self.fontstretch, size=self.fontsize, fname=self.fontfname, math_fontfamily=self.math_fontfamily, )
[docs] class QTensorStyle(Style): """All visual parameters controlling the appearance of a QTensor plot."""
[docs] sphere_points: int = Field( default=50, description="Number of points to use when plotting the Bloch sphere surface." )
[docs] sphere_color: str = Field(default="#1f77b4", description="Color for the Bloch sphere surface (hex or named color).")
[docs] arrow_color: str = Field(default="#1f77b4", description="Color for the state vector arrow (hex or named color).")
[docs] arrow_length_ratio: float = Field( default=0.1, description="Length of the arrow head as a fraction of the arrow length (e.g. 0.1 means the head is 10% of the total arrow length).", )
[docs] draw_center_circle: bool = Field( default=True, description="Whether to draw a circle around the centre of the Bloch sphere for reference.", )
[docs] centre_circle_color: str = Field( default="#1f77b4", description="Color for a circle drawn around the centre of the Bloch sphere for reference (hex or named color).", )
[docs] draw_reference_points: bool = Field( default=True, description="Whether to draw reference points (|0⟩, |1⟩, |+⟩, |-⟩, |+i⟩, |-i⟩) on the Bloch sphere for orientation.", )
[docs] reference_point_distance: float = Field( default=1.2, description="Distance from the origin to place the reference point labels (|0⟩, |1⟩, etc.) on the Bloch sphere.", )
[docs] rotation_style: Literal["azel", "trackball", "sphere", "arcball"] = Field( default="azel", description="Mouse rotation style for 3D plots.", )
[docs] class CircuitStyle(Style): """All visual parameters controlling the appearance of a circuit plot."""
[docs] end_wire_ext: int = Field(default=2, description="Extra space after last layer.")
[docs] padding: float = Field(default=0.3, description="Padding around drawing (inches).")
[docs] gate_margin: float = Field(default=0.15, description="Left/right margin per gate.")
[docs] wire_sep: float = Field(default=0.5, description="Vertical separation of wires.")
[docs] layer_sep: float = Field(default=0.5, description="Horizontal separation of layers.")
[docs] gate_pad: float = Field(default=0.05, description="Padding around gate text.")
[docs] label_pad: float = Field(default=0.1, description="Padding before wire label.")
[docs] bulge: str = Field(default="round", description="Box-style for gate rectangles.")
[docs] align_layer: bool = Field(default=True, description="Align layers across wires.")
[docs] wire_label: list[Any] | None = Field(default=None, description="Custom wire labels.")
[docs] start_pad: float = Field( default=0.1, description="Minimum spacing (inches) before the first layer so wire labels fit." )
[docs] min_gate_h: float = Field(default=0.2, description="Minimum gate box height (inches).")
[docs] min_gate_w: float = Field(default=0.2, description="Minimum gate box width (inches).")
[docs] connector_r: float = Field( default=0.01, description="Radius (inches) of small connector dots on multi-target gates." )
[docs] target_r: float = Field(default=0.12, description="Radius (inches) of ⊕ target circle and SWAP half-width.")
[docs] control_r: float = Field(default=0.05, description="Radius (inches) of a filled control dot.")
[docs] layout: Literal["normal", "compact"] = Field( default="normal", description="If 'compact' minimizes the layers to highlight circuit depth, if 'normal' conserves the order of the circuit", )
[docs] class HamiltonianStyle(Style): """All visual parameters controlling the appearance of a Hamiltonian interaction graph.""" # Figure
[docs] figsize: Optional[tuple] = Field(default=(7, 6), description=_FIGSIZE_DESCRIPTION)
[docs] tight_layout: bool = Field(default=True, description=_TIGHT_LAYOUT_DESCRIPTION)
[docs] title_fontsize: int = Field(default=16, description=_TITLE_FONTSIZE_DESCRIPTION)
# Graph layout
[docs] layout: Literal["spring", "circular", "shell", "spiral", "random"] = Field( default="spring", description="Rustworkx layout algorithm used to position the qubit nodes.",
[docs] layout_seed: int = Field(default=42, description="Seed for the randomized layouts ('spring' and 'random').")
[docs] positions: Optional[dict[int, tuple[float, float]]] = Field(
[docs] default=None,
description="Explicit qubit positions, keyed by qubit index. Overrides `layout` when provided.", )
# Nodes
[docs] node_radius: float = Field(
[docs] default=0.3,
description="Node radius as a fraction of the smallest distance between two nodes in the layout.", )
[docs]
[docs] min_node_radius: float = Field(
default=0.05, description="Lower bound on the node radius, in normalized layout units." )
[docs]
[docs] show_qubit_labels: bool = Field(default=True, description="Whether to label each node with its qubit index.")
[docs] qubit_label_fontsize: int = Field(default=11, description="Font size for the qubit index labels.")
[docs] show_field_labels: bool = Field( default=True, description="Whether to write the Pauli type inside each local-field slice of a node."
[docs] field_label_fontsize: int = Field(default=10, description="Font size for the local-field labels inside the nodes.")
# Couplings
[docs]
[docs] coupling_linewidth: float = Field(default=2.5, description="Line width of the coupling edges.")
[docs] coupling_curvature: float = Field( default=0.3, description="Curvature offset between parallel edges when several coupling types share the same qubit pair.", )
[docs] coupling_line_styles: dict[str, Any] = Field( default_factory=lambda: {"ZZ": "-", "XX": "--", "YY": "-.", "XZ": ":", "ZX": ":"}, description="Matplotlib line style per coupling type. Types not listed cycle through the remaining styles.",
[docs] default_coupling_line_styles: list[Any] = Field( default_factory=lambda: ["-", "--", "-.", ":", (0, (3, 1, 1, 1, 1, 1))],
[docs] description="Line styles cycled through for coupling types missing from `coupling_line_styles`.",
)
[docs] show_coupling_labels: bool = Field(
[docs] default=False,
description="Whether to annotate each coupling edge with its Pauli type. Off by default, since the legend already maps line styles to coupling types.", )
[docs]
[docs] coupling_label_fontsize: int = Field(default=9, description="Font size for the coupling type labels.")
[docs] show_multi_body: bool = Field( default=True,
[docs] description="Whether to draw terms acting on three or more qubits as a star-shaped hyperedge.",
)
[docs] # Colour scale
[docs] colormap: str | None = Field( default=None, description="Name of a matplotlib colormap for the coefficient strengths. Defaults to a theme gradient.", )
[docs] show_colorbar: bool = Field(default=True, description="Whether to draw the coefficient strength colour bar.")
[docs] colorbar_shrink: float = Field(default=0.75, description="Fraction of the axes height taken up by the colour bar.")
[docs] colorbar_label: str | None = Field(
[docs] default=None, description="Label of the colour bar. Defaults to 'coefficient' (or '|coefficient|')."
)
[docs] separate_color_scales: bool = Field( default=False, description="Whether local fields and couplings get their own colour scale (and colour bar) instead of a shared one.", )
# Legend
[docs] show_legend: bool = Field(
[docs] default=True, description="Whether to draw a legend mapping coupling types to their line style."
)
[docs] legend_loc: str = Field(default="upper right", description="Location of the coupling type legend.")
[docs]
[docs] legend_fontsize: int = Field(default=10, description=_LEGEND_FONTSIZE_DESCRIPTION)
[docs] legend_frame: bool = Field(default=True, description=_LEGEND_FRAME_DESCRIPTION)
[docs] # Misc
[docs] show_identity_offset: bool = Field( default=True, description="Whether to annotate the constant (identity) energy offset of the Hamiltonian." )
[docs] class ScheduleStyle(Style): """ Customization options for matplotlib schedule plots, with theme support. """ # Figure and axes
[docs] figsize: Optional[tuple] = Field(default=(8, 5), description=_FIGSIZE_DESCRIPTION)
[docs] grid: bool = Field(default=True, description="Whether to show grid lines on the plot.")
[docs] grid_style: dict[str, Any] = Field( default_factory=lambda: {"linestyle": "--", "color": "#e0e0e0", "alpha": 0.7}, description="Style dictionary for grid lines (linestyle, color, alpha, etc.).", )
# Title and labels
[docs] title_fontsize: int = Field(default=16, description=_TITLE_FONTSIZE_DESCRIPTION)
[docs] xlabel: str = Field(default="time", description="Label for the x-axis.")
[docs] ylabel: str = Field(default="coefficient value", description="Label for the y-axis.")
[docs] label_fontsize: int = Field(default=14, description="Font size for axis labels.")
# Legend
[docs] legend_loc: str = Field( default="best", description="Location of the legend (matplotlib string, e.g. 'best', 'upper right')." )
[docs] legend_fontsize: int = Field(default=12, description=_LEGEND_FONTSIZE_DESCRIPTION)
[docs] legend_frame: bool = Field(default=True, description=_LEGEND_FRAME_DESCRIPTION)
# Line style
[docs] line_styles: dict[str, dict[str, Any]] = Field( default_factory=dict, description="Custom line style dictionary for each Hamiltonian (e.g. {label: {color, linestyle, linewidth}}).", )
[docs] default_line_style: dict[str, Any] = Field( default_factory=lambda: {"linestyle": "-", "linewidth": 2}, description="Default line style for Hamiltonians not in line_styles.", )
# Marker style
[docs] marker: Optional[str] = Field( default=None, description="Matplotlib marker style for data points (e.g. 'o', 's', None for no marker)." )
[docs] marker_size: int = Field(default=6, description="Size of markers if used.")
# Ticks
[docs] xtick_fontsize: int = Field(default=12, description="Font size for x-axis tick labels.")
[docs] ytick_fontsize: int = Field(default=12, description="Font size for y-axis tick labels.")
[docs] tick_color: Optional[str] = Field( default=None, description="Color for tick labels (None uses theme.on_background)." )
# Misc
[docs] tight_layout: bool = Field(default=True, description=_TIGHT_LAYOUT_DESCRIPTION)
[docs] class DatasetStyle(Style): """ Customization options for matplotlib dataset plots, with theme support. Controls only the *appearance* of a dataset plot (theme, fonts, colours, grid, markers, ...). The *kind* of plot (``"1d"``, ``"2d"`` or ``"3d"``) is selected separately via the ``style`` argument of :meth:`Dataset.draw`. """ # Figure and axes
[docs] figsize: Optional[tuple] = Field(default=(8, 5), description=_FIGSIZE_DESCRIPTION)
[docs] grid: bool = Field(default=True, description="Whether to show grid lines on the plot.")
[docs] grid_style: dict[str, Any] = Field( default_factory=lambda: {"linestyle": "--", "color": "#e0e0e0", "alpha": 0.7}, description="Style dictionary for grid lines (linestyle, color, alpha, etc.).", )
# Title and labels
[docs] title_fontsize: int = Field(default=16, description=_TITLE_FONTSIZE_DESCRIPTION)
[docs] label_fontsize: int = Field(default=14, description="Font size for axis labels.")
[docs] xlabel: Optional[str] = Field( default=None, description="Override for the x-axis label (None uses a sensible default)." )
[docs] ylabel: Optional[str] = Field( default=None, description="Override for the y-axis label (None uses a sensible default)." )
[docs] zlabel: Optional[str] = Field( default=None, description="Override for the z-axis label (None uses a sensible default)." )
# Legend
[docs] legend_loc: str = Field( default="best", description="Location of the legend (matplotlib string, e.g. 'best', 'upper right')." )
[docs] legend_fontsize: int = Field(default=12, description=_LEGEND_FONTSIZE_DESCRIPTION)
[docs] legend_frame: bool = Field(default=True, description=_LEGEND_FRAME_DESCRIPTION)
# Trajectory rendering
[docs] trajectory_style: Literal["scatter", "line"] = Field( default="scatter", description="How to render 2-D/3-D phase portraits: 'scatter' (points coloured by time) or 'line'.", )
[docs] line_style: dict[str, Any] = Field( default_factory=lambda: {"linestyle": "-", "linewidth": 1.5}, description="Line style used for 1-D series and 'line' trajectories.", )
[docs] marker: Optional[str] = Field( default=None, description="Matplotlib marker for 1-D series data points (e.g. 'o', None for no marker)." )
[docs] marker_size: float = Field(default=6, description="Marker size for 1-D series markers.")
[docs] point_size: float = Field(default=6, description="Point size (area) for scatter trajectories.")
[docs] color_by_time: bool = Field( default=True, description="Colour 2-D/3-D scatter trajectories by time index using a theme gradient." )
[docs] colorbar: bool = Field(default=True, description="Show a colour bar for time when colouring trajectories by time.")
# Delay embedding
[docs] delay: int = Field( default=1, description="Delay (in samples) used to embed lower-dimensional series into a 2-D/3-D phase portrait.", )
# Ticks
[docs] xtick_fontsize: int = Field(default=12, description="Font size for x-axis tick labels.")
[docs] ytick_fontsize: int = Field(default=12, description="Font size for y-axis tick labels.")
# Misc
[docs] tight_layout: bool = Field(default=True, description=_TIGHT_LAYOUT_DESCRIPTION)