Source code for loom.visualizer.plotting_utils

"""
Copyright (c) Entropica Labs Pte Ltd 2025.

Use, distribution and reproduction of this program in its source or compiled
form is prohibited without the express written consent of Entropica Labs Pte
Ltd.

"""

import numpy as np
import plotly.graph_objs as go
import networkx as nx

from loom.eka import Circuit


[docs] def hex_to_rgb(hex_color: str) -> list[int]: """ Converts a hexadecimal color code to a list of RGB values between 0 and 255. Parameters ---------- hex_color : str The hexadecimal color code (e.g., "#RRGGBB"). Returns ------- list[int] RGB values between 0 and 255 representing the color. """ # Remove "#" if present if hex_color.startswith("#"): hex_color = hex_color[1:] # Convert hex color to RGB components r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) return [r, g, b]
[docs] def rgb_to_hex(rgb_color: list[int]) -> str: """ Converts a color from RGB format to HEX format. Parameters ---------- rgb_color : list[int] RGB values between 0 and 255 representing the color. Returns ------- str The hexadecimal color code (e.g., "#RRGGBB"). """ return f"#{rgb_color[0]:02x}{rgb_color[1]:02x}{rgb_color[2]:02x}"
[docs] def average_color_hex(color_list: list[str]) -> str: """ Calculate the average color from a list of colors in HEX format. Parameters ---------- color_list : list[str] List of hexadecimal colors Returns ------- str The average color in HEX format """ rgb_colors = [hex_to_rgb(color) for color in color_list] avg_rgb = tuple( int(sum(color_channel) / len(color_list)) for color_channel in zip(*rgb_colors, strict=True) ) return rgb_to_hex(avg_rgb)
[docs] def change_color_brightness(hex_color: str, factor: float) -> str: """ Change the brightness of a color by a given factor. Parameters ---------- hex_color : str The hexadecimal color code (e.g., "#RRGGBB"). factor : float The factor by which the brightness of the color should be multiplied. Returns ------- str The new color in HEX format. """ if factor < 0: raise ValueError("Factor must be greater than or equal to 0.") rgb_color = hex_to_rgb(hex_color) new_rgb_color = [min(int(np.round(color * factor)), 255) for color in rgb_color] new_hex_color = rgb_to_hex(new_rgb_color) return new_hex_color
[docs] def get_font_color(background_color: str) -> str: """ Determine font color based on background color. The font color is white for dark backgrounds and gray or black for bright backgrounds. Parameters ---------- background_color : str Background color in hexadecimal format (e.g., '#RRGGBB') Returns ------- str Font color for a good contrast with the background color """ # Convert hexadecimal color to RGB r = int(background_color[1:3], 16) g = int(background_color[3:5], 16) b = int(background_color[5:], 16) # Calculate luminance (YIQ formula) luminance = (r * 0.299 + g * 0.587 + b * 0.114) / 255 # Determine font color based on luminance if luminance > 0.7: return "#000000" if luminance > 0.5: return "#222222" return "#ffffff"
[docs] def point_in_polygon( x: float, y: float, polygon: list[tuple[float, float]], ) -> bool: """ Check if a point (x, y) is inside a polygon defined by its corners. This algorithm is based on the ray casting algorithm. Note that the behaviour for points on the edges or very close to them is undefined. Parameters ---------- x : float x coordinate of the point to check y : float y coordinate of the point to check polygon : list[tuple[float, float]] List of tuples representing the corners of the polygon Returns ------- bool True if the point is inside the polygon, False otherwise """ # How the ray casting algorithm works: # We consider a horizontal line starting from the point to check and going to the # right. We count how many times this line intersects with the edges of the polygon. # If the number of intersections is odd, the point is inside the polygon. If it is # even, the point is outside the polygon. # In the code, instead of an integer counter, we use a boolean variable is_inside # which is toggled every time the line intersects with an edge. is_inside = False for i, corner in enumerate(polygon): # Iterate over each edge of the polygon # corner = polygon[i] is the current corner # polygon[j] is the next corner of the polygon j = (i + 1) % len(polygon) next_corner = polygon[j] # Check if the y coordinate of the point is between the upper and lower y # coordinates of the edge point_is_in_y_range = (corner[1] > y) != (next_corner[1] > y) # If the point is not in the y range, we can skip this edge since there cannot # be an intersection if not point_is_in_y_range: continue # Check if the x coordinate of the point is to the left of the point on the edge # which has the same y coordinate as the given point if abs(next_corner[1] - corner[1]) > 1e-9: # Avoid division by zero inverse_slope = (next_corner[0] - corner[0]) / ( next_corner[1] - corner[1] ) # Inverse slope of line going from (x1, y1) to (x2, y2) is (x2 - x1) / (y2 - y1) point_is_left_of_edge = x < corner[0] + inverse_slope * (y - corner[1]) # Check if the horizontal line going to the right intersects with the edge if point_is_left_of_edge: is_inside = not is_inside else: # This is the case where the edge is practically horizontal and the point # might or might not lie on the edge. As stated in the docstring, the # behaviour for points on the edges or very close to them is undefined. # Therefore, we just skip this edge. continue return is_inside
[docs] def interpolate_values( point: tuple[float, float], interpolation_points: list[tuple[float, float]], interpolation_values: list[float] | list[list[float]], interpolation_power: float | None = None, min_distance: float | None = 1e-3, ) -> np.ndarray: """ Perform interpolation of the values in `interpolation_values` which are associated to the points in `interpolation_points`, based on the distances to a given point. The values in `interpolation_values` are either floats or lists of floats. If they are a list, the interpolation is done element- wise. E.g. every interpolation point could have a tuple (r, g, b) of three integers, representing an RGB color. Then the weighted average of these RGB tuples is calculated with weights determined by the distances to these points. Parameters ---------- point : tuple[float, float] Tuple representing the coordinates of the given point interpolation_points : list[tuple[float, float]] List of points at which the values in interpolation_values are given interpolation_values : list[float] | list[list[float]] List of values associated to the points in interpolation_points. The values can be floats or lists of floats. If they are lists, the interpolation is done element-wise. interpolation_power : float | None The interpolation_power parameter determines how rapidly the interpolated value changes with increasing distance. The weights with which the values of the n points are interpolated are given as 1 / distance**interpolation_power. The default value is 2. min_distance : float | None Points which are closer to one of the given points than min_distance will get a value equal to the value of this point. This is to prevent division by zero when calculating the weights. Returns ------- np.ndarray Interpolated value(s) based on the distances from the given point. The length of the array is equal to the length of the values in interpolation_values. """ if interpolation_power is None: interpolation_power = 2 # Make sure the number of interpolation points and interpolation values is the same if len(interpolation_points) != len(interpolation_values): raise ValueError( "The number of interpolation points and interpolation values must be the same." ) # Make sure every element of interpolation_values is a list. # If it is a single value, convert it to a list of length 1. for i, val in enumerate(interpolation_values): if not isinstance(val, (tuple, list)): interpolation_values[i] = [val] # Check if all interpolation_values have the same dimension if any(len(val) != len(interpolation_values[0]) for val in interpolation_values): raise ValueError( "All values in interpolation_values must have the same dimension." ) # Check that all points in interpolation_points are tuples of length 2 if any( not (isinstance(point, tuple)) or len(point) != 2 for point in interpolation_points ): raise ValueError( "All points in interpolation_points must be tuples of length 2." ) total_value = np.array([0] * len(interpolation_values[0]), dtype=float) total_weight = 0 # Calculate the weighted average of the values of the n points for pt, value in zip(interpolation_points, interpolation_values, strict=True): distance = ((point[0] - pt[0]) ** 2 + (point[1] - pt[1]) ** 2) ** 0.5 if distance < min_distance: return value weight = 1 / distance**interpolation_power total_value += np.array(value) * weight total_weight += weight return total_value / total_weight
[docs] def center_of_points(points: list[tuple[float, float]]) -> np.ndarray: """Calculate the center of a list of points.""" x_coords = [point[0] for point in points] y_coords = [point[1] for point in points] return np.array([np.mean(x_coords), np.mean(y_coords)])
[docs] def center_of_scatter_plot(scatter_plot: go.Scatter) -> np.ndarray: """Calculate the center of the points contained in a scatter plot.""" coords = np.stack((scatter_plot.x, scatter_plot.y), axis=1) return np.average(coords, axis=0)
[docs] def get_angle_from_x_axis( point1: tuple[float, float], point2: tuple[float, float] ) -> float: """ Calculate the angle between the vector from point1 to point2 and the x-axis. E.g. for point1 == [0,0] and point2 == [1,0], the angle is 0. For point1 == [0,0] and point2 == [0,1], the angle is pi/2 (90 degree). """ delta = np.array(point2) - np.array(point1) return np.arctan2(delta[1], delta[0])
[docs] def order_points_counterclockwise( points: list[tuple[float, float, list[any]]], ) -> list[tuple[float, float, list[any]]]: """ Order the provided list of points counterclockwise around the center point. Parameters ---------- points : list[tuple[float, float, list[any]]] List of points to be ordered. Every element in the list is a tuple which contains the x and y coordinate of the point as the first two elements. The third element is a list which can store additional metadata. Returns ------- list[tuple[float, float, list[any]]] List of points ordered counterclockwise around the center point of the provided list. The list still contains the original metadata. As an additional metadata, the angle for the every point was added to its respective list. """ center_point = center_of_points(points) points_with_angles = [list(point) for point in points] for point, point_with_angle in zip(points, points_with_angles, strict=True): # If the points have no additional metadata, add an empty list to add the angle if len(point_with_angle) == 2: point_with_angle.append([]) point_with_angle[2].append( get_angle_from_x_axis(center_point, np.array([point[0], point[1]])) ) sorted_points = sorted(points_with_angles, key=lambda x: x[2][-1]) return sorted_points
# pylint: disable=too-many-arguments, too-many-positional-arguments
[docs] def draw_half_circle( center: list[float], r: float, direction: float | None = 0, name: str | None = "", text: str | None = "", fillcolor: str | None = "white", line: dict | None = None, showlegend: bool = True, legendgroup: str | None = None, ) -> go.Scatter: """ Generate a scatter plot for a half circle. Parameters ---------- center : list[float] Center point around which the half circle should be drawn r : float Radius of the half circle direction : float | None Direction in which the half circle should point. E.g., - If direction == 0, half circle points upwards. - If direction == pi/2, half circle points right. - If direction == - pi/2, half circle points left. name : str | None Name parameter of the scatter plot text : str | None Text parameter of the scatter plot fillcolor : str | None Fill color of the half circle line : dict | None Line parameter of the scatter plot showlegend : bool, optional If True (the default), the stabilizers are shown in the legend. legendgroup: str | None Legend group to which the stabilizers belong. Returns ------- go.Scatter Scatter plot with the half circle """ if line is None: line = {} theta = np.linspace(0, np.pi, 100) # Calculate x and y coordinates for half circle x = center[0] + r * np.cos(-direction + theta) y = center[1] + r * np.sin(-direction + theta) # Add the starting point to have a closed shape x = np.append(x, x[0]) y = np.append(y, y[0]) # Add half circle as scatter plot return go.Scatter( x=x, y=y, mode="lines", name=name, fill="toself", fillcolor=fillcolor, line=line, text=text, hoverinfo="text", showlegend=showlegend, legendgroup=legendgroup, )
[docs] def get_label_for_circuit(circ: Circuit) -> str: """ Generate a label for a circuit. If the circuit has subcircuits, the label is simply the name attribute of the circuit. If the circuit has no subcircuits, the label contains the circuit's name (specifying the gate) and the labels of the channels that are involved in the circuit. Parameters ---------- circ : Circuit Circuit for which the label should be generated Returns ------- str Label for the circuit """ if len(circ.circuit) == 0: qb_list_str = ",".join(ch.label for ch in circ.channels) return f"{circ.name}({qb_list_str})" return circ.name
[docs] def convert_circuit_to_nx_graph(circ: Circuit) -> tuple[nx.DiGraph, list[str]]: """ Construct a NetworkX directed graph (DiGraph) from a circuit. The nodes of the graph are all the subcircuits contained in the circuit object. The edges are directed from every circuit to its subcircuits. Parameters ---------- circ : Circuit Circuit from which the graph should be constructed Returns ------- nx.DiGraph: Directed graph representing the circuit list[str]: List of labels for the nodes in BFS traversal order """ graph = nx.DiGraph() labels_nodes = [] graph.add_node(circ.id) labels_nodes.append(get_label_for_circuit(circ)) # This is the Breadth First Search (BFS) traversal of a tree: # https://en.wikipedia.org/wiki/Tree_traversal#Breadth-first_search queue = [circ] while len(queue) > 0: next_circuit = queue.pop(0) for c in next_circuit.circuit: # Iterate over all subcircuits if len(c) > 0: # Skip empty tuples graph.add_node(c[0].id) # Add node to graph graph.add_edge( next_circuit.id, c[0].id ) # Add edge from parent circuit to this subcircuit # Add label for the plot labels_nodes.append(get_label_for_circuit(c[0])) # If this subcircuit contains more subcircuits which have to be added to # the graph, add it to the queue so that it will be processed later in # the right order if len(c[0].circuit) > 0: queue.append(c[0]) return graph, labels_nodes