diff --git a/.gitignore b/.gitignore
index 125e6bad6..9fb3f6150 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,7 @@ test/python/*.log
test/python/*.pdf
test/python/*.prof
.stestr/
+.test_artifacts
# Translations
*.mo
diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py
index 892e28fbe..654b452b7 100644
--- a/qiskit_ibm_runtime/execution_span/execution_spans.py
+++ b/qiskit_ibm_runtime/execution_span/execution_spans.py
@@ -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.
@@ -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
+ )
diff --git a/qiskit_ibm_runtime/utils/noise_learner_result.py b/qiskit_ibm_runtime/utils/noise_learner_result.py
index f49ff17dc..d5d813400 100644
--- a/qiskit_ibm_runtime/utils/noise_learner_result.py
+++ b/qiskit_ibm_runtime/utils/noise_learner_result.py
@@ -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:
@@ -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.
diff --git a/qiskit_ibm_runtime/visualization/__init__.py b/qiskit_ibm_runtime/visualization/__init__.py
index 1e00d4b8f..916b190c3 100644
--- a/qiskit_ibm_runtime/visualization/__init__.py
+++ b/qiskit_ibm_runtime/visualization/__init__.py
@@ -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
diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py
new file mode 100644
index 000000000..2fab7180c
--- /dev/null
+++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py
@@ -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 = "
".join(
+ [
+ "{name}[{idx}]",
+ " Start: {span.start:%Y-%m-%d %H:%M:%S.%f}",
+ " Stop: {span.stop:%Y-%m-%d %H:%M:%S.%f}",
+ " Size: {span.size}",
+ " Pub Indexes: {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
diff --git a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py
index b286751b2..d819cde63 100644
--- a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py
+++ b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py
@@ -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(
@@ -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`.
@@ -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))
@@ -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:
@@ -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
]
@@ -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 += [
{
diff --git a/qiskit_ibm_runtime/visualization/utils.py b/qiskit_ibm_runtime/visualization/utils.py
index 5397e9869..8b1c6e14f 100644
--- a/qiskit_ibm_runtime/visualization/utils.py
+++ b/qiskit_ibm_runtime/visualization/utils.py
@@ -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.
@@ -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)
@@ -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:
diff --git a/release-notes/unreleased/1923.feat.rst b/release-notes/unreleased/1923.feat.rst
new file mode 100644
index 000000000..7c375d143
--- /dev/null
+++ b/release-notes/unreleased/1923.feat.rst
@@ -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()
\ No newline at end of file
diff --git a/test/ibm_test_case.py b/test/ibm_test_case.py
index 921ba6b8f..28d18585b 100644
--- a/test/ibm_test_case.py
+++ b/test/ibm_test_case.py
@@ -22,10 +22,12 @@
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
@@ -33,6 +35,7 @@
class IBMTestCase(TestCase):
"""Custom TestCase for use with qiskit-ibm-runtime."""
+ ARTIFACT_DIR = ".test_artifacts"
log: logging.Logger
dependencies: IntegrationTestDependencies
service: QiskitRuntimeService
@@ -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.
@@ -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."""
diff --git a/test/unit/test_execution_span.py b/test/unit/test_execution_span.py
index 960ccddc5..2f55ddc5a 100644
--- a/test/unit/test_execution_span.py
+++ b/test/unit/test_execution_span.py
@@ -291,3 +291,10 @@ 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, 4, None), (True, 6, "alpha"))
+ @ddt.unpack
+ def test_draw(self, normalize_y, width, name):
+ """Test the draw method."""
+ spans = ExecutionSpans([self.span2, self.span1])
+ self.save_plotly_artifact(spans.draw(normalize_y=normalize_y, line_width=width, name=name))
diff --git a/test/unit/test_visualization.py b/test/unit/test_visualization.py
new file mode 100644
index 000000000..161b59f0d
--- /dev/null
+++ b/test/unit/test_visualization.py
@@ -0,0 +1,92 @@
+# 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.
+
+"""Unit tests for the visualization folder."""
+
+
+from datetime import datetime, timedelta
+import random
+from types import ModuleType
+
+import ddt
+
+from qiskit_ibm_runtime.execution_span import ExecutionSpans, SliceSpan
+from qiskit_ibm_runtime.visualization import draw_execution_spans
+from qiskit_ibm_runtime.visualization.utils import plotly_module
+
+from ..ibm_test_case import IBMTestCase
+
+
+class TestUtils(IBMTestCase):
+ """Tests for the utility module."""
+
+ def test_get_plotly_module(self):
+ """Test that getting a module works."""
+ self.assertIsInstance(plotly_module(), ModuleType)
+ self.assertIsInstance(plotly_module(".graph_objects"), ModuleType)
+
+ def test_plotly_module_raises(self):
+ """Test that correct error is raised."""
+ with self.assertRaisesRegex(
+ ModuleNotFoundError, "Install all qiskit-ibm-runtime visualization dependencies"
+ ):
+ plotly_module(".not_a_module")
+
+
+@ddt.ddt
+class TestDrawExecutionSpans(IBMTestCase):
+ """Tests for the ``draw_execution_spans`` function."""
+
+ def setUp(self) -> None:
+ """Set up."""
+ random.seed(100)
+
+ time0 = time1 = datetime(year=1995, month=7, day=30)
+ time1 += timedelta(seconds=30)
+ spans0 = []
+ spans1 = []
+ for idx in range(100):
+ delta = timedelta(seconds=4 + 2 * random.random())
+ spans0.append(
+ SliceSpan(time0, time0 := time0 + delta, {0: ((100,), slice(idx, idx + 1))})
+ )
+
+ if idx < 50:
+ delta = timedelta(seconds=3 + 3 * random.random())
+ spans1.append(
+ SliceSpan(time1, time1 := time1 + delta, {0: ((50,), slice(idx, idx + 1))})
+ )
+
+ self.spans0 = ExecutionSpans(spans0)
+ self.spans1 = ExecutionSpans(spans1)
+
+ @ddt.data(False, True)
+ def test_one_spans(self, normalize_y):
+ """Test with one set of spans."""
+ fig = draw_execution_spans(self.spans0, normalize_y=normalize_y)
+ self.save_plotly_artifact(fig)
+
+ @ddt.data(
+ (False, False, 4, None), (True, True, 8, "alpha"), (True, False, 4, ["alpha", "beta"])
+ )
+ @ddt.unpack
+ def test_two_spans(self, normalize_y, common_start, width, names):
+ """Test with two sets of spans."""
+ fig = draw_execution_spans(
+ self.spans0,
+ self.spans1,
+ normalize_y=normalize_y,
+ common_start=common_start,
+ line_width=width,
+ names=names,
+ )
+ self.save_plotly_artifact(fig)