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)