Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add plotter for ExecutionSpans #1923

Merged
merged 20 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ test/python/*.log
test/python/*.pdf
test/python/*.prof
.stestr/
.test_artifacts

# Translations
*.mo
Expand Down
30 changes: 29 additions & 1 deletion qiskit_ibm_runtime/execution_span/execution_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@
from __future__ import annotations

from datetime import datetime
from typing import overload, Iterable, Iterator
from typing import overload, Iterable, Iterator, TYPE_CHECKING

from .execution_span import ExecutionSpan

if TYPE_CHECKING:
from plotly.graph_objects import Figure as PlotlyFigure


class ExecutionSpans:
"""A collection of timings for pub results.
Expand Down Expand Up @@ -113,3 +116,28 @@ def sort(self, inplace: bool = True) -> "ExecutionSpans":
obj = self if inplace else ExecutionSpans(self)
obj._spans.sort()
return obj

def draw(
self, name: str = None, normalize_y: bool = False, line_width: int = 4
) -> "PlotlyFigure":
"""Draw these execution spans.
.. note::
To draw multiple sets of execution spans at once, for example coming from multiple
jobs, consider calling :func:`~.draw_execution_spans` directly.
Args:
name: The name of this set of spans.
normalize_y: Whether to display the y-axis units as a percentage of work
complete, rather than cumulative shots completed.
line_width: The thickness of line segments.
Returns:
A plotly figure.
"""
# pylint: disable=import-outside-toplevel, cyclic-import
from ..visualization import draw_execution_spans

return draw_execution_spans(
self, normalize_y=normalize_y, line_width=line_width, names=name
)
4 changes: 2 additions & 2 deletions qiskit_ibm_runtime/utils/noise_learner_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from ..utils.deprecation import issue_deprecation_msg

if TYPE_CHECKING:
import plotly.graph_objs as go
ihincks marked this conversation as resolved.
Show resolved Hide resolved
from plotly.graph_objects import Figure as PlotlyFigure


class PauliLindbladError:
Expand Down Expand Up @@ -231,7 +231,7 @@ def draw_map(
background_color: str = "white",
radius: float = 0.25,
width: int = 800,
) -> go.Figure:
) -> PlotlyFigure:
r"""
Draw a map view of a this layer error.
Expand Down
2 changes: 2 additions & 0 deletions qiskit_ibm_runtime/visualization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
.. autosummary::
:toctree: ../stubs/
draw_execution_spans
draw_layer_error_map
"""

from .draw_execution_spans import draw_execution_spans
from .draw_layer_error_map import draw_layer_error_map
154 changes: 154 additions & 0 deletions qiskit_ibm_runtime/visualization/draw_execution_spans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2024.
#
# 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.

"""Functions to visualize :class:`~.ExecutionSpans` objects."""

from __future__ import annotations

from itertools import cycle
from datetime import datetime, timedelta
from typing import Iterable, TYPE_CHECKING

from ..execution_span import ExecutionSpan, ExecutionSpans
from .utils import plotly_module

if TYPE_CHECKING:
from plotly.graph_objects import Figure as PlotlyFigure


HOVER_TEMPLATE = "<br>".join(
[
"<b>{name}[{idx}]</b>",
"<b>&nbsp;&nbsp;&nbsp;Start:</b> {span.start:%Y-%m-%d %H:%M:%S.%f}",
"<b>&nbsp;&nbsp;&nbsp;Stop:</b> {span.stop:%Y-%m-%d %H:%M:%S.%f}",
"<b>&nbsp;&nbsp;&nbsp;Size:</b> {span.size}",
"<b>&nbsp;&nbsp;&nbsp;Pub Indexes:</b> {idxs}",
]
)


def _get_idxs(span: ExecutionSpan, limit: int = 10) -> str:
if len(idxs := span.pub_idxs) <= limit:
return str(idxs)
else:
return f"[{', '.join(map(str, idxs[:limit]))}, ...]"


def _get_id(spans: ExecutionSpans, multiple: bool) -> str:
return f"<{hex(id(spans))}>" if multiple else ""


def draw_execution_spans(
*spans: ExecutionSpans,
names: str | Iterable[str] | None = None,
common_start: bool = False,
normalize_y: bool = False,
line_width: int = 4,
show_legend: bool = None,
) -> PlotlyFigure:
"""Draw one or more :class:`~.ExecutionSpans` on a bar plot.
Args:
spans: One or more :class:`~.ExecutionSpans`.
names: Name or names to assign to respective ``spans``.
common_start: Whether to shift all collections of spans so that their first span's start is
at :math:`t=0`.
normalize_y: Whether to display the y-axis units as a percentage of work complete, rather
than cumulative shots completed.
line_width: The thickness of line segments.
show_legend: Whether to show a legend. By default, this choice is automatic.
Returns:
A plotly figure.
"""
go = plotly_module(".graph_objects")
colors = plotly_module(".colors").qualitative.Plotly

fig = go.Figure()

# assign a name to each span
all_names = []
if names is None:
show_legend = False if show_legend is None else show_legend
else:
show_legend = True if show_legend is None else show_legend
if isinstance(names, str):
all_names = [names]
else:
all_names.extend(names)

# make sure there are always at least as many names as span sets
all_names.extend(
f"ExecutionSpans{_get_id(single_span, len(spans)>1)}"
for single_span in spans[len(all_names) :]
)

# loop through and make a trace in the figure for each ExecutionSpans
for single_spans, color, name in zip(spans, cycle(colors), all_names):
if not single_spans:
continue

# sort the spans but remember their original order
sorted_spans = sorted(enumerate(single_spans), key=lambda x: x[1])

offset = timedelta()
if common_start:
# plotly doesn't have a way to display timedeltas or relative times on a axis. the
# standard workaround i've found is to shift times to t=0 (ie unix epoch) and suppress
# showing the year/month in the tick labels.
first_start = sorted_spans[0][1].start.replace(tzinfo=None)
offset = first_start - datetime(year=1970, month=1, day=1)

# gather x/y/text data for each span
total_size = sum(span.size for span in single_spans) if normalize_y else 1
y_value = 0.0
x_data = []
y_data = []
text_data = []
for idx, span in sorted_spans:
y_value += span.size / total_size
text = HOVER_TEMPLATE.format(span=span, idx=idx, idxs=_get_idxs(span), name=name)

x_data.extend([span.start - offset, span.stop - offset, None])
y_data.extend([y_value, y_value, None])
text_data.append(text)

# add the data to the plot
fig.add_trace(
go.Scatter(
x=x_data,
y=y_data,
mode="lines",
line={"width": line_width, "color": color},
text=text_data,
hoverinfo="text",
name=name,
)
)

# axis and layout settings
fig.update_layout(
xaxis={"title": "Time", "type": "date"},
showlegend=show_legend,
legend={"yanchor": "bottom", "y": 0.01, "xanchor": "right", "x": 0.99},
margin={"l": 70, "r": 20, "t": 20, "b": 70},
)

if normalize_y:
fig.update_yaxes(title="Completed Workload", tickformat=".0%")
else:
fig.update_yaxes(title="Shots Completed")

if common_start:
fig.update_xaxes(tickformat="%H:%M:%S.%f")

return fig
23 changes: 9 additions & 14 deletions qiskit_ibm_runtime/visualization/draw_layer_error_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@

from ..utils.embeddings import Embedding
from ..utils.noise_learner_result import LayerError
from .utils import get_rgb_color, pie_slice
from .utils import get_rgb_color, pie_slice, plotly_module

if TYPE_CHECKING:
import plotly.graph_objs as go
from plotly.graph_objects import Figure as PlotlyFigure


def draw_layer_error_map(
Expand All @@ -39,7 +39,7 @@ def draw_layer_error_map(
background_color: str = "white",
radius: float = 0.25,
width: int = 800,
) -> go.Figure:
) -> PlotlyFigure:
r"""
Draw a map view of a :class:`~.LayerError`.
Expand All @@ -64,13 +64,8 @@ def draw_layer_error_map(
ValueError: If ``backend`` has no coupling map.
ModuleNotFoundError: If the required ``plotly`` dependencies cannot be imported.
"""
# pylint: disable=import-outside-toplevel

try:
import plotly.graph_objects as go
from plotly.colors import sample_colorscale
except ModuleNotFoundError as msg:
raise ModuleNotFoundError(f"Failed to import 'plotly' dependencies with error: {msg}.")
go = plotly_module(".graph_objects")
sample_colorscale = plotly_module(".colors").sample_colorscale

fig = go.Figure(layout=go.Layout(width=width, height=height))

Expand Down Expand Up @@ -111,8 +106,8 @@ def draw_layer_error_map(

highest_rate = highest_rate if highest_rate else max_rate

# A discreet colorscale that contains 1000 hues.
discreet_colorscale = sample_colorscale(colorscale, np.linspace(0, 1, 1000))
# A discrete colorscale that contains 1000 hues.
discrete_colorscale = sample_colorscale(colorscale, np.linspace(0, 1, 1000))

# Plot the edges
for q1, q2 in edges:
Expand All @@ -132,7 +127,7 @@ def draw_layer_error_map(
]
color = [
get_rgb_color(
discreet_colorscale, v / highest_rate, color_no_data, color_out_of_scale
discrete_colorscale, v / highest_rate, color_no_data, color_out_of_scale
)
for v in all_vals
]
Expand Down Expand Up @@ -185,7 +180,7 @@ def draw_layer_error_map(
for pauli, angle in [("Z", -30), ("X", 90), ("Y", 210)]:
rate = rates_1q.get(qubit, {}).get(pauli, 0)
fillcolor = get_rgb_color(
discreet_colorscale, rate / highest_rate, color_no_data, color_out_of_scale
discrete_colorscale, rate / highest_rate, color_no_data, color_out_of_scale
)
shapes += [
{
Expand Down
35 changes: 32 additions & 3 deletions qiskit_ibm_runtime/visualization/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,36 @@
Utility functions for visualizing qiskit-ibm-runtime's objects.
"""

from typing import List
from __future__ import annotations

import importlib
from types import ModuleType

import numpy as np


def plotly_module(submodule: str = ".") -> ModuleType:
ihincks marked this conversation as resolved.
Show resolved Hide resolved
"""Import and return a plotly module.
Args:
submodule: The plotly submodule to import, relative or absolute.
Returns:
The submodule.
Raises:
ModuleNotFoundError: If it can't be imported.
"""
try:
return importlib.import_module(submodule, "plotly")
except (ModuleNotFoundError, ImportError) as ex:
raise ModuleNotFoundError(
"The plotly Python package is required for visualization. "
"Install all qiskit-ibm-runtime visualization dependencies with "
"pip install 'qiskit-ibm-runtime[visualization]'."
kt474 marked this conversation as resolved.
Show resolved Hide resolved
) from ex


def pie_slice(angle_st: float, angle_end: float, x: float, y: float, radius: float) -> str:
r"""
Return a path that can be used to draw a slice of a pie chart with plotly.
Expand All @@ -32,6 +58,9 @@ def pie_slice(angle_st: float, angle_end: float, x: float, y: float, radius: flo
x: The `x` coordinate of the centre of the pie.
y: The `y` coordinate of the centre of the pie.
radius: the radius of the pie.
Returns:
A path string.
"""
t = np.linspace(angle_st * np.pi / 180, angle_end * np.pi / 180, 10)

Expand All @@ -47,10 +76,10 @@ def pie_slice(angle_st: float, angle_end: float, x: float, y: float, radius: flo


def get_rgb_color(
discreet_colorscale: List[str], val: float, default: str, color_out_of_scale: str
discreet_colorscale: list[str], val: float, default: str, color_out_of_scale: str
) -> str:
r"""
Maps a float to an RGB color based on a discreet colorscale that contains
Map a float to an RGB color based on a discreet colorscale that contains
exactly ``1000`` hues.
Args:
Expand Down
15 changes: 15 additions & 0 deletions release-notes/unreleased/1923.feat.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Add :func:`~.draw_execution_spans` function for creating a Plotly figure that
visualizes one or more :class:`~.ExecutionSpans` objects. Also add the convenience
method :meth:`~.ExecutionSpans.draw` for invoking the drawing function on a
particular instance.

.. code::python
from qiskit_ibm_runtime.visualization import draw_execution_spans
# use the drawing function on spans from sampler job data
spans1 = sampler_job1.result().metadata["execution"]["execution_spans"]
spans2 = sampler_job2.result().metadata["execution"]["execution_spans"]
draw_execution_spans(spans1, spans2)
# convenience to plot just spans1
spans1.draw()
Loading