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 10 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
24 changes: 23 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,22 @@ def sort(self, inplace: bool = True) -> "ExecutionSpans":
obj = self if inplace else ExecutionSpans(self)
obj._spans.sort()
return obj

def draw(self, normalize_y: bool = False) -> "PlotlyFigure":
"""Draw these execution spans on a plot.
ihincks marked this conversation as resolved.
Show resolved Hide resolved

.. note::
To draw multiple sets of execution spans at once, for examule, coming from multiple
jobs, consider calling :func:`~.draw_execution_spans` directly.
ihincks marked this conversation as resolved.
Show resolved Hide resolved

Args:
normalize_y: Whether to display the y-axis units as a percentage of work
complete, rather than cummulative shots completed.
ihincks marked this conversation as resolved.
Show resolved Hide resolved

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)
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":
ihincks marked this conversation as resolved.
Show resolved Hide resolved
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
115 changes: 115 additions & 0 deletions qiskit_ibm_runtime/visualization/draw_execution_spans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 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 functools import partial
from itertools import cycle
from datetime import datetime, timedelta
from typing import 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>ExecutionSpans{id}[{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(span: ExecutionSpan, multiple: bool) -> str:
return f"<{hex(id(span))}>" if multiple else ""


def draw_execution_spans(
*list_of_spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False
ihincks marked this conversation as resolved.
Show resolved Hide resolved
) -> "PlotlyFigure":
ihincks marked this conversation as resolved.
Show resolved Hide resolved
"""Draw one or more :class:`~.ExecutionSpans` on a bar plot.

Args:
list_of_spans: One or more :class:`~.ExecutionSpans`.
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 cummulative shots completed.
ihincks marked this conversation as resolved.
Show resolved Hide resolved

Returns:
A plotly figure.
"""
go = plotly_module(".graph_objects")
colors = plotly_module(".colors").qualitative.Plotly

fig = go.Figure()
get_id = partial(_get_id, multiple=len(list_of_spans) > 1)

for spans, color in zip(list_of_spans, cycle(colors)):
if not spans:
continue

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

offset = timedelta()
if common_start:
offset = spans[0].start.replace(tzinfo=None) - datetime(year=1970, month=1, day=1)

total_size = sum(span.size for span in spans) if normalize_y else 1
y_value = 0.0
for idx, span in sorted_spans:
y_value += span.size / total_size
text = HOVER_TEMPLATE.format(span=span, idx=idx, idxs=_get_idxs(span), id=get_id(span))
# Create a line representing each span as a Scatter trace
fig.add_trace(
go.Scatter(
x=[span.start - offset, span.stop - offset],
y=[y_value, y_value],
mode="lines",
line={"width": 4, "color": color},
ihincks marked this conversation as resolved.
Show resolved Hide resolved
text=text,
hoverinfo="text",
kt474 marked this conversation as resolved.
Show resolved Hide resolved
)
)

# Axis and layout settings
fig.update_layout(
xaxis={"title": "Time", "type": "date"},
showlegend=False,
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":
ihincks marked this conversation as resolved.
Show resolved Hide resolved
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()
19 changes: 19 additions & 0 deletions test/ibm_test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@
from collections import defaultdict
from typing import DefaultDict, Dict

from plotly.graph_objects import Figure as PlotlyFigure
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import QISKIT_IBM_RUNTIME_LOGGER_NAME
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2


from .utils import setup_test_logging, bell
from .decorators import IntegrationTestDependencies, integration_test_setup


class IBMTestCase(TestCase):
"""Custom TestCase for use with qiskit-ibm-runtime."""

ARTIFACT_DIR = ".test_artifacts"
log: logging.Logger
dependencies: IntegrationTestDependencies
service: QiskitRuntimeService
Expand All @@ -48,6 +51,9 @@ def setUpClass(cls):
# fail test on deprecation warnings from qiskit
warnings.filterwarnings("error", category=DeprecationWarning, module=r"^qiskit$")

# Ensure the artifact directory exists
os.makedirs(cls.ARTIFACT_DIR, exist_ok=True)

@classmethod
def _set_logging_level(cls, logger: logging.Logger) -> None:
"""Set logging level for the input logger.
Expand Down Expand Up @@ -160,6 +166,19 @@ def valid_comparison(value):
else:
return ""

def save_plotly_artifact(self, fig: PlotlyFigure, name: str = None) -> str:
"""Save a Plotly figure as an HTML artifact."""
# nested folder path based on the test module, class, and method
test_path = self.id().split(".")[1:]
nested_dir = os.path.join(self.ARTIFACT_DIR, *test_path[:-1])
name = test_path[-1]
os.makedirs(nested_dir, exist_ok=True)

# save figure
artifact_path = os.path.join(nested_dir, f"{name}.html")
fig.write_html(artifact_path)
return artifact_path


class IBMIntegrationTestCase(IBMTestCase):
"""Custom integration test case for use with qiskit-ibm-runtime."""
Expand Down
6 changes: 6 additions & 0 deletions test/unit/test_execution_span.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,9 @@ def test_sort(self):
self.assertIsNot(inplace_sort, spans)
self.assertLess(spans[1], spans[0])
self.assertLess(new_sort[0], new_sort[1])

@ddt.data(False, True)
def test_draw(self, normalize_y):
"""Test the draw method."""
spans = ExecutionSpans([self.span2, self.span1])
self.save_plotly_artifact(spans.draw(normalize_y=normalize_y))
Loading