Skip to content

Commit

Permalink
Add plotter for ExecutionSpans (#1923)
Browse files Browse the repository at this point in the history
* initial commit

* changes

* add tests

* fix imports

* change

* appease mypy

* add reno

* Update qiskit_ibm_runtime/execution_span/execution_spans.py

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>

* Update qiskit_ibm_runtime/execution_span/execution_spans.py

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>

* rename input argument

* Apply suggestions from code review

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>

* add line_width option

* Add names option

* appease linting

* try again

* fix

---------

Co-authored-by: Samuele Ferracin <sam.ferracin@ibm.com>
Co-authored-by: Kevin Tian <kevin.tian@ibm.com>
  • Loading branch information
3 people authored Oct 30, 2024
1 parent 69f615b commit 7da4024
Show file tree
Hide file tree
Showing 11 changed files with 362 additions and 20 deletions.
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
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:
"""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]'."
) 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

0 comments on commit 7da4024

Please sign in to comment.