From 5f6bf995ac98ed13baf63de66f9035fac8239185 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Fri, 13 Sep 2024 07:36:22 -0400 Subject: [PATCH 01/16] initial commit --- .../execution_span/execution_spans.py | 20 ++++ qiskit_ibm_runtime/utils/__init__.py | 1 + .../utils/noise_learner_result.py | 8 +- qiskit_ibm_runtime/utils/utils.py | 8 ++ qiskit_ibm_runtime/visualization/__init__.py | 2 + .../visualization/draw_execution_spans.py | 111 ++++++++++++++++++ .../visualization/draw_layer_error_map.py | 27 ++--- qiskit_ibm_runtime/visualization/utils.py | 30 ++++- 8 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 qiskit_ibm_runtime/visualization/draw_execution_spans.py diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index 892e28fbe..44601fa45 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -17,6 +17,7 @@ from datetime import datetime from typing import overload, Iterable, Iterator +from ..utils import PlotlyFigure from .execution_span import ExecutionSpan @@ -113,3 +114,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. + + .. note:: + To draw multiple sets of execution spans at once, for examule, coming from multiple + jobs, consider calling :func:`~.draw_execution_spans` directly. + + Args: + normalize_y: Whether to display the y-axis units as a percentage of work + complete, rather than cummulative shots completed. + + Returns: + A plotly figure. + """ + # pylint: disable=import-outside-toplevel + from ..visualization import draw_execution_spans + + return draw_execution_spans(self, normalize_y=normalize_y) diff --git a/qiskit_ibm_runtime/utils/__init__.py b/qiskit_ibm_runtime/utils/__init__.py index 7005a241b..45e93f954 100644 --- a/qiskit_ibm_runtime/utils/__init__.py +++ b/qiskit_ibm_runtime/utils/__init__.py @@ -26,6 +26,7 @@ default_runtime_url_resolver, resolve_crn, are_circuits_dynamic, + PlotlyFigure, ) from .validations import ( validate_estimator_pubs, diff --git a/qiskit_ibm_runtime/utils/noise_learner_result.py b/qiskit_ibm_runtime/utils/noise_learner_result.py index f49ff17dc..00a8b2f4e 100644 --- a/qiskit_ibm_runtime/utils/noise_learner_result.py +++ b/qiskit_ibm_runtime/utils/noise_learner_result.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import Any, Iterator, Optional, Sequence, Union, TYPE_CHECKING +from typing import Any, Iterator, Optional, Sequence, Union from numpy.typing import NDArray import numpy as np @@ -34,9 +34,7 @@ from ..utils.embeddings import Embedding from ..utils.deprecation import issue_deprecation_msg - -if TYPE_CHECKING: - import plotly.graph_objs as go +from ..utils.utils import PlotlyFigure class PauliLindbladError: @@ -231,7 +229,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/utils/utils.py b/qiskit_ibm_runtime/utils/utils.py index b0d97ffc4..12c9ed9ba 100644 --- a/qiskit_ibm_runtime/utils/utils.py +++ b/qiskit_ibm_runtime/utils/utils.py @@ -34,6 +34,14 @@ from qiskit.providers.backend import BackendV1, BackendV2 from .deprecation import deprecate_function +try: + # This lives in the general utils folder instead of in visualization so that + # anything can import it without worrying about circular imports. + # pylint: disable=unused-import + from plotly.graph_objects import Figure as PlotlyFigure +except (AttributeError, ImportError, ModuleNotFoundError): + PlotlyFigure = None + def is_simulator(backend: BackendV1 | BackendV2) -> bool: """Return true if the backend is a simulator. 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..180db98b5 --- /dev/null +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -0,0 +1,111 @@ +# 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. + +from __future__ import annotations + +from functools import partial +from itertools import cycle +from datetime import datetime, timedelta + +from ..execution_span import ExecutionSpans +from ..utils.utils import PlotlyFigure +from .utils import plotly_module + + +HOVER_TEMPLATE = "
".join( + [ + "ExecutionSpans{id}[{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, limit=10): + if len(idxs := span.pub_idxs) <= limit: + return str(idxs) + else: + return f"[{', '.join(map(str, idxs[:limit]))}, ...]" + + +def _get_id(span, multiple): + return f"<{hex(id(span))}>" if multiple else "" + + +def draw_execution_spans( + *list_of_spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False +) -> PlotlyFigure: + """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. + + 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 + spans = sorted(enumerate(spans), key=lambda x: x[1]) + + offset = timedelta() + if common_start: + first_span = spans[0][1] + offset = first_span.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 + for idx, span in 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}, + text=text, + hoverinfo="text", + ) + ) + + # 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 diff --git a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py index b286751b2..82e5f0024 100644 --- a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py +++ b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py @@ -13,17 +13,15 @@ """Functions to visualize :class:`~.NoiseLearnerResult` objects.""" from __future__ import annotations -from typing import Dict, Optional, Tuple, Union, TYPE_CHECKING +from typing import Dict, Optional, Tuple, Union import numpy as np from qiskit.providers.backend import BackendV2 from ..utils.embeddings import Embedding from ..utils.noise_learner_result import LayerError -from .utils import get_rgb_color, pie_slice - -if TYPE_CHECKING: - import plotly.graph_objs as go +from ..utils.utils import PlotlyFigure +from .utils import get_rgb_color, pie_slice, plotly_module def draw_layer_error_map( @@ -39,7 +37,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 +62,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 +104,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 +125,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 +178,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..2d9e5b5f4 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. @@ -47,7 +73,7 @@ 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 From 0d53424fa34a23749d885f6f6bea8a355158beb0 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Sun, 27 Oct 2024 09:17:14 -0400 Subject: [PATCH 02/16] changes --- .gitignore | 1 + qiskit_ibm_runtime/visualization/utils.py | 5 ++++- test/ibm_test_case.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) 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/visualization/utils.py b/qiskit_ibm_runtime/visualization/utils.py index 2d9e5b5f4..8b1c6e14f 100644 --- a/qiskit_ibm_runtime/visualization/utils.py +++ b/qiskit_ibm_runtime/visualization/utils.py @@ -58,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) @@ -76,7 +79,7 @@ def get_rgb_color( 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/test/ibm_test_case.py b/test/ibm_test_case.py index 921ba6b8f..d461d7213 100644 --- a/test/ibm_test_case.py +++ b/test/ibm_test_case.py @@ -25,6 +25,7 @@ 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 qiskit_ibm_runtime.utils import PlotlyFigure from .utils import setup_test_logging, bell from .decorators import IntegrationTestDependencies, integration_test_setup @@ -33,6 +34,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 +50,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 +165,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.""" From 8a9baf1c24a792979fc896345ea1c729cd27db3f Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Sun, 27 Oct 2024 09:25:58 -0400 Subject: [PATCH 03/16] add tests --- test/unit/test_visualization.py | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/unit/test_visualization.py diff --git a/test/unit/test_visualization.py b/test/unit/test_visualization.py new file mode 100644 index 000000000..bd244a748 --- /dev/null +++ b/test/unit/test_visualization.py @@ -0,0 +1,83 @@ +# 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()) + sl = {0: ((100,), slice(idx, idx + 1))} + spans0.append(SliceSpan(time0, time0 := time0 + delta, sl)) + + if idx < 50: + delta = timedelta(seconds=3 + 3 * random.random()) + sl = {0: ((50,), slice(idx, idx + 1))} + spans1.append(SliceSpan(time1, time1 := time1 + delta, sl)) + + 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), (True, True)) + @ddt.unpack + def test_two_spans(self, normalize_y, common_start): + """Test with two sets of spans.""" + fig = draw_execution_spans( + self.spans0, self.spans1, normalize_y=normalize_y, common_start=common_start + ) + self.save_plotly_artifact(fig) From cbd9ffd48b05ce3631e6886ab7e3627962278992 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Sun, 27 Oct 2024 11:28:12 -0400 Subject: [PATCH 04/16] fix imports --- qiskit_ibm_runtime/execution_span/execution_spans.py | 8 +++++--- qiskit_ibm_runtime/utils/__init__.py | 1 - qiskit_ibm_runtime/utils/noise_learner_result.py | 8 +++++--- qiskit_ibm_runtime/utils/utils.py | 8 -------- qiskit_ibm_runtime/visualization/draw_execution_spans.py | 9 +++++++-- qiskit_ibm_runtime/visualization/draw_layer_error_map.py | 8 +++++--- test/ibm_test_case.py | 3 ++- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index 44601fa45..d40f98245 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -15,11 +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 ..utils import PlotlyFigure 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. @@ -115,7 +117,7 @@ def sort(self, inplace: bool = True) -> "ExecutionSpans": obj._spans.sort() return obj - def draw(self, normalize_y: bool = False) -> PlotlyFigure: + def draw(self, normalize_y: bool = False) -> "PlotlyFigure": """Draw these execution spans on a plot. .. note:: diff --git a/qiskit_ibm_runtime/utils/__init__.py b/qiskit_ibm_runtime/utils/__init__.py index 45e93f954..7005a241b 100644 --- a/qiskit_ibm_runtime/utils/__init__.py +++ b/qiskit_ibm_runtime/utils/__init__.py @@ -26,7 +26,6 @@ default_runtime_url_resolver, resolve_crn, are_circuits_dynamic, - PlotlyFigure, ) from .validations import ( validate_estimator_pubs, diff --git a/qiskit_ibm_runtime/utils/noise_learner_result.py b/qiskit_ibm_runtime/utils/noise_learner_result.py index 00a8b2f4e..3afd85fb8 100644 --- a/qiskit_ibm_runtime/utils/noise_learner_result.py +++ b/qiskit_ibm_runtime/utils/noise_learner_result.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import Any, Iterator, Optional, Sequence, Union +from typing import Any, Iterator, Optional, Sequence, Union, TYPE_CHECKING from numpy.typing import NDArray import numpy as np @@ -34,7 +34,9 @@ from ..utils.embeddings import Embedding from ..utils.deprecation import issue_deprecation_msg -from ..utils.utils import PlotlyFigure + +if TYPE_CHECKING: + from plotly.graph_objects import Figure as PlotlyFigure class PauliLindbladError: @@ -229,7 +231,7 @@ def draw_map( background_color: str = "white", radius: float = 0.25, width: int = 800, - ) -> PlotlyFigure: + ) -> "PlotlyFigure": r""" Draw a map view of a this layer error. diff --git a/qiskit_ibm_runtime/utils/utils.py b/qiskit_ibm_runtime/utils/utils.py index 12c9ed9ba..b0d97ffc4 100644 --- a/qiskit_ibm_runtime/utils/utils.py +++ b/qiskit_ibm_runtime/utils/utils.py @@ -34,14 +34,6 @@ from qiskit.providers.backend import BackendV1, BackendV2 from .deprecation import deprecate_function -try: - # This lives in the general utils folder instead of in visualization so that - # anything can import it without worrying about circular imports. - # pylint: disable=unused-import - from plotly.graph_objects import Figure as PlotlyFigure -except (AttributeError, ImportError, ModuleNotFoundError): - PlotlyFigure = None - def is_simulator(backend: BackendV1 | BackendV2) -> bool: """Return true if the backend is a simulator. diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index 180db98b5..87022cd63 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -10,16 +10,21 @@ # 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 ExecutionSpans -from ..utils.utils import PlotlyFigure from .utils import plotly_module +if TYPE_CHECKING: + from plotly.graph_objects import Figure as PlotlyFigure + HOVER_TEMPLATE = "
".join( [ @@ -45,7 +50,7 @@ def _get_id(span, multiple): def draw_execution_spans( *list_of_spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False -) -> PlotlyFigure: +) -> "PlotlyFigure": """Draw one or more :class:`~.ExecutionSpans` on a bar plot. Args: diff --git a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py index 82e5f0024..530574ef1 100644 --- a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py +++ b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py @@ -13,16 +13,18 @@ """Functions to visualize :class:`~.NoiseLearnerResult` objects.""" from __future__ import annotations -from typing import Dict, Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union, TYPE_CHECKING import numpy as np from qiskit.providers.backend import BackendV2 from ..utils.embeddings import Embedding from ..utils.noise_learner_result import LayerError -from ..utils.utils import PlotlyFigure from .utils import get_rgb_color, pie_slice, plotly_module +if TYPE_CHECKING: + from plotly.graph_objects import Figure as PlotlyFigure + def draw_layer_error_map( layer_error: LayerError, @@ -37,7 +39,7 @@ def draw_layer_error_map( background_color: str = "white", radius: float = 0.25, width: int = 800, -) -> PlotlyFigure: +) -> "PlotlyFigure": r""" Draw a map view of a :class:`~.LayerError`. diff --git a/test/ibm_test_case.py b/test/ibm_test_case.py index d461d7213..28d18585b 100644 --- a/test/ibm_test_case.py +++ b/test/ibm_test_case.py @@ -22,10 +22,11 @@ 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 qiskit_ibm_runtime.utils import PlotlyFigure + from .utils import setup_test_logging, bell from .decorators import IntegrationTestDependencies, integration_test_setup From 1020b22ec61ecd6b140464726b866237eb16fced Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Sun, 27 Oct 2024 11:42:19 -0400 Subject: [PATCH 05/16] change --- qiskit_ibm_runtime/execution_span/execution_spans.py | 2 +- test/unit/test_execution_span.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index d40f98245..bdcc8e0bb 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -131,7 +131,7 @@ def draw(self, normalize_y: bool = False) -> "PlotlyFigure": Returns: A plotly figure. """ - # pylint: disable=import-outside-toplevel + # pylint: disable=import-outside-toplevel, cyclic-import from ..visualization import draw_execution_spans return draw_execution_spans(self, normalize_y=normalize_y) diff --git a/test/unit/test_execution_span.py b/test/unit/test_execution_span.py index 960ccddc5..615a25d99 100644 --- a/test/unit/test_execution_span.py +++ b/test/unit/test_execution_span.py @@ -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)) From 660b2ab4533fc84b0175e7e0a16e4b6676fffb18 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Mon, 28 Oct 2024 09:31:59 -0400 Subject: [PATCH 06/16] appease mypy --- .../visualization/draw_execution_spans.py | 17 ++++++++--------- test/unit/test_visualization.py | 10 ++++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index 87022cd63..6c5641d49 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -19,7 +19,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING -from ..execution_span import ExecutionSpans +from ..execution_span import ExecutionSpan, ExecutionSpans from .utils import plotly_module if TYPE_CHECKING: @@ -37,14 +37,14 @@ ) -def _get_idxs(span, limit=10): +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, multiple): +def _get_id(span: ExecutionSpan, multiple: bool) -> str: return f"<{hex(id(span))}>" if multiple else "" @@ -74,16 +74,15 @@ def draw_execution_spans( continue # sort the spans but remember their original order - spans = sorted(enumerate(spans), key=lambda x: x[1]) + sorted_spans = sorted(enumerate(spans), key=lambda x: x[1]) offset = timedelta() if common_start: - first_span = spans[0][1] - offset = first_span.start.replace(tzinfo=None) - datetime(year=1970, month=1, day=1) + 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 - for idx, span in spans: + 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 diff --git a/test/unit/test_visualization.py b/test/unit/test_visualization.py index bd244a748..70e1b1850 100644 --- a/test/unit/test_visualization.py +++ b/test/unit/test_visualization.py @@ -56,13 +56,15 @@ def setUp(self) -> None: spans1 = [] for idx in range(100): delta = timedelta(seconds=4 + 2 * random.random()) - sl = {0: ((100,), slice(idx, idx + 1))} - spans0.append(SliceSpan(time0, time0 := time0 + delta, sl)) + spans0.append( + SliceSpan(time0, time0 := time0 + delta, {0: ((100,), slice(idx, idx + 1))}) + ) if idx < 50: delta = timedelta(seconds=3 + 3 * random.random()) - sl = {0: ((50,), slice(idx, idx + 1))} - spans1.append(SliceSpan(time1, time1 := time1 + delta, sl)) + spans1.append( + SliceSpan(time1, time1 := time1 + delta, {0: ((50,), slice(idx, idx + 1))}) + ) self.spans0 = ExecutionSpans(spans0) self.spans1 = ExecutionSpans(spans1) From 65bda6e1a1efd229d300a45896ffbd09322a7ec8 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Mon, 28 Oct 2024 11:42:13 -0400 Subject: [PATCH 07/16] add reno --- release-notes/unreleased/1923.feat.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 release-notes/unreleased/1923.feat.rst 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 From 7b0445269b1cb887d59dda7013858352719b9fea Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 09:49:09 -0400 Subject: [PATCH 08/16] Update qiskit_ibm_runtime/execution_span/execution_spans.py Co-authored-by: Samuele Ferracin --- qiskit_ibm_runtime/execution_span/execution_spans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index bdcc8e0bb..988f0710c 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -118,7 +118,7 @@ def sort(self, inplace: bool = True) -> "ExecutionSpans": return obj def draw(self, normalize_y: bool = False) -> "PlotlyFigure": - """Draw these execution spans on a plot. + """Draw these execution spans. .. note:: To draw multiple sets of execution spans at once, for examule, coming from multiple From 84c5832985e37362fdbcb1f6e1fef6f0d7b3d06f Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 09:49:21 -0400 Subject: [PATCH 09/16] Update qiskit_ibm_runtime/execution_span/execution_spans.py Co-authored-by: Samuele Ferracin --- qiskit_ibm_runtime/execution_span/execution_spans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index 988f0710c..5ccf431ce 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -121,7 +121,7 @@ def draw(self, normalize_y: bool = False) -> "PlotlyFigure": """Draw these execution spans. .. note:: - To draw multiple sets of execution spans at once, for examule, coming from multiple + To draw multiple sets of execution spans at once, for example coming from multiple jobs, consider calling :func:`~.draw_execution_spans` directly. Args: From ef424b7535b42b4e6be122fa1fd2f1f20cafe1f5 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 10:15:14 -0400 Subject: [PATCH 10/16] rename input argument --- .../visualization/draw_execution_spans.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index 6c5641d49..016883b61 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -49,12 +49,12 @@ def _get_id(span: ExecutionSpan, multiple: bool) -> str: def draw_execution_spans( - *list_of_spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False + *spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False ) -> "PlotlyFigure": """Draw one or more :class:`~.ExecutionSpans` on a bar plot. Args: - list_of_spans: One or more :class:`~.ExecutionSpans`. + 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 @@ -67,20 +67,21 @@ def draw_execution_spans( colors = plotly_module(".colors").qualitative.Plotly fig = go.Figure() - get_id = partial(_get_id, multiple=len(list_of_spans) > 1) + get_id = partial(_get_id, multiple=len(spans) > 1) - for spans, color in zip(list_of_spans, cycle(colors)): - if not spans: + for single_spans, color in zip(spans, cycle(colors)): + if not single_spans: continue # sort the spans but remember their original order - sorted_spans = sorted(enumerate(spans), key=lambda x: x[1]) + sorted_spans = sorted(enumerate(single_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) + first_start = sorted_spans[0][1].start.replace(tzinfo=None) + offset = first_start - datetime(year=1970, month=1, day=1) - total_size = sum(span.size for span in spans) if normalize_y else 1 + total_size = sum(span.size for span in single_spans) if normalize_y else 1 y_value = 0.0 for idx, span in sorted_spans: y_value += span.size / total_size From ca0d7239383dc75ba26bad8d89d4a8e3c1d430e5 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 10:35:56 -0400 Subject: [PATCH 11/16] Apply suggestions from code review Co-authored-by: Samuele Ferracin --- qiskit_ibm_runtime/execution_span/execution_spans.py | 2 +- qiskit_ibm_runtime/utils/noise_learner_result.py | 2 +- qiskit_ibm_runtime/visualization/draw_execution_spans.py | 4 ++-- qiskit_ibm_runtime/visualization/draw_layer_error_map.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index 5ccf431ce..46cb078e5 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -126,7 +126,7 @@ def draw(self, normalize_y: bool = False) -> "PlotlyFigure": Args: normalize_y: Whether to display the y-axis units as a percentage of work - complete, rather than cummulative shots completed. + complete, rather than cumulative shots completed. Returns: A plotly figure. diff --git a/qiskit_ibm_runtime/utils/noise_learner_result.py b/qiskit_ibm_runtime/utils/noise_learner_result.py index 3afd85fb8..d5d813400 100644 --- a/qiskit_ibm_runtime/utils/noise_learner_result.py +++ b/qiskit_ibm_runtime/utils/noise_learner_result.py @@ -231,7 +231,7 @@ def draw_map( background_color: str = "white", radius: float = 0.25, width: int = 800, - ) -> "PlotlyFigure": + ) -> PlotlyFigure: r""" Draw a map view of a this layer error. diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index 016883b61..65d5b5e83 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -50,7 +50,7 @@ def _get_id(span: ExecutionSpan, multiple: bool) -> str: def draw_execution_spans( *spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False -) -> "PlotlyFigure": +) -> PlotlyFigure: """Draw one or more :class:`~.ExecutionSpans` on a bar plot. Args: @@ -58,7 +58,7 @@ def draw_execution_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 cummulative shots completed. + than cumulative shots completed. Returns: A plotly figure. diff --git a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py index 530574ef1..d819cde63 100644 --- a/qiskit_ibm_runtime/visualization/draw_layer_error_map.py +++ b/qiskit_ibm_runtime/visualization/draw_layer_error_map.py @@ -39,7 +39,7 @@ def draw_layer_error_map( background_color: str = "white", radius: float = 0.25, width: int = 800, -) -> "PlotlyFigure": +) -> PlotlyFigure: r""" Draw a map view of a :class:`~.LayerError`. From ab9b77f6ef5a83260d1c9d4fde77d44f9a54700e Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 11:02:53 -0400 Subject: [PATCH 12/16] add line_width option --- qiskit_ibm_runtime/execution_span/execution_spans.py | 5 +++-- .../visualization/draw_execution_spans.py | 8 ++++++-- test/unit/test_execution_span.py | 7 ++++--- test/unit/test_visualization.py | 10 +++++++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index 46cb078e5..239e325e9 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -117,7 +117,7 @@ def sort(self, inplace: bool = True) -> "ExecutionSpans": obj._spans.sort() return obj - def draw(self, normalize_y: bool = False) -> "PlotlyFigure": + def draw(self, normalize_y: bool = False, line_width: int = 4) -> "PlotlyFigure": """Draw these execution spans. .. note:: @@ -127,6 +127,7 @@ def draw(self, normalize_y: bool = False) -> "PlotlyFigure": Args: 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. @@ -134,4 +135,4 @@ def draw(self, normalize_y: bool = False) -> "PlotlyFigure": # pylint: disable=import-outside-toplevel, cyclic-import from ..visualization import draw_execution_spans - return draw_execution_spans(self, normalize_y=normalize_y) + return draw_execution_spans(self, normalize_y=normalize_y, line_width=line_width) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index 65d5b5e83..e8c2bd263 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -49,7 +49,10 @@ def _get_id(span: ExecutionSpan, multiple: bool) -> str: def draw_execution_spans( - *spans: ExecutionSpans, common_start: bool = False, normalize_y: bool = False + *spans: ExecutionSpans, + common_start: bool = False, + normalize_y: bool = False, + line_width: int = 4, ) -> PlotlyFigure: """Draw one or more :class:`~.ExecutionSpans` on a bar plot. @@ -59,6 +62,7 @@ def draw_execution_spans( 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. Returns: A plotly figure. @@ -92,7 +96,7 @@ def draw_execution_spans( x=[span.start - offset, span.stop - offset], y=[y_value, y_value], mode="lines", - line={"width": 4, "color": color}, + line={"width": line_width, "color": color}, text=text, hoverinfo="text", ) diff --git a/test/unit/test_execution_span.py b/test/unit/test_execution_span.py index 615a25d99..471f9350f 100644 --- a/test/unit/test_execution_span.py +++ b/test/unit/test_execution_span.py @@ -292,8 +292,9 @@ def test_sort(self): self.assertLess(spans[1], spans[0]) self.assertLess(new_sort[0], new_sort[1]) - @ddt.data(False, True) - def test_draw(self, normalize_y): + @ddt.data((False, 4), (True, 6)) + @ddt.unpack + def test_draw(self, normalize_y, width): """Test the draw method.""" spans = ExecutionSpans([self.span2, self.span1]) - self.save_plotly_artifact(spans.draw(normalize_y=normalize_y)) + self.save_plotly_artifact(spans.draw(normalize_y=normalize_y, line_width=width)) diff --git a/test/unit/test_visualization.py b/test/unit/test_visualization.py index 70e1b1850..9b1e50c43 100644 --- a/test/unit/test_visualization.py +++ b/test/unit/test_visualization.py @@ -75,11 +75,15 @@ def test_one_spans(self, normalize_y): fig = draw_execution_spans(self.spans0, normalize_y=normalize_y) self.save_plotly_artifact(fig) - @ddt.data((False, False), (True, True)) + @ddt.data((False, False, 4), (True, True, 8)) @ddt.unpack - def test_two_spans(self, normalize_y, common_start): + def test_two_spans(self, normalize_y, common_start, width): """Test with two sets of spans.""" fig = draw_execution_spans( - self.spans0, self.spans1, normalize_y=normalize_y, common_start=common_start + self.spans0, + self.spans1, + normalize_y=normalize_y, + common_start=common_start, + line_width=width, ) self.save_plotly_artifact(fig) From e0acb101636f46d1f4201e61469ede40da71f089 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 21:29:33 -0400 Subject: [PATCH 13/16] Add names option --- .../execution_span/execution_spans.py | 9 ++- .../visualization/draw_execution_spans.py | 67 +++++++++++++------ test/unit/test_execution_span.py | 6 +- test/unit/test_visualization.py | 7 +- 4 files changed, 63 insertions(+), 26 deletions(-) diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py index 239e325e9..654b452b7 100644 --- a/qiskit_ibm_runtime/execution_span/execution_spans.py +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -117,7 +117,9 @@ def sort(self, inplace: bool = True) -> "ExecutionSpans": obj._spans.sort() return obj - def draw(self, normalize_y: bool = False, line_width: int = 4) -> "PlotlyFigure": + def draw( + self, name: str = None, normalize_y: bool = False, line_width: int = 4 + ) -> "PlotlyFigure": """Draw these execution spans. .. note:: @@ -125,6 +127,7 @@ def draw(self, normalize_y: bool = False, line_width: int = 4) -> "PlotlyFigure" 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. @@ -135,4 +138,6 @@ def draw(self, normalize_y: bool = False, line_width: int = 4) -> "PlotlyFigure" # 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) + return draw_execution_spans( + self, normalize_y=normalize_y, line_width=line_width, names=name + ) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index e8c2bd263..e6b71925c 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -14,10 +14,9 @@ from __future__ import annotations -from functools import partial from itertools import cycle from datetime import datetime, timedelta -from typing import TYPE_CHECKING +from typing import Iterable, TYPE_CHECKING from ..execution_span import ExecutionSpan, ExecutionSpans from .utils import plotly_module @@ -28,7 +27,7 @@ HOVER_TEMPLATE = "
".join( [ - "ExecutionSpans{id}[{idx}]", + "{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}", @@ -44,25 +43,29 @@ def _get_idxs(span: ExecutionSpan, limit: int = 10) -> str: return f"[{', '.join(map(str, idxs[:limit]))}, ...]" -def _get_id(span: ExecutionSpan, multiple: bool) -> str: - return f"<{hex(id(span))}>" if multiple else "" +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. @@ -71,9 +74,22 @@ def draw_execution_spans( colors = plotly_module(".colors").qualitative.Plotly fig = go.Figure() - get_id = partial(_get_id, multiple=len(spans) > 1) - for single_spans, color in zip(spans, cycle(colors)): + # assign a name to each span + if names is None: + show_legend = False if show_legend is None else show_legend + names = [] + else: + show_legend = True if show_legend is None else show_legend + if isinstance(names, str): + names = [names] + + # make sure there are always at least as many names as span sets + names.extend( + f"ExecutionSpans{_get_id(single_span, len(spans)>1)}" for single_span in spans[len(names) :] + ) + + for single_spans, color, name in zip(spans, cycle(colors), names): if not single_spans: continue @@ -82,30 +98,43 @@ def draw_execution_spans( 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 (unix epoch) and suppress + # plotting the year/month. first_start = sorted_spans[0][1].start.replace(tzinfo=None) offset = first_start - datetime(year=1970, month=1, day=1) 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), 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": line_width, "color": color}, - text=text, - hoverinfo="text", - ) + 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) + + # put all data for this ExecutionSpans into one Scatter trace + 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=False, + showlegend=show_legend, + legend={"yanchor": "bottom", "y": 0.01, "xanchor": "right", "x": 0.99}, margin={"l": 70, "r": 20, "t": 20, "b": 70}, ) diff --git a/test/unit/test_execution_span.py b/test/unit/test_execution_span.py index 471f9350f..2f55ddc5a 100644 --- a/test/unit/test_execution_span.py +++ b/test/unit/test_execution_span.py @@ -292,9 +292,9 @@ def test_sort(self): self.assertLess(spans[1], spans[0]) self.assertLess(new_sort[0], new_sort[1]) - @ddt.data((False, 4), (True, 6)) + @ddt.data((False, 4, None), (True, 6, "alpha")) @ddt.unpack - def test_draw(self, normalize_y, width): + 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)) + 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 index 9b1e50c43..161b59f0d 100644 --- a/test/unit/test_visualization.py +++ b/test/unit/test_visualization.py @@ -75,9 +75,11 @@ def test_one_spans(self, normalize_y): fig = draw_execution_spans(self.spans0, normalize_y=normalize_y) self.save_plotly_artifact(fig) - @ddt.data((False, False, 4), (True, True, 8)) + @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): + def test_two_spans(self, normalize_y, common_start, width, names): """Test with two sets of spans.""" fig = draw_execution_spans( self.spans0, @@ -85,5 +87,6 @@ def test_two_spans(self, normalize_y, common_start, width): normalize_y=normalize_y, common_start=common_start, line_width=width, + names=names, ) self.save_plotly_artifact(fig) From 7b1f32f140bc1e3bd08cd12629c431db93cf9a23 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 21:35:02 -0400 Subject: [PATCH 14/16] appease linting --- .../visualization/draw_execution_spans.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index e6b71925c..80185611d 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -78,18 +78,19 @@ def draw_execution_spans( # assign a name to each span if names is None: show_legend = False if show_legend is None else show_legend - names = [] + all_names = [] else: show_legend = True if show_legend is None else show_legend if isinstance(names, str): - names = [names] + all_names = [names] # make sure there are always at least as many names as span sets - names.extend( + all_names.extend( f"ExecutionSpans{_get_id(single_span, len(spans)>1)}" for single_span in spans[len(names) :] ) - for single_spans, color, name in zip(spans, cycle(colors), 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 @@ -117,7 +118,6 @@ def draw_execution_spans( y_data.extend([y_value, y_value, None]) text_data.append(text) - # put all data for this ExecutionSpans into one Scatter trace fig.add_trace( go.Scatter( x=x_data, @@ -130,7 +130,7 @@ def draw_execution_spans( ) ) - # Axis and layout settings + # axis and layout settings fig.update_layout( xaxis={"title": "Time", "type": "date"}, showlegend=show_legend, From 821039682d0064cd2a84205b158e7553cfb0bd50 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 21:42:19 -0400 Subject: [PATCH 15/16] try again --- qiskit_ibm_runtime/visualization/draw_execution_spans.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index 80185611d..a08105d9f 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -86,7 +86,8 @@ def draw_execution_spans( # 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(names) :] + 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 @@ -100,11 +101,12 @@ def draw_execution_spans( 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 (unix epoch) and suppress - # plotting the year/month. + # 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 = [] @@ -118,6 +120,7 @@ def draw_execution_spans( 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, From 569bb71eeaef40b168147cb932b62a23e547d1d5 Mon Sep 17 00:00:00 2001 From: Ian Hincks Date: Tue, 29 Oct 2024 21:53:42 -0400 Subject: [PATCH 16/16] fix --- qiskit_ibm_runtime/visualization/draw_execution_spans.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qiskit_ibm_runtime/visualization/draw_execution_spans.py b/qiskit_ibm_runtime/visualization/draw_execution_spans.py index a08105d9f..2fab7180c 100644 --- a/qiskit_ibm_runtime/visualization/draw_execution_spans.py +++ b/qiskit_ibm_runtime/visualization/draw_execution_spans.py @@ -76,13 +76,15 @@ def draw_execution_spans( 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 - all_names = [] 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(